diff --git a/build/make_tipp_bild.py b/build/make_tipp_bild.py new file mode 100644 index 0000000..da693e7 --- /dev/null +++ b/build/make_tipp_bild.py @@ -0,0 +1,61 @@ +"""Erzeugt provtool/gui/bilder/tipp.png (freigestellt, verkleinert, ohne Metadaten) aus dem Foto +für den „Tipp des Tages“. Nur zum Entwickeln nötig (Pillow): python build/make_tipp_bild.py """ +import sys +from collections import deque +from pathlib import Path + +from PIL import Image + +ZIEL_HOEHE = 170 +ROOT = Path(__file__).resolve().parents[1] + + +def freistellen(im: Image.Image, schwelle: int = 232, min_flaeche: int = 6000) -> Image.Image: + """Weißen Hintergrund transparent machen: alle hellen Flächen, die den Rand berühren ODER groß + sind (z. B. das Innere der Büroklammer-Schlaufen). Kleine helle Flächen (Augenweiß) bleiben.""" + im = im.convert("RGBA") + w, h = im.size + px = im.load() + hell = lambda p: p[0] >= schwelle and p[1] >= schwelle and p[2] >= schwelle # noqa: E731 + gesehen = bytearray(w * h) + for sy in range(h): + for sx in range(w): + if gesehen[sy * w + sx] or not hell(px[sx, sy]): + continue + flaeche, rand, q = [], False, deque([(sx, sy)]) + gesehen[sy * w + sx] = 1 + while q: + x, y = q.popleft() + flaeche.append((x, y)) + if x in (0, w - 1) or y in (0, h - 1): + rand = True + for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): + if 0 <= nx < w and 0 <= ny < h and not gesehen[ny * w + nx] and hell(px[nx, ny]): + gesehen[ny * w + nx] = 1 + q.append((nx, ny)) + if rand or len(flaeche) >= min_flaeche: + for x, y in flaeche: + px[x, y] = (255, 255, 255, 0) + return im.crop(im.getbbox()) + + +def main(foto: str, ziel: Path, modus: str = "lanczos"): + im = freistellen(Image.open(foto)) + faktor = ZIEL_HOEHE / im.height + groesse = (max(1, round(im.width * faktor)), ZIEL_HOEHE) + if modus == "raster": # erst auf das Pixel-Raster (ca. 8 px) mitteln, dann hochskalieren + klein = im.resize((im.width // 8, im.height // 8), Image.BOX) + im = klein.resize(groesse, Image.NEAREST) + else: + im = im.resize(groesse, Image.LANCZOS) + # Halbtransparente Randpixel hart machen (Tk/Win95-Optik, keine weißen Säume) + alpha = im.getchannel("A").point(lambda a: 255 if a >= 128 else 0) + im.putalpha(alpha) + ziel.parent.mkdir(parents=True, exist_ok=True) + im.save(ziel, optimize=True) # PNG ohne EXIF-Metadaten + print(ziel, im.size) + + +if __name__ == "__main__": + main(sys.argv[1], Path(sys.argv[2]) if len(sys.argv) > 2 else ROOT / "provtool" / "gui" / "bilder" / "tipp.png", + sys.argv[3] if len(sys.argv) > 3 else "lanczos") diff --git a/build/provisionsmanager.spec b/build/provisionsmanager.spec index f2b795f..c3ea6ac 100644 --- a/build/provisionsmanager.spec +++ b/build/provisionsmanager.spec @@ -8,6 +8,7 @@ a = Analysis( [os.path.join(ROOT, "build", "start.py")], pathex=[ROOT], hiddenimports=["openpyxl", "xlrd"], + datas=[(os.path.join(ROOT, "provtool", "gui", "bilder"), os.path.join("provtool", "gui", "bilder"))], excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"], noarchive=False, ) diff --git a/provtool/__init__.py b/provtool/__init__.py index c72c481..5f80904 100644 --- a/provtool/__init__.py +++ b/provtool/__init__.py @@ -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" diff --git a/provtool/gui/assistent.py b/provtool/gui/assistent.py index db83167..1027cc2 100644 --- a/provtool/gui/assistent.py +++ b/provtool/gui/assistent.py @@ -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)) diff --git a/provtool/gui/bilder/tipp.png b/provtool/gui/bilder/tipp.png new file mode 100644 index 0000000..87a9f38 Binary files /dev/null and b/provtool/gui/bilder/tipp.png differ diff --git a/provtool/gui/rahmen.py b/provtool/gui/rahmen.py index 70e335e..a40a529 100644 --- a/provtool/gui/rahmen.py +++ b/provtool/gui/rahmen.py @@ -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) diff --git a/provtool/gui/win32.py b/provtool/gui/win32.py new file mode 100644 index 0000000..3eca622 --- /dev/null +++ b/provtool/gui/win32.py @@ -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 diff --git a/provtool/gui/win95.py b/provtool/gui/win95.py index bd93e16..6f7f408 100644 --- a/provtool/gui/win95.py +++ b/provtool/gui/win95.py @@ -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("", 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() diff --git a/provtool/selbsttest.py b/provtool/selbsttest.py index 51ac9d6..c062d1b 100644 --- a/provtool/selbsttest.py +++ b/provtool/selbsttest.py @@ -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: diff --git a/tests/test_gui.py b/tests/test_gui.py index 3ea5a53..6c2437f 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -448,3 +448,113 @@ def test_hauptfenster_ablaeufe(lauf_mit_daten): assert not lauf.uebrig, f"nicht ausgeführt: {lauf.uebrig}" assert any(p["prozent"] == 80 for p in app.positionen) lauf.pruefen() + + +# =========================================================================== Windows: Popups bleiben erreichbar + +import sys # noqa: E402 + +nur_windows = pytest.mark.skipif(not sys.platform.startswith("win"), + reason="Win32-Fensterbesitz gibt es nur unter Windows (im Build: Wine)") + + +@nur_windows +def test_windows_popup_kommt_nach_alt_tab_wieder_nach_vorn(lauf_mit_daten): + """Gemeldet: Popup offen, woanders hingeklickt – danach kam das Popup nicht mehr nach vorn + (auch nicht per Alt+Tab). Geprüft wird: Popup gehört dem Hauptfenster, das Hauptfenster ist + gesperrt, und holt man das Hauptfenster zurück, liegt das Popup darüber.""" + from provtool.gui import win32 + from provtool.gui.dialogs import InfoDialog + lauf = lauf_mit_daten + m = {} + + def messen(): + d = lauf.dialog(InfoDialog) + fremd = tk.Toplevel(lauf.root) # steht für ein anderes Programm + fremd.geometry("400x300+50+50") + fremd.update() + m.update(besitzer=win32.besitzer_von(d), haupt=win32.hwnd(lauf.root), + haupt_bedienbar=win32.ist_bedienbar(lauf.root)) + win32.nach_vorn(fremd) # „woanders hingeklickt“ + lauf.root.update() + m["woanders"] = win32.stapel(d, lauf.root, fremd) + win32.nach_vorn(lauf.root) # Alt+Tab / Taskleiste zurück zu uns + lauf.root.update() + m["zurueck"] = win32.stapel(d, lauf.root, fremd) + fremd.destroy() + d.abbrechen() + + lauf.ablauf([ + (IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Info öffnen"), + (lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.root.after(150, messen), "messen"), + (lambda: "zurueck" in m and lauf.dialog(InfoDialog) is None, lambda: None, "geschlossen"), + ], sekunden=10) + assert not lauf.uebrig, lauf.uebrig + assert m["besitzer"] == m["haupt"], "Popup gehört nicht dem Hauptfenster" + assert m["haupt_bedienbar"] is False, "Hauptfenster muss gesperrt sein, solange das Popup offen ist" + assert m["woanders"][0] == 2, f"Testaufbau: das fremde Fenster sollte oben liegen ({m['woanders']})" + assert m["zurueck"][:2] == [0, 1], f"Popup liegt nach dem Zurückholen nicht über dem Hauptfenster: {m['zurueck']}" + assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Schließen nicht wieder freigegeben" + lauf.pruefen() + + +@nur_windows +def test_windows_verschachtelte_rueckfrage(lauf_mit_daten): + """Händler-Dialog → Rückfrage: Rückfrage gehört dem Händler-Dialog; beim Schließen der Reihe nach frei.""" + from provtool.gui import win32 + from provtool.gui import win95 as w + from provtool.gui.dialogs import HaendlerDialog + lauf = lauf_mit_daten + m = {} + rueckfrage = lambda: next((d for d in lauf.dialoge() if d.title() == "Test-Rückfrage"), None) # noqa: E731 + + def messen_innen(): + h, r = lauf.dialog(HaendlerDialog), rueckfrage() + m.update(r_besitzer=win32.besitzer_von(r), h=win32.hwnd(h), h_an=win32.ist_bedienbar(h), + haupt_an=win32.ist_bedienbar(lauf.root)) + r.destroy() + + def messen_aussen(): + h = lauf.dialog(HaendlerDialog) + m.update(h_an_danach=win32.ist_bedienbar(h), haupt_an_danach=win32.ist_bedienbar(lauf.root)) + h.abbrechen() + + lauf.ablauf([ + (IMMER, lauf.app.haendler, "Händler-Dialog öffnen"), + (lambda: lauf.dialog(HaendlerDialog) is not None, + lambda: w.info(lauf.dialog(HaendlerDialog), "Test-Rückfrage", "?"), "Rückfrage öffnen"), + (lambda: rueckfrage() is not None, lambda: lauf.root.after(100, messen_innen), "innen messen"), + (lambda: "h" in m and rueckfrage() is None, messen_aussen, "außen messen"), + (lambda: lauf.dialog(HaendlerDialog) is None, lambda: None, "alles zu"), + ], sekunden=12) + assert not lauf.uebrig, lauf.uebrig + assert m["r_besitzer"] == m["h"], "Rückfrage gehört nicht dem Händler-Dialog" + assert m["h_an"] is False and m["haupt_an"] is False + assert m["h_an_danach"] is True and m["haupt_an_danach"] is False + assert win32.ist_bedienbar(lauf.root) + lauf.pruefen() + + +@nur_windows +def test_windows_rundgang_sperrt_und_gibt_frei(lauf_mit_daten): + from provtool.gui import win32 + lauf = lauf_mit_daten + rg = lambda: getattr(lauf.app, "_rundgang", None) # noqa: E731 + m = {} + + def messen_und_weiter(): + m.setdefault("gesperrt", []).append(not win32.ist_bedienbar(lauf.root)) + m.setdefault("besitzer", []).append(win32.besitzer_von(rg().blase) == win32.hwnd(lauf.root)) + rg()._weiter() + + schritte = [(IMMER, lauf.app.rundgang, "Rundgang starten")] + for i in range(3): + schritte.append((lambda i=i: rg() is not None and rg().blase is not None and rg().nr == i, + messen_und_weiter, f"Schritt {i + 1}")) + schritte.append((IMMER, lambda: rg().beenden(), "Rundgang beenden")) + lauf.ablauf(schritte, sekunden=12) + assert not lauf.uebrig, lauf.uebrig + assert all(m["gesperrt"]), "Hauptfenster während des Rundgangs nicht gesperrt" + assert all(m["besitzer"]), "Sprechblase gehört nicht dem Hauptfenster" + assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Rundgang nicht freigegeben" + lauf.pruefen()