Files
provisionsmanager95/provtool/gui/rahmen.py
T
adminandClaude Opus 5.5 aae28fa7f2 Absturz beim Schließen mit offenem Dialog behoben; GUI-Tests und Selbsttest der .exe im Build
Fehler: Programm schließen, während der Einrichtungs-Assistent offen war → Zugriff auf
geschlossene Datenbank, danach Absturz des Fehler-Handlers.

- beenden(): schließt zuerst offene Dialoge/Rückfragen/Rundgang, lässt wartende Abläufe zu Ende
  laufen und schließt erst dann Datenbank und Fenster; mehrfacher Aufruf unschädlich
- Fehler-Handler protokolliert immer, zeigt aber keine Meldung mehr ohne Fenster
- Alt+F4 schließt nur das aktive bzw. modale Fenster (nicht mehr das ganze Programm)
- Betrag ändern: Ersatz-Dialog, wenn die Zelle nicht sichtbar ist (vorher passierte still nichts)
- tests/test_gui.py: Oberflächen-Tests inkl. Schließen bei jedem Dialog, Watchdog gegen Hänger
- Build: ein Xvfb für den ganzen Build (Wine bindet sich an das erste DISPLAY), GUI-Tests unter
  Windows-Python, danach Selbsttest der fertigen .exe (--selbsttest)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:55:00 +01:00

394 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Selbst gezeichneter Windows-95-Fensterrahmen.
Windows erlaubt Tk nicht, die echte Titelleiste umzufärben. Deshalb wird sie ausgeblendet
(overrideredirect) und hier nachgebaut: blaue Titelleiste mit Icon und _ □ x, Verschieben,
Doppelklick = Maximieren, Größenänderung über den Rand und den Anfasser unten rechts,
graue Menüleiste. Unter Windows sorgen ein paar Win32-Aufrufe dafür, dass das Fenster
trotzdem in der Taskleiste erscheint, per Alt+Tab erreichbar ist und minimiert werden kann.
"""
from __future__ import annotations
import sys
import tkinter as tk
from provtool.gui import win95 as w
IS_WIN = sys.platform.startswith("win")
# --------------------------------------------------------------------------- Glyphen
GLYPHEN = {
"min": """
........
........
........
........
........
........
kkkkkk..
kkkkkk..""",
"max": """
kkkkkkkkk
kkkkkkkkk
k.......k
k.......k
k.......k
k.......k
k.......k
kkkkkkkkk""",
"restore": """
..kkkkkk
..kkkkkk
..k....k
kkkkkk.k
kkkkkk.k
k....kkk
k....k..
kkkkkk..""",
"close": """
kk....kk
.kk..kk.
..kkkk..
...kk...
..kkkk..
.kk..kk.
kk....kk""",
}
def glyph(master, name: str) -> tk.PhotoImage:
rows = [r.strip() for r in GLYPHEN[name].strip("\n").splitlines() if r.strip()]
breite = max(len(r) for r in rows)
img = tk.PhotoImage(master=master, width=breite, height=len(rows))
for y, row in enumerate(rows):
for x, ch in enumerate(row):
if ch != ".":
img.put(w.PALETTE[ch], (x, y))
return img
def griff_bild(master, groesse: int = 12) -> tk.PhotoImage:
"""Der typische schraffierte Größen-Anfasser unten rechts."""
img = tk.PhotoImage(master=master, width=groesse, height=groesse)
for y in range(groesse):
for x in range(groesse):
d = x + y - (groesse - 1)
if d >= 0:
farbe = (w.HILITE, w.SHADOW, w.SHADOW, None)[d % 4]
if farbe:
img.put(farbe, (x, y))
return img
# --------------------------------------------------------------------------- Win32-Hilfen
def _hwnd(fenster: tk.Misc) -> int:
import ctypes
return ctypes.windll.user32.GetParent(fenster.winfo_id())
def taskleiste_anzeigen(fenster: tk.Tk) -> None:
"""Ein overrideredirect-Fenster erscheint unter Windows sonst nicht in Taskleiste/Alt+Tab."""
if not IS_WIN:
return
try:
import ctypes
GWL_EXSTYLE, WS_EX_APPWINDOW, WS_EX_TOOLWINDOW = -20, 0x00040000, 0x00000080
hwnd = _hwnd(fenster)
stil = ctypes.windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
stil = (stil & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW
ctypes.windll.user32.SetWindowLongW(hwnd, GWL_EXSTYLE, stil)
fenster.wm_withdraw()
fenster.after(10, lambda: (fenster.wm_deiconify(), fenster.focus_force()))
except Exception:
pass
def arbeitsbereich(fenster: tk.Misc) -> tuple[int, int, int, int]:
"""Bildschirmbereich ohne Taskleiste (des Monitors, auf dem das Fenster steht): x, y, breite, höhe."""
if IS_WIN:
try:
import ctypes
from ctypes import wintypes
class MONITORINFO(ctypes.Structure):
_fields_ = [("cbSize", wintypes.DWORD), ("rcMonitor", wintypes.RECT),
("rcWork", wintypes.RECT), ("dwFlags", wintypes.DWORD)]
mon = ctypes.windll.user32.MonitorFromWindow(_hwnd(fenster), 2) # MONITOR_DEFAULTTONEAREST
mi = MONITORINFO()
mi.cbSize = ctypes.sizeof(MONITORINFO)
if ctypes.windll.user32.GetMonitorInfoW(mon, ctypes.byref(mi)):
r = mi.rcWork
return r.left, r.top, r.right - r.left, r.bottom - r.top
except Exception:
pass
return 0, 0, fenster.winfo_screenwidth(), fenster.winfo_screenheight()
# --------------------------------------------------------------------------- Bausteine
class TitelKnopf(tk.Label):
def __init__(self, master, bild: tk.PhotoImage, command):
super().__init__(master, image=bild, bg=w.FACE, bd=2, relief="raised", width=14, height=12)
self.command = command
self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken"))
self.bind("<ButtonRelease-1>", self._los)
def _los(self, e):
self.configure(relief="raised")
if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height():
self.command()
class Titelleiste(tk.Frame):
"""Blaue Win95-Titelleiste. knoepfe: Teilmenge von ("min", "max", "close")."""
def __init__(self, master, fenster: tk.Misc, titel: str, icon: tk.PhotoImage | None, knoepfe=("close",),
schliessen=None, minimieren=None, maximieren=None):
super().__init__(master, bg=w.NAVY, height=20)
self.fenster = fenster
self._bilder = {n: glyph(self, n) for n in ("min", "max", "restore", "close")}
if icon is not None:
tk.Label(self, image=icon, bg=w.NAVY).pack(side="left", padx=(2, 0))
self.titel_var = tk.StringVar(value=titel)
self.titel_lbl = tk.Label(self, textvariable=self.titel_var, bg=w.NAVY, fg=w.WHITE, font=w.FONT_BOLD,
anchor="w")
self.titel_lbl.pack(side="left", fill="x", expand=True, padx=3)
self.knoepfe: dict[str, TitelKnopf] = {}
if "close" in knoepfe:
self.knoepfe["close"] = TitelKnopf(self, self._bilder["close"], schliessen)
self.knoepfe["close"].pack(side="right", padx=(2, 2), pady=2)
if "max" in knoepfe:
self.knoepfe["max"] = TitelKnopf(self, self._bilder["max"], maximieren)
self.knoepfe["max"].pack(side="right", pady=2)
if "min" in knoepfe:
self.knoepfe["min"] = TitelKnopf(self, self._bilder["min"], minimieren)
self.knoepfe["min"].pack(side="right", pady=2)
self._maximieren = maximieren
for widget in (self, self.titel_lbl):
widget.bind("<ButtonPress-1>", self._start)
widget.bind("<B1-Motion>", self._ziehen)
if maximieren:
widget.bind("<Double-Button-1>", lambda e: maximieren())
self._ab = (0, 0)
self.ziehen_erlaubt = lambda: True
def set_titel(self, t: str):
self.titel_var.set(t)
def max_symbol(self, maximiert: bool):
if "max" in self.knoepfe:
self.knoepfe["max"].configure(image=self._bilder["restore" if maximiert else "max"])
def _start(self, e):
self._ab = (e.x_root - self.fenster.winfo_rootx(), e.y_root - self.fenster.winfo_rooty())
def _ziehen(self, e):
if not self.ziehen_erlaubt():
return
self.fenster.geometry(f"+{e.x_root - self._ab[0]}+{e.y_root - self._ab[1]}")
class Menueleiste(tk.Frame):
"""Graue Menüleiste (die native von Windows 10/11 ist weiß und lässt sich nicht umfärben)."""
def __init__(self, master, root: tk.Misc):
super().__init__(master, bg=w.FACE)
self.root = root
self.knoepfe: dict[str, tk.Menubutton] = {}
def menue(self, label: str, underline: int | None = 0) -> tk.Menu:
mb = tk.Menubutton(self, text=label, bg=w.FACE, relief="flat", bd=1, padx=6, pady=1,
activebackground=w.NAVY, activeforeground=w.WHITE,
underline=-1 if underline is None else underline)
menu = tk.Menu(mb, tearoff=0)
mb.configure(menu=menu)
mb.pack(side="left")
self.knoepfe[label] = mb
if underline is not None:
taste = label[underline].lower()
self.root.bind_all(f"<Alt-KeyPress-{taste}>", lambda e, m=mb: self.oeffnen(m))
return menu
def oeffnen(self, mb: tk.Menubutton):
menu = mb.nametowidget(mb.cget("menu"))
menu.tk_popup(mb.winfo_rootx(), mb.winfo_rooty() + mb.winfo_height())
return "break"
# --------------------------------------------------------------------------- Hauptfenster
class Hauptrahmen:
"""Umgibt das Hauptfenster. Im klassischen Modus mit eigener Titelleiste, sonst nur ein Container."""
RAND = 5 # Pixel am Rand, an denen man die Größe ziehen kann
def __init__(self, root: tk.Tk, titel: str, icon: tk.PhotoImage, schliessen, klassisch: bool = True,
minbreite: int = 900, minhoehe: int = 520):
self.root, self.klassisch = root, klassisch
self.minbreite, self.minhoehe = minbreite, minhoehe
self.maximiert = False
self._normal_geo: str | None = None
self._schliessen = schliessen
root.minsize(minbreite, minhoehe)
if not klassisch:
self.aussen = tk.Frame(root, bg=w.FACE)
self.aussen.pack(fill="both", expand=True)
self.inhalt = self.aussen
self.titelleiste = None
return
root.overrideredirect(True)
self.aussen = tk.Frame(root, bg=w.FACE, bd=2, relief="raised", padx=2, pady=2)
self.aussen.pack(fill="both", expand=True)
self.titelleiste = Titelleiste(self.aussen, root, titel, icon, ("min", "max", "close"),
schliessen=schliessen, minimieren=self.minimieren,
maximieren=self.umschalten)
self.titelleiste.pack(fill="x")
self.titelleiste.ziehen_erlaubt = lambda: not self.maximiert
self.inhalt = tk.Frame(self.aussen, bg=w.FACE)
self.inhalt.pack(fill="both", expand=True)
self._rand: str = ""
self.aussen.bind("<Motion>", self._rand_cursor)
self.aussen.bind("<Leave>", lambda e: self.aussen.configure(cursor=""))
self.aussen.bind("<ButtonPress-1>", self._resize_start)
self.aussen.bind("<B1-Motion>", self._resize)
root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget))
root.after(50, lambda: taskleiste_anzeigen(root))
def alt_f4(self, widget) -> str:
"""Alt+F4 wie unter Windows: schließt nur das Fenster, in dem man gerade ist."""
try:
# Ist ein modaler Dialog offen, gilt Alt+F4 immer ihm – egal wo der Tastaturfokus liegt
# (unter Windows liefert Tk Tastendrücke an das Fenster mit Fokus, das kann noch das
# Hauptfenster sein).
sperre = self.root.grab_current()
if sperre is not None and sperre.winfo_toplevel() is not self.root:
fenster = sperre.winfo_toplevel()
else:
fenster = widget.winfo_toplevel() if isinstance(widget, tk.Misc) else self.root
except tk.TclError:
fenster = self.root
if fenster is self.root:
self._schliessen()
elif hasattr(fenster, "abbrechen"):
fenster.abbrechen()
else:
fenster.destroy()
return "break"
# ---- öffentliche Helfer
def set_titel(self, text: str):
self.root.title(text)
if self.titelleiste:
self.titelleiste.set_titel(text)
def griff(self, master) -> tk.Label | None:
"""Anfasser für die Statusleiste (nur im klassischen Modus)."""
if not self.klassisch:
return None
self._griff_bild = griff_bild(master)
g = tk.Label(master, image=self._griff_bild, bg=w.FACE, cursor="sizing" if not IS_WIN else "size_nw_se")
g.bind("<ButtonPress-1>", lambda e: self._resize_start(e, "se"))
g.bind("<B1-Motion>", self._resize)
return g
def geometrie_setzen(self, geo: str | None, maximiert: bool = False):
self.root.update_idletasks()
if not geo or "+" not in geo:
breite, hoehe = 1200, 720
if geo and "x" in geo:
try:
breite, hoehe = (int(v) for v in geo.split("+")[0].split("x"))
except ValueError:
pass
x0, y0, bw, bh = arbeitsbereich(self.root)
breite, hoehe = min(breite, bw), min(hoehe, bh)
geo = f"{breite}x{hoehe}+{x0 + (bw - breite) // 2}+{y0 + (bh - hoehe) // 2}"
self.root.geometry(geo)
if maximiert and self.klassisch:
self.root.after(100, self.maximieren)
def normale_geometrie(self) -> str:
return self._normal_geo if self.maximiert and self._normal_geo else self.root.geometry()
# ---- Fensterfunktionen
def minimieren(self):
# Windows minimiert keine overrideredirect-Fenster → kurz normalen Rahmen aktivieren
geo = self.root.geometry()
self.root.overrideredirect(False)
self.root.iconify()
def wieder_da(e):
if e.widget is self.root and self.root.state() == "normal":
self.root.unbind("<Map>")
self.root.overrideredirect(True)
self.root.geometry(geo)
taskleiste_anzeigen(self.root)
self.root.bind("<Map>", wieder_da)
def maximieren(self):
if self.maximiert:
return
self._normal_geo = self.root.geometry()
x, y, bw, bh = arbeitsbereich(self.root)
self.root.geometry(f"{bw}x{bh}+{x}+{y}")
self.maximiert = True
self.titelleiste.max_symbol(True)
def wiederherstellen(self):
if not self.maximiert:
return
self.maximiert = False
if self._normal_geo:
self.root.geometry(self._normal_geo)
self.titelleiste.max_symbol(False)
def umschalten(self):
self.wiederherstellen() if self.maximiert else self.maximieren()
# ---- Größe ändern
def _welcher_rand(self, x, y) -> str:
bw, bh, r = self.aussen.winfo_width(), self.aussen.winfo_height(), self.RAND
rand = ("n" if y < r else "s" if y >= bh - r else "") + ("w" if x < r else "e" if x >= bw - r else "")
return rand
CURSOR_WIN = {"n": "size_ns", "s": "size_ns", "e": "size_we", "w": "size_we",
"nw": "size_nw_se", "se": "size_nw_se", "ne": "size_ne_sw", "sw": "size_ne_sw"}
CURSOR_X11 = {"n": "top_side", "s": "bottom_side", "e": "right_side", "w": "left_side",
"nw": "top_left_corner", "se": "bottom_right_corner", "ne": "top_right_corner",
"sw": "bottom_left_corner"}
def _rand_cursor(self, e):
if self.maximiert:
return
rand = self._welcher_rand(e.x, e.y)
try:
self.aussen.configure(cursor=(self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, ""))
except tk.TclError:
pass
def _resize_start(self, e, rand: str | None = None):
self._rand = rand or self._welcher_rand(e.x, e.y)
self._start = (e.x_root, e.y_root, self.root.winfo_x(), self.root.winfo_y(),
self.root.winfo_width(), self.root.winfo_height())
def _resize(self, e):
if not self._rand or self.maximiert:
return
sx, sy, x, y, bw, bh = self._start
dx, dy = e.x_root - sx, e.y_root - sy
if "e" in self._rand:
bw = max(self.minbreite, bw + dx)
if "s" in self._rand:
bh = max(self.minhoehe, bh + dy)
if "w" in self._rand:
neu = max(self.minbreite, bw - dx)
x, bw = x + (bw - neu), neu
if "n" in self._rand:
neu = max(self.minhoehe, bh - dy)
y, bh = y + (bh - neu), neu
self.root.geometry(f"{bw}x{bh}+{x}+{y}")