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:
1 parent
aae28fa7f2
commit
26ac90bb0f
10 files changed
+417
-29
No files matched your search
@@ -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 <foto>"""
|
||||||
|
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")
|
||||||
@@ -8,6 +8,7 @@ a = Analysis(
|
|||||||
[os.path.join(ROOT, "build", "start.py")],
|
[os.path.join(ROOT, "build", "start.py")],
|
||||||
pathex=[ROOT],
|
pathex=[ROOT],
|
||||||
hiddenimports=["openpyxl", "xlrd"],
|
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"],
|
excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"],
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
|
"""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_NAME = "Provisions-Manager 95"
|
||||||
APP_ORG = "Handy-Insel Miesbach"
|
APP_ORG = "Handy-Insel Miesbach"
|
||||||
+54
-22
@@ -9,6 +9,7 @@ from tkinter import filedialog, ttk
|
|||||||
|
|
||||||
from provtool import APP_NAME
|
from provtool import APP_NAME
|
||||||
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Haendler, ist_grossgeschrieben
|
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.gui import win95 as w
|
||||||
from provtool.importer import lies_datei
|
from provtool.importer import lies_datei
|
||||||
from provtool.money import fmt, parse_percent
|
from provtool.money import fmt, parse_percent
|
||||||
@@ -406,41 +407,54 @@ class Rundgang:
|
|||||||
self.nr = 0
|
self.nr = 0
|
||||||
self.blase: tk.Toplevel | None = None
|
self.blase: tk.Toplevel | None = None
|
||||||
self.rahmen: list[tk.Toplevel] = []
|
self.rahmen: list[tk.Toplevel] = []
|
||||||
|
self._sperre = None # Windows: Hauptfenster während des Rundgangs gesperrt (win32.Modal)
|
||||||
self._zeige(0)
|
self._zeige(0)
|
||||||
|
|
||||||
def _weg(self):
|
@staticmethod
|
||||||
for r in self.rahmen:
|
def _entsorgen(blase, rahmen):
|
||||||
|
for r in rahmen:
|
||||||
try:
|
try:
|
||||||
r.destroy()
|
r.destroy()
|
||||||
except tk.TclError:
|
except tk.TclError:
|
||||||
pass
|
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 = []
|
self.rahmen = []
|
||||||
if self.blase is not None:
|
self.blase = None
|
||||||
try:
|
|
||||||
self.blase.grab_release()
|
|
||||||
except tk.TclError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
self.blase.destroy()
|
|
||||||
except tk.TclError:
|
|
||||||
pass
|
|
||||||
self.blase = None
|
|
||||||
|
|
||||||
def beenden(self):
|
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()
|
self._weg()
|
||||||
|
|
||||||
def _markieren(self, x, y, bw, bh, dicke=3, farbe="#ff0000"):
|
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),
|
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)):
|
(x - dicke, y, dicke, bh), (x + bw, y, dicke, bh)):
|
||||||
t = tk.Toplevel(self.root, bg=farbe)
|
t = tk.Toplevel(self.root, bg=farbe)
|
||||||
t.overrideredirect(True)
|
t.overrideredirect(True) # kein transient() – siehe win32.py
|
||||||
t.transient(self.root)
|
|
||||||
t.geometry(f"{max(1, gw)}x{max(1, gh)}+{gx}+{gy}")
|
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)
|
self.rahmen.append(t)
|
||||||
|
|
||||||
def _zeige(self, nr: int):
|
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):
|
if getattr(self.root, "_provtool_schliesst", False):
|
||||||
|
self._entsorgen(alt_blase, alt_rahmen)
|
||||||
return
|
return
|
||||||
self.nr = nr
|
self.nr = nr
|
||||||
ziel, titel, text = self.schritte[nr]
|
ziel, titel, text = self.schritte[nr]
|
||||||
@@ -454,8 +468,7 @@ class Rundgang:
|
|||||||
b = self.blase = tk.Toplevel(self.root, bg=w.DARK)
|
b = self.blase = tk.Toplevel(self.root, bg=w.DARK)
|
||||||
b.abbrechen = self.beenden # Alt+F4 auf der Sprechblase beendet den Rundgang sauber
|
b.abbrechen = self.beenden # Alt+F4 auf der Sprechblase beendet den Rundgang sauber
|
||||||
b.withdraw()
|
b.withdraw()
|
||||||
b.overrideredirect(True)
|
b.overrideredirect(True) # kein transient() – Besitzer setzt win32.Modal
|
||||||
b.transient(self.root)
|
|
||||||
innen = tk.Frame(b, bg=w.INFO_BG, padx=10, pady=8)
|
innen = tk.Frame(b, bg=w.INFO_BG, padx=10, pady=8)
|
||||||
innen.pack(padx=1, pady=1)
|
innen.pack(padx=1, pady=1)
|
||||||
kopf = tk.Frame(innen, bg=w.INFO_BG)
|
kopf = tk.Frame(innen, bg=w.INFO_BG)
|
||||||
@@ -497,6 +510,13 @@ class Rundgang:
|
|||||||
b.geometry(f"{pw}x{ph}+{px}+{py}")
|
b.geometry(f"{pw}x{ph}+{px}+{py}")
|
||||||
b.deiconify()
|
b.deiconify()
|
||||||
b.lift()
|
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:
|
try:
|
||||||
b.grab_set()
|
b.grab_set()
|
||||||
except tk.TclError:
|
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):
|
class TippDesTages(w.Dialog):
|
||||||
def __init__(self, app):
|
def __init__(self, app):
|
||||||
super().__init__(app.root, "Tipp des Tages")
|
super().__init__(app.root, "Tipp des Tages")
|
||||||
@@ -547,16 +578,17 @@ class TippDesTages(w.Dialog):
|
|||||||
haupt.pack(padx=12, pady=12)
|
haupt.pack(padx=12, pady=12)
|
||||||
box = tk.Frame(haupt, bg=w.WHITE, bd=2, relief="sunken")
|
box = tk.Frame(haupt, bg=w.WHITE, bd=2, relief="sunken")
|
||||||
box.pack(side="left", fill="both")
|
box.pack(side="left", fill="both")
|
||||||
links = tk.Frame(box, bg=w.SHADOW, width=60)
|
self._bild = tipp_bild(self)
|
||||||
links.pack(side="left", fill="y")
|
self.bild_ok = self._bild is not None
|
||||||
self._ic = w.make_icon(self, "birne", 3)
|
if self._bild is None: # Ersatz, falls das Bild fehlt
|
||||||
tk.Label(links, image=self._ic, bg=w.SHADOW).pack(padx=6, pady=10)
|
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 = tk.Frame(box, bg=w.WHITE, padx=12, pady=10)
|
||||||
rechts.pack(side="left", fill="both")
|
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"),
|
tk.Label(rechts, text="Wussten Sie schon…", bg=w.WHITE, font=(w.FONT_FAMILY, w.FONT_SIZE + 6, "bold"),
|
||||||
anchor="w").pack(fill="x")
|
anchor="w").pack(fill="x")
|
||||||
tk.Frame(rechts, bg=w.DARK, height=1).pack(fill="x", pady=6)
|
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")
|
self.text.pack(fill="both")
|
||||||
knoepfe = tk.Frame(haupt, bg=w.FACE)
|
knoepfe = tk.Frame(haupt, bg=w.FACE)
|
||||||
knoepfe.pack(side="left", anchor="n", padx=(10, 0))
|
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
|
# (unter Windows liefert Tk Tastendrücke an das Fenster mit Fokus, das kann noch das
|
||||||
# Hauptfenster sein).
|
# Hauptfenster sein).
|
||||||
sperre = self.root.grab_current()
|
sperre = self.root.grab_current()
|
||||||
|
popup = self._offenes_popup()
|
||||||
if sperre is not None and sperre.winfo_toplevel() is not self.root:
|
if sperre is not None and sperre.winfo_toplevel() is not self.root:
|
||||||
fenster = sperre.winfo_toplevel()
|
fenster = sperre.winfo_toplevel()
|
||||||
|
elif popup is not None: # Sperre (noch) nicht gesetzt – trotzdem gilt Alt+F4 dem Popup
|
||||||
|
fenster = popup
|
||||||
else:
|
else:
|
||||||
fenster = widget.winfo_toplevel() if isinstance(widget, tk.Misc) else self.root
|
fenster = widget.winfo_toplevel() if isinstance(widget, tk.Misc) else self.root
|
||||||
except tk.TclError:
|
except tk.TclError:
|
||||||
@@ -279,6 +282,18 @@ class Hauptrahmen:
|
|||||||
fenster.destroy()
|
fenster.destroy()
|
||||||
return "break"
|
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
|
# ---- öffentliche Helfer
|
||||||
def set_titel(self, text: str):
|
def set_titel(self, text: str):
|
||||||
self.root.title(text)
|
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)
|
super().__init__(master, bg=FACE)
|
||||||
self.withdraw()
|
self.withdraw()
|
||||||
self.title(titel)
|
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.resizable(resizable, resizable)
|
||||||
self.result = None
|
self.result = None
|
||||||
|
self._modal = None # Windows: Besitzer + Sperre des aufrufenden Fensters (siehe win32.Modal)
|
||||||
self.protocol("WM_DELETE_WINDOW", self.abbrechen)
|
self.protocol("WM_DELETE_WINDOW", self.abbrechen)
|
||||||
self.bind("<Escape>", lambda e: self.abbrechen())
|
self.bind("<Escape>", lambda e: self.abbrechen())
|
||||||
try:
|
try:
|
||||||
@@ -537,11 +541,14 @@ class Dialog(tk.Toplevel):
|
|||||||
self.geometry(f"+{max(0, x)}+{max(0, y)}")
|
self.geometry(f"+{max(0, x)}+{max(0, y)}")
|
||||||
self.deiconify()
|
self.deiconify()
|
||||||
self.lift()
|
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
|
vorher = self.grab_current() # z. B. der Assistent, wenn hier eine Rückfrage aufgeht
|
||||||
try:
|
self._sperre_setzen()
|
||||||
self.grab_set()
|
|
||||||
except tk.TclError:
|
|
||||||
pass
|
|
||||||
bind_id = None
|
bind_id = None
|
||||||
if KLASSISCH:
|
if KLASSISCH:
|
||||||
# Ein rahmenloser Dialog darf nicht hinter dem Hauptfenster verschwinden
|
# Ein rahmenloser Dialog darf nicht hinter dem Hauptfenster verschwinden
|
||||||
@@ -563,6 +570,25 @@ class Dialog(tk.Toplevel):
|
|||||||
pass
|
pass
|
||||||
return self.result
|
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):
|
def abbrechen(self):
|
||||||
self.result = None
|
self.result = None
|
||||||
self.destroy()
|
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():
|
if len(pos) != 2 or len(dateien) != 1 or not dateien[0].exists():
|
||||||
s.fehler.append(f"Export fehlerhaft: {len(pos)} Positionen, Dateien {dateien}")
|
s.fehler.append(f"Export fehlerhaft: {len(pos)} Positionen, Dateien {dateien}")
|
||||||
|
|
||||||
|
from provtool.gui.assistent import TippDesTages
|
||||||
from provtool.gui.dialogs import ImportBericht
|
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([
|
fehler += s.ablauf([
|
||||||
(seite(0), lambda: wiz()._weiter(), "Willkommen"),
|
(seite(0), lambda: wiz()._weiter(), "Willkommen"),
|
||||||
(seite(1), lambda: wiz()._weiter(), "Speicherort"),
|
(seite(1), lambda: wiz()._weiter(), "Speicherort"),
|
||||||
@@ -158,7 +166,9 @@ def ausfuehren(ergebnisdatei: str) -> int:
|
|||||||
(seite(4), lambda: wiz()._weiter(), "Fertig stellen"),
|
(seite(4), lambda: wiz()._weiter(), "Fertig stellen"),
|
||||||
(lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"),
|
(lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"),
|
||||||
(lambda: len(s.app.positionen) == 2, export, "Export"),
|
(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"),
|
(lambda: getattr(s.app, "_rundgang", None) is not None, s.app.beenden, "Beenden während Rundgang"),
|
||||||
])
|
])
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -448,3 +448,113 @@ def test_hauptfenster_ablaeufe(lauf_mit_daten):
|
|||||||
assert not lauf.uebrig, f"nicht ausgeführt: {lauf.uebrig}"
|
assert not lauf.uebrig, f"nicht ausgeführt: {lauf.uebrig}"
|
||||||
assert any(p["prozent"] == 80 for p in app.positionen)
|
assert any(p["prozent"] == 80 for p in app.positionen)
|
||||||
lauf.pruefen()
|
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()
|
||||||
Reference in new issue
Block a user