Files
provisionsmanager95/provtool/gui/rahmen.py
T
Kerim AtilganandClaude Opus 5.5 851fd6600a Modernes Design (Standard, Win95 als Legacy), Zeitraum abrechnen, Abrechnung erledigt, Linux-Build
- Design: „modern“ (hell, flach, wie Windows 11, normaler Windows-Rahmen) ist Standard;
  „win95“ bleibt unter Datei > Einstellungen wählbar (Einstellung „design“).
- Export: Zeitraum von–bis (nach Aktivierungsdatum oder Abrechnungszeitraum des Anbieters),
  exportierte Positionen werden als abgerechnet gekennzeichnet (Betrag/Händler eingefroren) und
  per Fingerabdruck auch in späteren Dateien/Abrechnungen nicht noch einmal abgerechnet.
- Datei > Abrechnung erledigt / wieder öffnen: erledigte Abrechnungen sind gegen Änderungen gesperrt.
- DB-Schema v4 (Migration inkl. Fingerabdrücke für alte Positionen).
- Linux-Build (python:3.12-bookworm) als zweiter Pipeline-Job mit Tests und Selbsttest;
  Release-Upload für beide Jobs über build/release_hochladen.sh.
- Behoben: normaler Windows-Rahmen stürzte beim Start ab (Hauptrahmen.nativ fehlte).

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

553 lines
22 KiB
Python
Raw Permalink 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 ist das Hauptfenster trotzdem ein echtes Programmfenster (siehe nativ.py): Windows
übernimmt Verschieben, Größe ändern, Andocken am Bildschirmrand, Win+Pfeiltasten, Taskleiste und
Alt+Tab. Auf anderen Systemen (Entwicklung/Tests) macht Tk das selbst.
"""
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())
self._nativ = False
if NATIV_VERSCHIEBEN and IS_WIN and self.ziehen_erlaubt():
# Windows übernimmt das Ziehen: flüssig und mit Andocken am Bildschirmrand
from provtool.gui import win32
self._nativ = win32.nativ_verschieben(self.fenster)
if self._nativ:
return "break"
def _ziehen(self, e):
if self._nativ or not self.ziehen_erlaubt():
return
self.fenster.geometry(f"+{e.x_root - self._ab[0]}+{e.y_root - self._ab[1]}")
def blinken(self, mal: int = 3):
"""Titelleiste kurz grau/blau blinken lassen (wie Windows bei gesperrtem Fenster)."""
farben = [w.SHADOW, w.NAVY] * mal
def schritt(i=0):
if i >= len(farben) or not self.winfo_exists():
return
for widget in (self, self.titel_lbl):
widget.configure(bg=farben[i])
self.after(70, lambda: schritt(i + 1))
schritt()
NATIV_VERSCHIEBEN = True # Tests können das abschalten, um den Tk-Weg zu prüfen
NATIVER_RAHMEN = True # Windows: echtes Fenster mit unsichtbarem Rahmen (nativ.py)
# --------------------------------------------------------------------------- Hauptfenster
class Hauptrahmen:
"""Umgibt das Hauptfenster. Im klassischen Modus mit eigener Titelleiste, sonst nur ein Container."""
RAND = 6 # Pixel am Rand, an denen man die Größe ziehen kann (= sichtbarer Rahmen)
ECKE = 18 # so weit vom Eck entfernt gilt der Rand noch als Ecke (diagonal ziehen)
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
self.nativ = None
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
root.sperrbereich = lambda: [self.inhalt] # type: ignore[attr-defined] # für modal.py
return
root.overrideredirect(True)
self.aussen = tk.Frame(root, bg=w.FACE, bd=2, relief="raised", padx=self.RAND - 2, pady=self.RAND - 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")
# Maximiert darf man nur nativ ziehen (Windows stellt das Fenster dabei wieder her)
self.titelleiste.ziehen_erlaubt = lambda: not self.maximiert or self.nativ_an
self.inhalt = tk.Frame(self.aussen, bg=w.FACE)
self.inhalt.pack(fill="both", expand=True)
# Gesperrt wird bei offenem Dialog nur der Inhalt – Titelleiste und Rand bleiben bedienbar
root.sperrbereich = lambda: [self.inhalt] # type: ignore[attr-defined]
self._rand: str = ""
self._resize_geplant = None
self.aussen.bind("<Motion>", self._rand_cursor)
# Mauszeiger zurücksetzen, sobald der Rand verlassen wird – Kindfenster erben sonst den
# Größenänderungs-Zeiger, wenn das letzte Motion-Ereignis am Rand verpasst wurde.
self.aussen.bind("<Leave>", self._cursor_zurueck)
self.inhalt.bind("<Enter>", self._cursor_zurueck)
self.titelleiste.bind("<Enter>", self._cursor_zurueck, add="+")
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))
if IS_WIN and NATIVER_RAHMEN:
from provtool.gui.nativ import NativerRahmen
self.nativ = NativerRahmen(root, self.RAND, self.ECKE)
root.bind("<Configure>", self._fenster_geaendert, add="+")
root.bind("<Map>", lambda e: e.widget is root and self._nativ_einrichten(), add="+")
self._nativ_einrichten() # möglichst schon vor dem ersten Anzeigen
root.after(50, self._fenster_einrichten)
# ---- natives Windows-Fenster
@property
def nativ_an(self) -> bool:
return self.nativ is not None and self.nativ.aktiv
def _nativ_einrichten(self) -> bool:
if self.nativ is None:
return False
if self.nativ.aktiv and not self.nativ.veraltet():
return True
try:
sichtbar = bool(self.root.winfo_viewable())
except tk.TclError:
sichtbar = True
if self.nativ.veraltet(): # Tk hat das äußere Fenster neu angelegt
ok = self.nativ.neu_einrichten()
else:
ok = self.nativ.einrichten()
if ok:
# Erst eingerichtet, als das Fenster schon zu sehen war -> Taskleiste muss es neu erfassen
self._taskleiste_neu = sichtbar
return ok
_taskleiste_neu = True
def _fenster_einrichten(self):
self._nativ_einrichten()
if self.nativ_an and not self._taskleiste_neu:
return # vor dem ersten Anzeigen eingerichtet: Windows kennt den Taskleisten-Stil schon
taskleiste_anzeigen(self.root)
def _fenster_geaendert(self, e):
"""Zustand nachführen – maximieren/wiederherstellen geht jetzt auch über Windows selbst
(Win+Hoch, an den oberen Rand ziehen, Doppelklick, Taskleiste)."""
if e.widget is not self.root or self.nativ is None:
return
if self.nativ.veraltet():
self._nativ_einrichten()
if not self.nativ_an:
return
try:
maximiert = self.nativ.maximiert()
if maximiert != self.maximiert:
self.maximiert = maximiert
self.titelleiste.max_symbol(maximiert)
self._cursor_zurueck()
if not maximiert and self.root.wm_state() == "normal":
self._normal_geo = self.root.geometry()
except Exception:
pass
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).
from provtool.gui import modal
sperre = self.root.grab_current() or modal.oberstes()
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:
fenster = self.root
if fenster is self.root:
from provtool.gui import modal
offen = modal.oberstes()
if offen is not None: # Alt+F4 bei offenem Dialog schließt den Dialog
offen.abbrechen()
return "break"
self._schliessen()
elif hasattr(fenster, "abbrechen"):
fenster.abbrechen()
else:
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)
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>", self._griff_start)
g.bind("<B1-Motion>", self._resize)
return g
def _griff_start(self, e):
if self.nativ_an and not self.maximiert:
self.nativ.groesse_ziehen("se") # Windows zieht die Größe (flüssig)
return "break"
self._resize_start(e, "se")
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)
self._normal_geo = geo
self._nativ_einrichten()
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):
if IS_WIN:
# Direkt über Windows: offene Dialoge (gehören dem Hauptfenster) werden mit aus- und
# wieder eingeblendet. NICHT overrideredirect umschalten – Tk baut dabei das Fenster neu
# und Windows würde die zugehörigen Dialoge zerstören.
from provtool.gui import win32
if win32.minimieren(self.root):
return
# Tk 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
if self.nativ_an:
from provtool.gui.nativ import SC_MAXIMIZE
self._normal_geo = self.root.geometry()
self.nativ.befehl(SC_MAXIMIZE)
self.maximiert = self.nativ.maximiert()
self.titelleiste.max_symbol(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
if self.nativ_an:
from provtool.gui.nativ import SC_RESTORE
self.nativ.befehl(SC_RESTORE)
self.maximiert = self.nativ.maximiert()
self.titelleiste.max_symbol(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, k = self.aussen.winfo_width(), self.aussen.winfo_height(), self.RAND, self.ECKE
ns = "n" if y < r else "s" if y >= bh - r else ""
we = "w" if x < r else "e" if x >= bw - r else ""
# an einer Kante nahe am Eck -> Ecke (diagonal), wie bei Windows
if ns and not we:
we = "w" if x < k else "e" if x >= bw - k else ""
elif we and not ns:
ns = "n" if y < k else "s" if y >= bh - k else ""
return ns + we
def _cursor_zurueck(self, _e=None):
if self._rand_zeiger:
self._rand_zeiger = False
try:
self.aussen.configure(cursor="")
except tk.TclError:
pass
_rand_zeiger = False
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:
self._cursor_zurueck()
return
rand = self._welcher_rand(e.x, e.y)
zeiger = (self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "")
try:
self.aussen.configure(cursor=zeiger)
self._rand_zeiger = bool(zeiger)
except tk.TclError:
pass
def _resize_start(self, e, rand: str | None = None):
self._rand = rand or self._welcher_rand(e.x, e.y)
if self._rand and self.nativ_an and not self.maximiert:
# Den Zeiger zeigt Tk (weiß dann immer, welcher gilt); ziehen lässt Windows (flüssig,
# mit Andocken). Angestoßen per PostMessage – siehe nativ.py.
self.nativ.groesse_ziehen(self._rand)
self._rand = ""
return "break"
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
# Mausbewegungen bündeln: nur die jeweils letzte Größe anwenden (flüssiger unter Windows)
self._resize_ziel = f"{bw}x{bh}+{x}+{y}"
if self._resize_geplant is None:
self._resize_geplant = self.root.after(15, self._resize_anwenden)
def _resize_anwenden(self):
self._resize_geplant = None
try:
self.root.geometry(self._resize_ziel)
except tk.TclError:
pass