- 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>
305 lines
12 KiB
Python
305 lines
12 KiB
Python
"""Menüs im Windows-95-Stil (Menüleiste, Aufklapp- und Kontextmenüs).
|
||
|
||
Die nativen Windows-Menüs sind weiß und wechseln nicht mit, wenn man bei offenem Menü mit der
|
||
Maus zum nächsten Menüleisten-Eintrag fährt. Diese Menüs sind selbst gezeichnet:
|
||
- grau, Markierung in Navy, Tastenkürzel rechtsbündig, Trennlinien eingelassen
|
||
- bei offenem Menü wechselt das Menü beim Überfahren der Menüleiste
|
||
- Tastatur: Pfeil hoch/runter wählen, Enter ausführen, Esc schließen, Pfeil links/rechts benachbartes Menü
|
||
- Klick daneben oder Wechsel in ein anderes Programm schließt das Menü
|
||
Die Schnittstelle (add_command, add_separator, tk_popup) entspricht tk.Menu.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import tkinter as tk
|
||
|
||
from provtool.gui import win95 as w
|
||
|
||
|
||
class Popup:
|
||
"""Ein Aufklapp- bzw. Kontextmenü."""
|
||
|
||
def __init__(self, master: tk.Misc):
|
||
self.master = master
|
||
self.eintraege: list[dict] = []
|
||
self.fenster: tk.Toplevel | None = None
|
||
self.zeilen: list[tuple[dict, tk.Frame, list[tk.Label]]] = []
|
||
self.aktiv = -1
|
||
self.leiste: Menueleiste | None = None
|
||
self._pruefer = None
|
||
|
||
# ------------------------------------------------------------- tk.Menu-kompatibel
|
||
def add_command(self, label: str, command=None, accelerator: str = "", state: str = "normal"):
|
||
self.eintraege.append(dict(art="befehl", label=label, command=command, accelerator=accelerator,
|
||
state=state))
|
||
|
||
def add_separator(self):
|
||
self.eintraege.append(dict(art="trenner"))
|
||
|
||
def tk_popup(self, x: int, y: int):
|
||
self.oeffnen(x, y)
|
||
|
||
# ------------------------------------------------------------- Anzeige
|
||
@property
|
||
def offen(self) -> bool:
|
||
try:
|
||
return self.fenster is not None and bool(self.fenster.winfo_exists())
|
||
except tk.TclError:
|
||
return False
|
||
|
||
def oeffnen(self, x: int, y: int, leiste: Menueleiste | None = None):
|
||
self.schliessen()
|
||
self.leiste = leiste
|
||
root = self.master._root()
|
||
f = self.fenster = tk.Toplevel(root, bg=w.FACE, bd=2, relief="raised")
|
||
if w.MODERN:
|
||
f.configure(bg=w.WHITE, bd=0, relief="flat", highlightthickness=1, highlightbackground=w.RAND)
|
||
f.withdraw()
|
||
f.overrideredirect(True)
|
||
self.zeilen = []
|
||
grund, py = (w.WHITE, 4) if w.MODERN else (w.FACE, 2)
|
||
for e in self.eintraege:
|
||
if e["art"] == "trenner":
|
||
t = tk.Frame(f, bg=grund, height=8)
|
||
t.pack(fill="x")
|
||
linie = w.trenner(t, vertikal=False)
|
||
linie.place(relx=0, rely=0.5, relwidth=1, y=-1)
|
||
continue
|
||
zeile = tk.Frame(f, bg=grund)
|
||
zeile.pack(fill="x")
|
||
grau = e["state"] == "disabled"
|
||
text = tk.Label(zeile, text=e["label"], bg=grund, anchor="w", padx=18, pady=py,
|
||
fg=w.SHADOW if grau else w.DARK)
|
||
text.pack(side="left", fill="x", expand=True)
|
||
labels = [text]
|
||
if e["accelerator"]:
|
||
acc = tk.Label(zeile, text=e["accelerator"], bg=grund, anchor="e", padx=12, pady=py,
|
||
fg=w.SHADOW if grau else w.DARK)
|
||
acc.pack(side="right")
|
||
labels.append(acc)
|
||
idx = len(self.zeilen)
|
||
for wdg in [zeile] + labels:
|
||
wdg.bind("<Enter>", lambda ev, i=idx: self._markieren(i))
|
||
wdg.bind("<ButtonRelease-1>", lambda ev, i=idx: self._ausfuehren(i))
|
||
self.zeilen.append((e, zeile, labels))
|
||
|
||
f.update_idletasks()
|
||
bw, bh = f.winfo_reqwidth(), f.winfo_reqheight()
|
||
sw, sh = f.winfo_screenwidth(), f.winfo_screenheight()
|
||
x = max(0, min(x, sw - bw))
|
||
y = max(0, min(y, sh - bh))
|
||
# Größe UND Position zusammen setzen: eine reine Positionsangabe ignoriert Tk unter Windows bei
|
||
# rahmenlosen Fenstern – das Menü landete sonst oben links in der Bildschirmecke.
|
||
f.geometry(f"{bw}x{bh}+{x}+{y}")
|
||
f.deiconify()
|
||
f.lift()
|
||
f.update_idletasks()
|
||
try:
|
||
from provtool.gui import win32
|
||
win32.besitzer_setzen(f, root) # bleibt über dem Hauptfenster
|
||
except Exception:
|
||
pass
|
||
|
||
# Alle Maus-Ereignisse der Anwendung an das Menü (lokale Tk-Sperre): Klick daneben schließt,
|
||
# Bewegung über die Menüleiste wechselt das Menü.
|
||
try:
|
||
f.grab_set()
|
||
except tk.TclError:
|
||
pass
|
||
f.focus_force()
|
||
f.bind("<ButtonPress>", self._klick)
|
||
f.bind("<Motion>", self._bewegung)
|
||
f.bind("<Escape>", lambda e: self.schliessen())
|
||
f.bind("<Up>", lambda e: self._schritt(-1))
|
||
f.bind("<Down>", lambda e: self._schritt(1))
|
||
f.bind("<Return>", lambda e: self._ausfuehren(self.aktiv))
|
||
f.bind("<KP_Enter>", lambda e: self._ausfuehren(self.aktiv))
|
||
f.bind("<Left>", lambda e: self.leiste and self.leiste.nachbar(-1))
|
||
f.bind("<Right>", lambda e: self.leiste and self.leiste.nachbar(1))
|
||
self.aktiv = -1
|
||
self._pruefer = f.after(250, self._fokus_pruefen)
|
||
|
||
def schliessen(self):
|
||
if self._pruefer is not None and self.fenster is not None:
|
||
try:
|
||
self.fenster.after_cancel(self._pruefer)
|
||
except tk.TclError:
|
||
pass
|
||
self._pruefer = None
|
||
if self.offen:
|
||
try:
|
||
self.fenster.grab_release()
|
||
except tk.TclError:
|
||
pass
|
||
self.fenster.destroy()
|
||
self.fenster = None
|
||
if self.leiste is not None:
|
||
self.leiste._geschlossen(self)
|
||
|
||
# ------------------------------------------------------------- Bedienung
|
||
def _innerhalb(self, x_root: int, y_root: int) -> bool:
|
||
"""Liegt der Punkt über diesem Menü? Fragt das Fenstersystem, welches Fenster dort liegt –
|
||
die Geometrie rahmenloser Fenster meldet Tk unter Windows nicht zuverlässig."""
|
||
f = self.fenster
|
||
try:
|
||
unter = f.winfo_containing(x_root, y_root)
|
||
except (tk.TclError, KeyError):
|
||
unter = None
|
||
if unter is not None:
|
||
return unter.winfo_toplevel() is f
|
||
return (f.winfo_rootx() <= x_root < f.winfo_rootx() + f.winfo_width()
|
||
and f.winfo_rooty() <= y_root < f.winfo_rooty() + f.winfo_height())
|
||
|
||
def _klick(self, e):
|
||
if self._innerhalb(e.x_root, e.y_root):
|
||
return
|
||
if self.leiste is not None and self.leiste.knopf_bei(e.x_root, e.y_root) is not None:
|
||
knopf = self.leiste.knopf_bei(e.x_root, e.y_root)
|
||
if knopf is self.leiste.offener_knopf:
|
||
self.schliessen() # Klick auf den eigenen Menütitel klappt zu
|
||
else:
|
||
self.leiste.oeffnen(knopf)
|
||
return "break"
|
||
self.schliessen()
|
||
return "break"
|
||
|
||
def _bewegung(self, e):
|
||
if self.leiste is None or self._innerhalb(e.x_root, e.y_root):
|
||
return
|
||
knopf = self.leiste.knopf_bei(e.x_root, e.y_root)
|
||
if knopf is not None and knopf is not self.leiste.offener_knopf:
|
||
self.leiste.oeffnen(knopf)
|
||
|
||
def _markieren(self, i: int):
|
||
self.aktiv = i
|
||
for j, (e, zeile, labels) in enumerate(self.zeilen):
|
||
an = j == i and e.get("state") != "disabled"
|
||
bg = w.SEL_BG if an else (w.WHITE if w.MODERN else w.FACE)
|
||
fg = w.SEL_FG if an else (w.SHADOW if e.get("state") == "disabled" else w.DARK)
|
||
zeile.configure(bg=bg)
|
||
for lbl in labels:
|
||
lbl.configure(bg=bg, fg=fg)
|
||
|
||
def _schritt(self, richtung: int):
|
||
if not self.zeilen:
|
||
return
|
||
i = self.aktiv
|
||
for _ in range(len(self.zeilen)):
|
||
i = (i + richtung) % len(self.zeilen)
|
||
if self.zeilen[i][0].get("state") != "disabled":
|
||
break
|
||
self._markieren(i)
|
||
|
||
def _ausfuehren(self, i: int):
|
||
if not (0 <= i < len(self.zeilen)):
|
||
return
|
||
e = self.zeilen[i][0]
|
||
if e.get("state") == "disabled":
|
||
return
|
||
self.schliessen()
|
||
if e.get("command"):
|
||
# nach dem Schließen ausführen (der Befehl öffnet evtl. einen Dialog)
|
||
self.master.after_idle(e["command"])
|
||
|
||
def _fokus_pruefen(self):
|
||
"""In ein anderes Programm gewechselt -> Menü schließen."""
|
||
self._pruefer = None
|
||
if not self.offen:
|
||
return
|
||
try:
|
||
weg = self.fenster.focus_get() is None
|
||
except (tk.TclError, KeyError):
|
||
weg = False
|
||
if weg:
|
||
self.schliessen()
|
||
else:
|
||
self._pruefer = self.fenster.after(250, self._fokus_pruefen)
|
||
|
||
|
||
class Menueleiste(tk.Frame):
|
||
"""Graue Menüleiste mit Popup-Menüs (die native von Windows 10/11 ist weiß)."""
|
||
|
||
def __init__(self, master, root: tk.Misc):
|
||
super().__init__(master, bg=w.FACE)
|
||
self.root = root
|
||
self.knoepfe: dict[str, tk.Label] = {}
|
||
self.menues: dict[tk.Label, Popup] = {}
|
||
self.offener_knopf: tk.Label | None = None
|
||
|
||
def menue(self, label: str, underline: int | None = 0) -> Popup:
|
||
knopf = tk.Label(self, text=label, bg=w.FACE, padx=7, pady=2, bd=1, relief="flat",
|
||
underline=-1 if underline is None else underline)
|
||
knopf.pack(side="left")
|
||
popup = Popup(self)
|
||
self.knoepfe[label] = knopf
|
||
self.menues[knopf] = popup
|
||
knopf.bind("<ButtonPress-1>", lambda e, k=knopf: self._klick(k))
|
||
knopf.bind("<Enter>", lambda e, k=knopf: self._hover(k, True))
|
||
knopf.bind("<Leave>", lambda e, k=knopf: self._hover(k, False))
|
||
if underline is not None:
|
||
taste = label[underline].lower()
|
||
self.root.bind_all(f"<Alt-KeyPress-{taste}>", lambda e, k=knopf: self._alt(k))
|
||
return popup
|
||
|
||
# ---- Zustand
|
||
def knopf_bei(self, x_root: int, y_root: int) -> tk.Label | None:
|
||
for k in self.knoepfe.values():
|
||
if (k.winfo_rootx() <= x_root < k.winfo_rootx() + k.winfo_width()
|
||
and k.winfo_rooty() <= y_root < k.winfo_rooty() + k.winfo_height()):
|
||
return k
|
||
return None
|
||
|
||
def _darstellen(self):
|
||
for k in self.knoepfe.values():
|
||
if k is self.offener_knopf:
|
||
k.configure(bg=w.SEL_BG, fg=w.SEL_FG, relief="flat")
|
||
else:
|
||
k.configure(bg=w.FACE, fg=w.DARK, relief="flat")
|
||
|
||
# ---- Bedienung
|
||
def oeffnen(self, knopf: tk.Label):
|
||
from provtool.gui import modal
|
||
if modal.offen():
|
||
return
|
||
alt = self.offener_knopf
|
||
if alt is not None and alt is not knopf:
|
||
self.menues[alt].leiste = None # ohne Rückmeldung schließen, wir öffnen gleich das nächste
|
||
self.menues[alt].schliessen()
|
||
self.offener_knopf = knopf
|
||
self._darstellen()
|
||
self.menues[knopf].oeffnen(knopf.winfo_rootx(), knopf.winfo_rooty() + knopf.winfo_height(), leiste=self)
|
||
|
||
def schliessen(self):
|
||
if self.offener_knopf is not None:
|
||
self.menues[self.offener_knopf].schliessen()
|
||
|
||
def _geschlossen(self, popup: Popup):
|
||
if self.offener_knopf is not None and self.menues.get(self.offener_knopf) is popup:
|
||
self.offener_knopf = None
|
||
self._darstellen()
|
||
|
||
def nachbar(self, richtung: int):
|
||
liste = list(self.knoepfe.values())
|
||
if self.offener_knopf in liste:
|
||
i = (liste.index(self.offener_knopf) + richtung) % len(liste)
|
||
self.oeffnen(liste[i])
|
||
|
||
def _klick(self, knopf):
|
||
if self.offener_knopf is knopf:
|
||
self.schliessen()
|
||
else:
|
||
self.oeffnen(knopf)
|
||
return "break"
|
||
|
||
def _hover(self, knopf, drin: bool):
|
||
if self.offener_knopf is None:
|
||
if w.MODERN:
|
||
knopf.configure(bg=w.LIGHT if drin else w.FACE)
|
||
else:
|
||
knopf.configure(relief="raised" if drin else "flat")
|
||
|
||
def _alt(self, knopf):
|
||
self.oeffnen(knopf)
|
||
if self.offener_knopf is knopf:
|
||
self.menues[knopf]._schritt(1)
|
||
return "break"
|