Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b2d5e8580 | ||
|
|
86192c853d | ||
|
|
bd45ffea17 | ||
|
|
26ac90bb0f |
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")],
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
|
||||
|
||||
__version__ = "1.1.1"
|
||||
__version__ = "1.1.3"
|
||||
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,25 @@ 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():
|
||||
# Fensterstatus statt winfo_ismapped(): direkt nach dem Einblenden ist der Status schon
|
||||
# „normal“, gezeichnet ist das Fenster aber evtl. noch nicht (langsamer PC, Wine).
|
||||
if isinstance(kind, tk.Toplevel) and hasattr(kind, "abbrechen"):
|
||||
try:
|
||||
offen = kind.winfo_exists() and kind.wm_state() != "withdrawn"
|
||||
except tk.TclError:
|
||||
offen = False
|
||||
if offen:
|
||||
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:
|
||||
|
||||
+177
-8
@@ -47,6 +47,7 @@ class Lauf:
|
||||
raise
|
||||
pytest.skip(f"kein Bildschirm: {exc}")
|
||||
self.fehler: list[str] = []
|
||||
self.watchdog = False # True = der TEST hat wegen Zeitüberschreitung abgebrochen
|
||||
from provtool.gui import app as A
|
||||
self.app = A.App(self.root)
|
||||
echt = self.root.report_callback_exception
|
||||
@@ -62,6 +63,13 @@ class Lauf:
|
||||
self.root.report_callback_exception = melden
|
||||
|
||||
# ---------------------------------------------------------------- Zustand
|
||||
def lebt_sonst(self, text: str) -> str:
|
||||
"""Fehlertext für „Programm ist zu“, der Watchdog-Abbrüche klar unterscheidet."""
|
||||
if self.watchdog:
|
||||
return "Zeitüberschreitung – der Test hat abgebrochen (nicht das Programm hat sich beendet):\n" \
|
||||
+ "\n".join(self.fehler)
|
||||
return text
|
||||
|
||||
@property
|
||||
def lebt(self) -> bool:
|
||||
try:
|
||||
@@ -95,6 +103,7 @@ class Lauf:
|
||||
d.destroy()
|
||||
|
||||
def _notaus(self):
|
||||
self.watchdog = True
|
||||
for d in reversed(self.dialoge()):
|
||||
try:
|
||||
d.destroy()
|
||||
@@ -219,11 +228,11 @@ def test_alt_f4_im_assistent_fragt_und_schliesst_nur_assistent(home):
|
||||
d.destroy()
|
||||
|
||||
lauf.ablauf([
|
||||
(_assistent_offen(lauf), lambda: lauf.dialog(Einrichtungsassistent).event_generate("<Alt-F4>"), "Alt+F4"),
|
||||
(_assistent_offen(lauf), lambda: lauf.app.rahmen.alt_f4(lauf.dialog(Einrichtungsassistent)), "Alt+F4"),
|
||||
(lambda: rueckfrage() is not None, ja_klicken, "Rückfrage mit Ja beantworten"),
|
||||
(lambda: lauf.dialog(Einrichtungsassistent) is None, lambda: None, "Assistent ist zu"),
|
||||
])
|
||||
assert lauf.lebt, "Programm darf nicht beendet werden"
|
||||
assert lauf.lebt, lauf.lebt_sonst("Programm darf nicht beendet werden")
|
||||
assert not lauf.uebrig, lauf.uebrig
|
||||
assert lauf.app.settings.get("assistent_erledigt") is True
|
||||
lauf.pruefen()
|
||||
@@ -231,17 +240,37 @@ def test_alt_f4_im_assistent_fragt_und_schliesst_nur_assistent(home):
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_alt_f4_im_dialog_schliesst_nur_den_dialog(lauf_mit_daten):
|
||||
def test_alt_f4_im_dialog_schliesst_nur_den_dialog(lauf_mit_daten, monkeypatch):
|
||||
from provtool.gui import rahmen
|
||||
from provtool.gui.dialogs import InfoDialog
|
||||
lauf = lauf_mit_daten
|
||||
protokoll: list[str] = [] # Diagnose: wer hat Alt+F4 bzw. Beenden ausgelöst?
|
||||
alt_f4, beenden = rahmen.Hauptrahmen.alt_f4, type(lauf.app).beenden
|
||||
|
||||
def alt_f4_spion(self, widget):
|
||||
protokoll.append(f"alt_f4(widget={widget}, sperre={self.root.grab_current()}, "
|
||||
f"popup={self._offenes_popup()}, fokus={self.root.focus_get()})\n"
|
||||
+ "".join(traceback.format_stack(limit=8)))
|
||||
return alt_f4(self, widget)
|
||||
|
||||
def beenden_spion(self):
|
||||
protokoll.append("beenden()\n" + "".join(traceback.format_stack(limit=10)))
|
||||
return beenden(self)
|
||||
monkeypatch.setattr(rahmen.Hauptrahmen, "alt_f4", alt_f4_spion)
|
||||
monkeypatch.setattr(type(lauf.app), "beenden", beenden_spion)
|
||||
|
||||
lauf.ablauf([
|
||||
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Info öffnen"),
|
||||
# Handler direkt mit dem Dialog als Empfänger aufrufen: Erzeugte Tastendrücke stellt Tk unter
|
||||
# Windows nur dem Fenster mit Tastaturfokus zu – auf dem Build-Rechner gingen sie verloren.
|
||||
# Dass Alt+F4 an diesen Handler gebunden ist, prüft test_alt_f4_ist_gebunden.
|
||||
(lambda: lauf.dialog(InfoDialog) is not None,
|
||||
lambda: lauf.dialog(InfoDialog).event_generate("<Alt-F4>"), "Alt+F4 im Dialog"),
|
||||
lambda: lauf.app.rahmen.alt_f4(lauf.dialog(InfoDialog)), "Alt+F4 im Dialog"),
|
||||
(lambda: lauf.dialog(InfoDialog) is None, lambda: None, "Dialog geschlossen"),
|
||||
], sekunden=8)
|
||||
assert lauf.lebt, "Alt+F4 im Dialog darf nicht das ganze Programm beenden"
|
||||
assert not lauf.uebrig, lauf.uebrig
|
||||
diagnose = "\n---\n".join(protokoll) + "\nFehler:\n" + "\n".join(lauf.fehler)
|
||||
assert lauf.lebt, lauf.lebt_sonst("Alt+F4 im Dialog darf nicht das ganze Programm beenden\n" + diagnose)
|
||||
assert not lauf.uebrig, f"{lauf.uebrig}\n{diagnose}"
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
@@ -298,16 +327,46 @@ def test_schliessen_waehrend_rundgang(lauf_mit_daten):
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_alt_f4_ist_gebunden(lauf_mit_daten):
|
||||
"""Alt+F4 muss im Win95-Rahmen an den eigenen Handler gebunden sein (für alle Fenster)."""
|
||||
lauf = lauf_mit_daten
|
||||
if not lauf.app.rahmen.klassisch:
|
||||
pytest.skip("nur im Win95-Rahmen")
|
||||
skript = lauf.root.bind_all("<Alt-F4>")
|
||||
assert skript, "Alt+F4 ist nicht gebunden"
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_alt_f4_bevor_die_sperre_steht(lauf_mit_daten, monkeypatch):
|
||||
"""Erzwingt den Zustand vom langsamen Build-Rechner: Dialog ist offen, aber Tk-Sperre und
|
||||
Zeichnen sind noch nicht fertig. Alt+F4 muss trotzdem nur den Dialog schließen."""
|
||||
from provtool.gui import win95 as w
|
||||
from provtool.gui.dialogs import InfoDialog
|
||||
monkeypatch.setattr(w.Dialog, "_sperre_setzen", lambda self, versuch=0: None) # keine Sperre
|
||||
monkeypatch.setattr(w.Dialog, "winfo_ismapped", lambda self: 0) # „noch nicht gezeichnet“
|
||||
lauf = lauf_mit_daten
|
||||
lauf.ablauf([
|
||||
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Info öffnen"),
|
||||
# wie unter Windows: Tk liefert den Tastendruck an das Fenster mit Fokus – hier noch das Hauptfenster
|
||||
(lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.app.rahmen.alt_f4(lauf.root),
|
||||
"Alt+F4 (Fokus noch beim Hauptfenster)"),
|
||||
(lambda: lauf.dialog(InfoDialog) is None, lambda: None, "Dialog geschlossen"),
|
||||
], sekunden=8)
|
||||
assert lauf.lebt, lauf.lebt_sonst("Alt+F4 darf bei offenem Dialog nicht das ganze Programm beenden")
|
||||
assert not lauf.uebrig, lauf.uebrig
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_alt_f4_im_rundgang_beendet_nur_den_rundgang(lauf_mit_daten):
|
||||
lauf = lauf_mit_daten
|
||||
rg = lambda: getattr(lauf.app, "_rundgang", None) # noqa: E731
|
||||
lauf.ablauf([
|
||||
(IMMER, lauf.app.rundgang, "Rundgang starten"),
|
||||
(lambda: rg() is not None and rg().blase is not None,
|
||||
lambda: rg().blase.event_generate("<Alt-F4>"), "Alt+F4"),
|
||||
lambda: lauf.app.rahmen.alt_f4(rg().blase), "Alt+F4"),
|
||||
(lambda: rg().blase is None and not rg().rahmen, lambda: None, "Rundgang samt Rahmen weg"),
|
||||
], sekunden=8)
|
||||
assert lauf.lebt, "Alt+F4 im Rundgang darf nicht das Programm beenden"
|
||||
assert lauf.lebt, lauf.lebt_sonst("Alt+F4 im Rundgang darf nicht das Programm beenden")
|
||||
assert not lauf.uebrig, lauf.uebrig
|
||||
lauf.pruefen()
|
||||
|
||||
@@ -448,3 +507,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()
|
||||
Reference in new issue
Block a user