Popups kommen nach Klick woanders / Alt+Tab wieder nach vorn; Büroklammer im Tipp des Tages
Windows-Build / windows-exe (push) Failing after 3m10s
Windows-Build / windows-exe (push) Failing after 3m10s
Fehler: War ein Popup offen und man klickte in ein anderes Programm, ließ sich das Popup über Hauptfenster, Taskleiste oder Alt+Tab nicht mehr nach vorn holen. Ursache: Rahmenlose Tk-Fenster mit transient() legt Tk unter Windows als Kindfenster des Desktops (WS_CHILD) an – ohne Besitzer, daher kam beim Zurückholen nur das (gesperrte) Hauptfenster nach vorn. - Popups (Dialoge, Rückfragen, Rundgang) ohne transient(), stattdessen Windows-Besitzer + Sperre des aufrufenden Fensters wie bei echten modalen Windows-Dialogen (gui/win32.py) - Rundgang: neue Sprechblase erscheint vor dem Entfernen der alten (sonst springt Windows weg) - Alt+F4 und Modalsperre robust gegen Timing (Sperre wird bei Bedarf nachgeholt) - Tests (nur unter Windows/Wine): Besitzer, Sperre, Stapelreihenfolge nach „woanders klicken“ und Zurückholen, verschachtelte Rückfragen, Rundgang - Tipp des Tages mit freigestelltem Büroklammer-Bild (build/make_tipp_bild.py), in der .exe enthalten und vom Selbsttest geprüft Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0bcc22d8c9
commit
9f05cab952
10 files changed
+417
-29
No files matched your search
@@ -1,5 +1,5 @@
|
||||
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
|
||||
|
||||
__version__ = "1.1.1"
|
||||
__version__ = "1.1.2"
|
||||
APP_NAME = "Provisions-Manager 95"
|
||||
APP_ORG = "Handy-Insel Miesbach"
|
||||
+54
-22
@@ -9,6 +9,7 @@ from tkinter import filedialog, ttk
|
||||
|
||||
from provtool import APP_NAME
|
||||
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Haendler, ist_grossgeschrieben
|
||||
from provtool.gui import win32
|
||||
from provtool.gui import win95 as w
|
||||
from provtool.importer import lies_datei
|
||||
from provtool.money import fmt, parse_percent
|
||||
@@ -406,41 +407,54 @@ class Rundgang:
|
||||
self.nr = 0
|
||||
self.blase: tk.Toplevel | None = None
|
||||
self.rahmen: list[tk.Toplevel] = []
|
||||
self._sperre = None # Windows: Hauptfenster während des Rundgangs gesperrt (win32.Modal)
|
||||
self._zeige(0)
|
||||
|
||||
def _weg(self):
|
||||
for r in self.rahmen:
|
||||
@staticmethod
|
||||
def _entsorgen(blase, rahmen):
|
||||
for r in rahmen:
|
||||
try:
|
||||
r.destroy()
|
||||
except tk.TclError:
|
||||
pass
|
||||
if blase is not None:
|
||||
try:
|
||||
blase.grab_release()
|
||||
except tk.TclError:
|
||||
pass
|
||||
try:
|
||||
blase.destroy()
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
def _weg(self):
|
||||
self._entsorgen(self.blase, self.rahmen)
|
||||
self.rahmen = []
|
||||
if self.blase is not None:
|
||||
try:
|
||||
self.blase.grab_release()
|
||||
except tk.TclError:
|
||||
pass
|
||||
try:
|
||||
self.blase.destroy()
|
||||
except tk.TclError:
|
||||
pass
|
||||
self.blase = None
|
||||
self.blase = None
|
||||
|
||||
def beenden(self):
|
||||
if self._sperre is not None: # vor dem Schließen freigeben, sonst springt Windows woandershin
|
||||
self._sperre.beenden()
|
||||
self._sperre = None
|
||||
self._weg()
|
||||
|
||||
def _markieren(self, x, y, bw, bh, dicke=3, farbe="#ff0000"):
|
||||
for gx, gy, gw, gh in ((x - dicke, y - dicke, bw + 2 * dicke, dicke), (x - dicke, y + bh, bw + 2 * dicke, dicke),
|
||||
(x - dicke, y, dicke, bh), (x + bw, y, dicke, bh)):
|
||||
t = tk.Toplevel(self.root, bg=farbe)
|
||||
t.overrideredirect(True)
|
||||
t.transient(self.root)
|
||||
t.overrideredirect(True) # kein transient() – siehe win32.py
|
||||
t.geometry(f"{max(1, gw)}x{max(1, gh)}+{gx}+{gy}")
|
||||
t.update_idletasks()
|
||||
win32.besitzer_setzen(t, self.root) # bleibt über dem Hauptfenster, auch nach Alt+Tab
|
||||
self.rahmen.append(t)
|
||||
|
||||
def _zeige(self, nr: int):
|
||||
self._weg()
|
||||
# Die alte Sprechblase verschwindet erst, wenn die neue steht: Wäre kurz kein Popup da,
|
||||
# während das Hauptfenster gesperrt ist, würde Windows ein fremdes Programm aktivieren.
|
||||
alt_blase, alt_rahmen = self.blase, self.rahmen
|
||||
self.blase, self.rahmen = None, []
|
||||
if getattr(self.root, "_provtool_schliesst", False):
|
||||
self._entsorgen(alt_blase, alt_rahmen)
|
||||
return
|
||||
self.nr = nr
|
||||
ziel, titel, text = self.schritte[nr]
|
||||
@@ -454,8 +468,7 @@ class Rundgang:
|
||||
b = self.blase = tk.Toplevel(self.root, bg=w.DARK)
|
||||
b.abbrechen = self.beenden # Alt+F4 auf der Sprechblase beendet den Rundgang sauber
|
||||
b.withdraw()
|
||||
b.overrideredirect(True)
|
||||
b.transient(self.root)
|
||||
b.overrideredirect(True) # kein transient() – Besitzer setzt win32.Modal
|
||||
innen = tk.Frame(b, bg=w.INFO_BG, padx=10, pady=8)
|
||||
innen.pack(padx=1, pady=1)
|
||||
kopf = tk.Frame(innen, bg=w.INFO_BG)
|
||||
@@ -497,6 +510,13 @@ class Rundgang:
|
||||
b.geometry(f"{pw}x{ph}+{px}+{py}")
|
||||
b.deiconify()
|
||||
b.lift()
|
||||
b.update_idletasks()
|
||||
b.focus_force()
|
||||
if self._sperre is None:
|
||||
self._sperre = win32.Modal(b, self.root)
|
||||
else:
|
||||
win32.besitzer_setzen(b, self.root)
|
||||
self._entsorgen(alt_blase, alt_rahmen)
|
||||
try:
|
||||
b.grab_set()
|
||||
except tk.TclError:
|
||||
@@ -539,6 +559,17 @@ TIPPS = [
|
||||
]
|
||||
|
||||
|
||||
BILDER = Path(__file__).resolve().parent / "bilder"
|
||||
|
||||
|
||||
def tipp_bild(master) -> tk.PhotoImage | None:
|
||||
"""Der Büroklammer-Assistent für den Tipp des Tages (liegt auch in der .exe, siehe .spec)."""
|
||||
try:
|
||||
return tk.PhotoImage(master=master, file=str(BILDER / "tipp.png"))
|
||||
except tk.TclError:
|
||||
return None
|
||||
|
||||
|
||||
class TippDesTages(w.Dialog):
|
||||
def __init__(self, app):
|
||||
super().__init__(app.root, "Tipp des Tages")
|
||||
@@ -547,16 +578,17 @@ class TippDesTages(w.Dialog):
|
||||
haupt.pack(padx=12, pady=12)
|
||||
box = tk.Frame(haupt, bg=w.WHITE, bd=2, relief="sunken")
|
||||
box.pack(side="left", fill="both")
|
||||
links = tk.Frame(box, bg=w.SHADOW, width=60)
|
||||
links.pack(side="left", fill="y")
|
||||
self._ic = w.make_icon(self, "birne", 3)
|
||||
tk.Label(links, image=self._ic, bg=w.SHADOW).pack(padx=6, pady=10)
|
||||
self._bild = tipp_bild(self)
|
||||
self.bild_ok = self._bild is not None
|
||||
if self._bild is None: # Ersatz, falls das Bild fehlt
|
||||
self._bild = w.make_icon(self, "birne", 3)
|
||||
tk.Label(box, image=self._bild, bg=w.WHITE).pack(side="left", anchor="n", padx=(12, 4), pady=10)
|
||||
rechts = tk.Frame(box, bg=w.WHITE, padx=12, pady=10)
|
||||
rechts.pack(side="left", fill="both")
|
||||
tk.Label(rechts, text="Wussten Sie schon…", bg=w.WHITE, font=(w.FONT_FAMILY, w.FONT_SIZE + 6, "bold"),
|
||||
anchor="w").pack(fill="x")
|
||||
tk.Frame(rechts, bg=w.DARK, height=1).pack(fill="x", pady=6)
|
||||
self.text = tk.Label(rechts, bg=w.WHITE, justify="left", anchor="nw", wraplength=330, width=48, height=6)
|
||||
self.text = tk.Label(rechts, bg=w.WHITE, justify="left", anchor="nw", wraplength=330, width=48, height=8)
|
||||
self.text.pack(fill="both")
|
||||
knoepfe = tk.Frame(haupt, bg=w.FACE)
|
||||
knoepfe.pack(side="left", anchor="n", padx=(10, 0))
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -265,8 +265,11 @@ class Hauptrahmen:
|
||||
# (unter Windows liefert Tk Tastendrücke an das Fenster mit Fokus, das kann noch das
|
||||
# Hauptfenster sein).
|
||||
sperre = self.root.grab_current()
|
||||
popup = self._offenes_popup()
|
||||
if sperre is not None and sperre.winfo_toplevel() is not self.root:
|
||||
fenster = sperre.winfo_toplevel()
|
||||
elif popup is not None: # Sperre (noch) nicht gesetzt – trotzdem gilt Alt+F4 dem Popup
|
||||
fenster = popup
|
||||
else:
|
||||
fenster = widget.winfo_toplevel() if isinstance(widget, tk.Misc) else self.root
|
||||
except tk.TclError:
|
||||
@@ -279,6 +282,18 @@ class Hauptrahmen:
|
||||
fenster.destroy()
|
||||
return "break"
|
||||
|
||||
def _offenes_popup(self):
|
||||
"""Innerstes sichtbares Popup mit abbrechen() (Dialog, Rückfrage, Rundgang-Sprechblase)."""
|
||||
gefunden = []
|
||||
|
||||
def suche(widget):
|
||||
for kind in widget.winfo_children():
|
||||
if isinstance(kind, tk.Toplevel) and hasattr(kind, "abbrechen") and kind.winfo_ismapped():
|
||||
gefunden.append(kind)
|
||||
suche(kind)
|
||||
suche(self.root)
|
||||
return gefunden[-1] if gefunden else None
|
||||
|
||||
# ---- öffentliche Helfer
|
||||
def set_titel(self, text: str):
|
||||
self.root.title(text)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Win32-Hilfen für modale Fenster (nur unter Windows wirksam, sonst ohne Funktion).
|
||||
|
||||
Wichtig: Rahmenlose Fenster NICHT zusätzlich mit Tk `transient()` versehen – Tk legt sie dann als
|
||||
Kindfenster des Desktops (WS_CHILD) an, und denen kann Windows keinen Besitzer geben.
|
||||
|
||||
Rahmenlose Tk-Fenster (overrideredirect) gehören für Windows zu keinem anderen Fenster. Holt man das
|
||||
Programm per Taskleiste oder Alt+Tab zurück, kommt dann nur das Hauptfenster nach vorn – ein offener
|
||||
Dialog bliebe dahinter verborgen. Deshalb machen wir es wie Windows bei echten modalen Dialogen:
|
||||
der Dialog bekommt das aufrufende Fenster als *Besitzer*, und der Besitzer wird solange *deaktiviert*.
|
||||
Windows bringt dann bei Klick aufs Hauptfenster, Taskleiste und Alt+Tab automatisch den Dialog nach vorn.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tkinter as tk
|
||||
|
||||
IS_WIN = sys.platform.startswith("win")
|
||||
GWLP_HWNDPARENT = -8
|
||||
GW_OWNER = 4
|
||||
|
||||
_u32 = None
|
||||
|
||||
|
||||
def user32():
|
||||
"""user32 mit korrekten 64-Bit-Signaturen (Fensterhandles dürfen nicht abgeschnitten werden)."""
|
||||
global _u32
|
||||
if _u32 is None:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
u = ctypes.WinDLL("user32", use_last_error=True)
|
||||
u.GetParent.argtypes = [wintypes.HWND]
|
||||
u.GetParent.restype = wintypes.HWND
|
||||
u.SetWindowLongPtrW.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_void_p]
|
||||
u.SetWindowLongPtrW.restype = ctypes.c_void_p
|
||||
u.EnableWindow.argtypes = [wintypes.HWND, wintypes.BOOL]
|
||||
u.EnableWindow.restype = wintypes.BOOL
|
||||
u.IsWindowEnabled.argtypes = [wintypes.HWND]
|
||||
u.IsWindowEnabled.restype = wintypes.BOOL
|
||||
u.GetWindow.argtypes = [wintypes.HWND, wintypes.UINT]
|
||||
u.GetWindow.restype = wintypes.HWND
|
||||
u.GetLastActivePopup.argtypes = [wintypes.HWND]
|
||||
u.GetLastActivePopup.restype = wintypes.HWND
|
||||
u.SetForegroundWindow.argtypes = [wintypes.HWND]
|
||||
u.SetForegroundWindow.restype = wintypes.BOOL
|
||||
u.IsWindow.argtypes = [wintypes.HWND]
|
||||
u.IsWindow.restype = wintypes.BOOL
|
||||
u.GetAncestor.argtypes = [wintypes.HWND, wintypes.UINT]
|
||||
u.GetAncestor.restype = wintypes.HWND
|
||||
u.GetTopWindow.argtypes = [wintypes.HWND]
|
||||
u.GetTopWindow.restype = wintypes.HWND
|
||||
u.BringWindowToTop.argtypes = [wintypes.HWND]
|
||||
u.BringWindowToTop.restype = wintypes.BOOL
|
||||
_u32 = u
|
||||
return _u32
|
||||
|
||||
|
||||
def hwnd(fenster: tk.Misc) -> int | None:
|
||||
"""Handle des äußeren (obersten) Windows-Fensters eines Tk-Toplevels."""
|
||||
if not IS_WIN:
|
||||
return None
|
||||
try:
|
||||
return user32().GetAncestor(fenster.winfo_toplevel().winfo_id(), 2) # GA_ROOT
|
||||
except (tk.TclError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def stapel(*fenster: tk.Misc) -> list[int]:
|
||||
"""Reihenfolge der übergebenen Fenster im Windows-Fensterstapel, oberstes zuerst (Indizes)."""
|
||||
ziele = [hwnd(f) for f in fenster]
|
||||
out, w = [], user32().GetTopWindow(None)
|
||||
while w:
|
||||
if w in ziele:
|
||||
out.append(ziele.index(w))
|
||||
w = user32().GetWindow(w, 2) # GW_HWNDNEXT
|
||||
return out
|
||||
|
||||
|
||||
def nach_vorn(fenster: tk.Misc) -> None:
|
||||
h = hwnd(fenster)
|
||||
if h:
|
||||
user32().SetForegroundWindow(h)
|
||||
user32().BringWindowToTop(h)
|
||||
|
||||
|
||||
def besitzer_setzen(fenster: tk.Misc, besitzer: tk.Misc) -> None:
|
||||
h, b = hwnd(fenster), hwnd(besitzer)
|
||||
if h and b:
|
||||
user32().SetWindowLongPtrW(h, GWLP_HWNDPARENT, b)
|
||||
|
||||
|
||||
def besitzer_von(fenster: tk.Misc) -> int | None:
|
||||
h = hwnd(fenster)
|
||||
return user32().GetWindow(h, GW_OWNER) if h else None
|
||||
|
||||
|
||||
def bedienbar(fenster: tk.Misc, an: bool) -> None:
|
||||
h = hwnd(fenster)
|
||||
if h and user32().IsWindow(h):
|
||||
user32().EnableWindow(h, bool(an))
|
||||
|
||||
|
||||
def ist_bedienbar(fenster: tk.Misc) -> bool:
|
||||
h = hwnd(fenster)
|
||||
return bool(user32().IsWindowEnabled(h)) if h else True
|
||||
|
||||
|
||||
def letztes_popup(fenster: tk.Misc) -> int | None:
|
||||
h = hwnd(fenster)
|
||||
return user32().GetLastActivePopup(h) if h else None
|
||||
|
||||
|
||||
class Modal:
|
||||
"""Macht `fenster` modal zu `besitzer` (Windows-Art). beenden() muss VOR dem Zerstören des
|
||||
Fensters laufen: erst Besitzer wieder freigeben, dann schließen – sonst aktiviert Windows
|
||||
beim Schließen ein fremdes Programm statt unseres Hauptfensters."""
|
||||
|
||||
def __init__(self, fenster: tk.Misc, besitzer: tk.Misc, sperren: bool = True):
|
||||
self.besitzer = besitzer
|
||||
self.gesperrt = False
|
||||
if not IS_WIN:
|
||||
return
|
||||
besitzer_setzen(fenster, besitzer)
|
||||
if sperren and ist_bedienbar(besitzer):
|
||||
bedienbar(besitzer, False)
|
||||
self.gesperrt = True
|
||||
|
||||
def beenden(self) -> None:
|
||||
if self.gesperrt:
|
||||
self.gesperrt = False
|
||||
try:
|
||||
bedienbar(self.besitzer, True)
|
||||
except tk.TclError:
|
||||
pass
|
||||
+31
-5
@@ -508,9 +508,13 @@ class Dialog(tk.Toplevel):
|
||||
super().__init__(master, bg=FACE)
|
||||
self.withdraw()
|
||||
self.title(titel)
|
||||
self.transient(master.winfo_toplevel())
|
||||
if not KLASSISCH:
|
||||
# Rahmenlose Dialoge bekommen ihren Besitzer über win32.Modal – mit transient() würde Tk
|
||||
# sie unter Windows als Kindfenster anlegen, die dann hinter anderen Programmen hängen.
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.resizable(resizable, resizable)
|
||||
self.result = None
|
||||
self._modal = None # Windows: Besitzer + Sperre des aufrufenden Fensters (siehe win32.Modal)
|
||||
self.protocol("WM_DELETE_WINDOW", self.abbrechen)
|
||||
self.bind("<Escape>", lambda e: self.abbrechen())
|
||||
try:
|
||||
@@ -537,11 +541,14 @@ class Dialog(tk.Toplevel):
|
||||
self.geometry(f"+{max(0, x)}+{max(0, y)}")
|
||||
self.deiconify()
|
||||
self.lift()
|
||||
# Windows: Dialog gehört dem aufrufenden Fenster, das solange gesperrt ist → Klick aufs
|
||||
# Hauptfenster, Taskleiste und Alt+Tab holen automatisch den Dialog nach vorn.
|
||||
from provtool.gui.win32 import Modal
|
||||
self.update_idletasks()
|
||||
self.focus_force()
|
||||
self._modal = Modal(self, m)
|
||||
vorher = self.grab_current() # z. B. der Assistent, wenn hier eine Rückfrage aufgeht
|
||||
try:
|
||||
self.grab_set()
|
||||
except tk.TclError:
|
||||
pass
|
||||
self._sperre_setzen()
|
||||
bind_id = None
|
||||
if KLASSISCH:
|
||||
# Ein rahmenloser Dialog darf nicht hinter dem Hauptfenster verschwinden
|
||||
@@ -563,6 +570,25 @@ class Dialog(tk.Toplevel):
|
||||
pass
|
||||
return self.result
|
||||
|
||||
def _sperre_setzen(self, versuch: int = 0):
|
||||
"""Tk-Modalsperre setzen. Ist das Fenster noch nicht ganz sichtbar (langsamer PC, Wine),
|
||||
schlägt grab_set fehl – dann kurz danach erneut versuchen statt ohne Sperre weiterzumachen."""
|
||||
try:
|
||||
if self.winfo_exists():
|
||||
self.grab_set()
|
||||
except tk.TclError:
|
||||
if versuch < 50:
|
||||
self.after(20, lambda: self._sperre_setzen(versuch + 1))
|
||||
|
||||
def destroy(self):
|
||||
# Erst das aufrufende Fenster wieder freigeben, dann schließen – sonst aktiviert Windows
|
||||
# beim Schließen ein fremdes Programm statt unseres Fensters.
|
||||
modal = getattr(self, "_modal", None)
|
||||
if modal is not None:
|
||||
modal.beenden()
|
||||
self._modal = None
|
||||
super().destroy()
|
||||
|
||||
def abbrechen(self):
|
||||
self.result = None
|
||||
self.destroy()
|
||||
|
||||
+11
-1
@@ -148,7 +148,15 @@ def ausfuehren(ergebnisdatei: str) -> int:
|
||||
if len(pos) != 2 or len(dateien) != 1 or not dateien[0].exists():
|
||||
s.fehler.append(f"Export fehlerhaft: {len(pos)} Positionen, Dateien {dateien}")
|
||||
|
||||
from provtool.gui.assistent import TippDesTages
|
||||
from provtool.gui.dialogs import ImportBericht
|
||||
|
||||
def tipp_pruefen():
|
||||
d = s.dialog(TippDesTages)
|
||||
if not d.bild_ok:
|
||||
s.fehler.append("Bild für den Tipp des Tages fehlt in der .exe (provtool/gui/bilder/tipp.png)")
|
||||
d.abbrechen()
|
||||
|
||||
fehler += s.ablauf([
|
||||
(seite(0), lambda: wiz()._weiter(), "Willkommen"),
|
||||
(seite(1), lambda: wiz()._weiter(), "Speicherort"),
|
||||
@@ -158,7 +166,9 @@ def ausfuehren(ergebnisdatei: str) -> int:
|
||||
(seite(4), lambda: wiz()._weiter(), "Fertig stellen"),
|
||||
(lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"),
|
||||
(lambda: len(s.app.positionen) == 2, export, "Export"),
|
||||
(immer, s.app.rundgang, "Rundgang"),
|
||||
(immer, s.app.tipp_des_tages, "Tipp des Tages öffnen"),
|
||||
(lambda: s.dialog(TippDesTages) is not None, tipp_pruefen, "Tipp-Bild in der .exe"),
|
||||
(lambda: s.dialog(TippDesTages) is None, s.app.rundgang, "Rundgang"),
|
||||
(lambda: getattr(s.app, "_rundgang", None) is not None, s.app.beenden, "Beenden während Rundgang"),
|
||||
])
|
||||
except Exception:
|
||||
|
||||
Reference in new issue
Block a user