Popups kommen nach Klick woanders / Alt+Tab wieder nach vorn; Büroklammer im Tipp des Tages

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:
adminandClaude Opus 5.5 committed 2026-11-02 11:26:17 +01:00
1 parent aae28fa7f2
commit 26ac90bb0f
10 files changed
+417 -29

No files matched your search

+54 -22
View File
@@ -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

+15
View File
@@ -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)
+133
View File
@@ -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
View File
@@ -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()