Darstellung waehlbar: hell, dunkel oder wie Windows

Seine Ansage
  "waere nun noch gut das mann in den einstellungen einstellen kann hell
   oder dunkel oder automatik das fehlt sollte eigendlich drin sein"

  Er hatte recht: beide Farbpaletten lagen laengst in theme.py, das Programm
  folgte aber starr dem Windows-Theme. Jetzt steht die Wahl unter
  Einstellungen -> Darstellung und wirkt SOFORT, nicht erst beim Neustart.

Umschalten heisst neu bauen
  Die Farben stehen an ueber sechzig Stellen fest in den Widgets. Sie einzeln
  nachzuziehen hiesse, ein Verzeichnis aller eingefaerbten Widgets zu fuehren
  - und wer beim naechsten neuen Widget vergisst, es einzutragen, hat einen
  hellen Fleck im dunklen Fenster, den niemand mehr findet. Neu bauen kann
  nichts vergessen.

  ⚠️ Dabei gingen im ersten Versuch Kundenname und Rechnungsnummer verloren:
  die _build_-Methoden legen ihre StringVars SELBST an, nach dem Neubau
  standen leere da. Gesichert werden jetzt ALLE Tk-Variablen des Fensters,
  nicht eine gepflegte Auswahl - wer spaeter ein Feld hinzufuegt, muss nichts
  eintragen.

Titelleiste, Symbol und Logo gehen mit
  Die Titelleiste gehoert Windows; sie folgt ueber
  DWMWA_USE_IMMERSIVE_DARK_MODE. _dark_titlebar konnte bisher nur DUNKEL und
  zeigt jetzt in beide Richtungen - vorher behielt man beim Wechsel auf hell
  eine schwarze Leiste ueber einem hellen Fenster.

  Sein Befund: "die svg fuer das dunkle heller machen weil die geht nun
  unter" und "icon musst wenn es dunkel ist mit aendern auf das hellere".
  Gemessen sind es zwei Farben: Petrol #004860 und ein fast schwarzes
  #181818 - letzteres liegt genau auf der Kachelfarbe #15171b. Umgestellt
  wird nur die HELLIGKEIT (L -> 1-L, gedeckelt bei 0.78), Farbton und
  Saettigung bleiben: sonst waere es nicht mehr sein Logo. Betrifft
  Wasserzeichen, Kopflogo und das Fenstersymbol; die aufgehellte ICO entsteht
  beim Bauen.

menueleiste.py
  Die selbstgebaute Menueleiste ist jetzt eine eigene Datei und liegt in
  beiden Programmen gleich - das Steuerrechnungstool hatte noch das native
  tk.Menu, das sich unter Windows nicht einfaerben laesst.

pruef_darstellung.py
  Prueft die drei Modi, dass beim Umschalten NICHTS verlorengeht, und dass
  danach kein Widget mehr eine Farbe der anderen Palette traegt - genau der
  helle Fleck, den man sonst nie wiederfindet.

Nebenbei: die beiden Dauertimer melden beim Beenden kein
"invalid command name ..._api_pumpe" mehr. Ein Log, in dem beim normalen
Beenden immer zwei Fehler stehen, liest irgendwann niemand mehr.

Fassung 1.8.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
This commit is contained in:
TheMockTv 2026-09-07 00:34:16 +02:00
parent 20876102d5
commit f7801f46f0
11 changed files with 724 additions and 109 deletions

221
app.py
View file

@ -29,6 +29,7 @@ from tkinter import ttk, messagebox
import api
import config
import menueleiste
import berechnung
import gemeinsam
import hinweis
@ -145,7 +146,6 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
def _build_menu(self):
# Vollstaendig eigene Menueleiste + eigene Dropdowns -> komplett theme-konform
# (native tk-Menubar/Popups lassen sich unter Windows nicht einfaerben).
self._open_pop = None
self._menus = {
"Datei": [
("Vorschau ansehen", self.vorschau_zeigen),
@ -163,72 +163,16 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
("Rechnungsnummer…", self.dlg_nummer),
("Speicherort der Rechnungen…", self.dlg_speicherort),
("Gemeinsamer Ordner (Nummernbuch)…", self.dlg_gemeinsam),
("-", None),
("Darstellung…", self.dlg_darstellung),
],
"Hilfe": [("Info", self._info)],
}
bar = tk.Frame(self, bg=self.col_bg)
bar.pack(side="top", fill="x")
for text, items in self._menus.items():
lbl = tk.Label(bar, text=text, bg=self.col_bg, fg=self.col_fg, padx=8, pady=3)
lbl.pack(side="left")
lbl.bind("<Enter>", lambda e, w=lbl: w.configure(bg=self.col_sel))
lbl.bind("<Leave>", lambda e, w=lbl: w.configure(bg=self.col_bg))
lbl.bind("<Button-1>", lambda e, b=lbl, it=items: self._menu_popup(b, it))
ttk.Separator(self, orient="horizontal").pack(side="top", fill="x")
self.menue = menueleiste.Menueleiste(self, self._menus, farben=self)
# bind statt bind_all: sonst feuert Strg+P auch aus einem offenen
# Dialog heraus und schreibt im Formular dahinter eine Rechnung.
self.bind("<Control-p>", lambda e: self.pdf_erstellen())
def _menu_popup(self, anchor, items):
if self._open_pop: # offenes Menu zu -> Toggle
self._close_pop()
return
pop = tk.Toplevel(self)
pop.overrideredirect(True)
pop.configure(bg=self.col_line) # 1px Rahmen = col_line statt weiss
inner = tk.Frame(pop, bg=self.col_field)
inner.pack(padx=1, pady=1)
for label, cmd in items:
if label == "-":
tk.Frame(inner, bg=self.col_line, height=1).pack(fill="x", pady=3)
continue
it = tk.Label(inner, text=label, bg=self.col_field, fg=self.col_fg,
anchor="w", padx=16, pady=5)
it.pack(fill="x")
it.bind("<Enter>", lambda e, w=it: w.configure(bg=self.col_sel))
it.bind("<Leave>", lambda e, w=it: w.configure(bg=self.col_field))
it.bind("<Button-1>", lambda e, c=cmd: self._run_menu(c))
pop.update_idletasks()
pop.geometry(f"+{anchor.winfo_rootx()}+{anchor.winfo_rooty() + anchor.winfo_height()}")
self._open_pop = pop
pop.grab_set()
pop.bind("<Button-1>", self._maybe_close_pop, add="+")
pop.bind("<Escape>", lambda e: self._close_pop())
self._dark_titlebar(pop)
def _maybe_close_pop(self, event):
pop = self._open_pop
if not pop:
return
if (event.x_root < pop.winfo_rootx() or event.x_root > pop.winfo_rootx() + pop.winfo_width()
or event.y_root < pop.winfo_rooty() or event.y_root > pop.winfo_rooty() + pop.winfo_height()):
self._close_pop()
def _run_menu(self, cmd):
self._close_pop()
if cmd:
cmd()
def _close_pop(self):
if getattr(self, "_open_pop", None):
try:
self._open_pop.grab_release()
self._open_pop.destroy()
except tk.TclError:
pass
self._open_pop = None
# ------------------------------------------------------- Arbeitsflaeche
def _build_flaeche(self):
"""Die Kaesten und der Knopf, mit dem man sie verschieben darf.
@ -504,6 +448,134 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
self._render_positionszeilen()
# ------------------------------------------------------- Darstellung
def _oberflaeche_neu(self):
"""Die ganze Oberflaeche neu zeichnen - fuer den Wechsel hell/dunkel.
WARUM NEU BAUEN UND NICHT UMFAERBEN
-----------------------------------
Die Farben stehen an ueber sechzig Stellen fest in den Widgets
(tk.Label mit bg=..., Streifen der Leistungsliste, Kopfzeilen der
Kaesten). Sie einzeln nachzuziehen hiesse, ein Verzeichnis aller
eingefaerbten Widgets zu fuehren - und wer beim naechsten neuen Widget
vergisst, es einzutragen, hat einen hellen Fleck im dunklen Fenster,
den niemand mehr findet. Neu bauen kann nichts vergessen.
WAS DABEI ERHALTEN BLEIBT
-------------------------
Alles Eingetippte. Ein Themenwechsel darf keine halb getippte Rechnung
wegwerfen.
Dass die Werte an StringVars haengen, reicht dafuer NICHT: die
_build_-Methoden legen ihre Vars selbst an (`self.k_name =
tk.StringVar()`), beim Neubau stehen also neue, leere Vars da. Beim
ersten Versuch waren Kundenname und Rechnungsnummer weg, waehrend die
Mengen blieben - die hatte ich einzeln gesichert.
Deshalb werden hier ALLE Variablen des Fensters gesichert, nicht eine
Auswahl: durchgegangen wird, was das Objekt an Tk-Variablen traegt.
Wer spaeter ein Feld hinzufuegt, muss nichts eintragen - es ist
automatisch dabei. Eine Liste zum Pflegen waere genau die Sorte, die
man beim naechsten Feld vergisst.
"""
gesichert = {}
for name in dir(self):
if name.startswith("__"):
continue
try:
wert = getattr(self, name)
except Exception: # noqa: BLE001 - Eigenschaften koennen werfen
continue
if isinstance(wert, (tk.StringVar, tk.IntVar, tk.DoubleVar,
tk.BooleanVar)):
try:
gesichert[name] = wert.get()
except tk.TclError:
pass
mengen = {nr: v.get() for nr, v in self.anzahl_vars.items()}
personen = {nr: v.get() for nr, v in self.personen_vars.items()}
blatt = None
try:
blatt = self.blaetter.index(self.blaetter.select())
except Exception: # noqa: BLE001 - dann eben das erste Blatt
pass
for kind in self.winfo_children():
kind.destroy()
self.flaeche = None
self._text_zellen = []
self._apply_theme()
self._build_menu()
self._build_summen()
self._build_flaeche()
self._build_kopf()
self._build_positionen()
self.flaeche.anordnen()
for name, wert in gesichert.items():
neu = getattr(self, name, None)
if isinstance(neu, (tk.StringVar, tk.IntVar, tk.DoubleVar,
tk.BooleanVar)):
try:
neu.set(wert)
except tk.TclError:
pass
for nr, wert in mengen.items():
if nr in self.anzahl_vars:
self.anzahl_vars[nr].set(wert)
for nr, wert in personen.items():
if nr in self.personen_vars:
self.personen_vars[nr].set(wert)
if blatt is not None:
try:
self.blaetter.select(blatt)
except Exception: # noqa: BLE001
pass
self._buch_anzeigen()
self.recalc()
def dlg_darstellung(self):
"""Hell, dunkel oder wie Windows - und sofort sichtbar."""
d = tk.Toplevel(self)
d.title("Darstellung")
d.transient(self)
d.resizable(False, False)
self._style_dialog(d)
rahmen = ttk.Frame(d, padding=18)
rahmen.pack(fill="both", expand=True)
ttk.Label(rahmen, text="Darstellung", font=(theme.SCHRIFT_NAME, 11, "bold"),
foreground=self.col_accent).pack(anchor="w")
ttk.Label(rahmen, justify="left", wraplength=340, foreground=self.col_muted,
text="Gilt nur für das Programmfenster. Die Farbe der Rechnung "
"steht unter Einstellungen → Firmendaten – das Blatt "
"gehört dem Kunden, nicht dem Programm.").pack(
anchor="w", pady=(6, 12))
gewaehlt = tk.StringVar(value=theme.modus(self.cfg))
def uebernehmen():
neu = gewaehlt.get()
if neu == theme.modus(self.cfg):
return
self.cfg["darstellung"] = neu
config.save_config(self.cfg)
self._oberflaeche_neu()
# Der Dialog gehoert noch zur alten Darstellung - er wird
# mitgefaerbt, sonst steht ein helles Fenster vor einem dunklen.
self._style_dialog(d)
for wert in theme.MODI:
ttk.Radiobutton(rahmen, text=theme.MODUS_NAMEN[wert], value=wert,
variable=gewaehlt, command=uebernehmen).pack(
anchor="w", pady=3)
ttk.Separator(rahmen, orient="horizontal").pack(fill="x", pady=(12, 10))
ttk.Button(rahmen, text="Schließen", width=14,
command=d.destroy).pack(anchor="e")
d.geometry(f"+{self.winfo_rootx() + 120}+{self.winfo_rooty() + 140}")
return d
def _umbruch_nachziehen(self, runde=0, vorige_breite=None):
"""Den Textumbruch der Leistungsspalte auf ihre echte Breite setzen.
@ -714,7 +786,8 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
# Das Programmlogo neben der Marke - klein, immer sichtbar. Es steht
# rechts bei "powered by ravokk", weil dort die Kennung des Werkzeugs
# sitzt und nicht die des Kunden.
self._kopf_bild = wasserzeichen.kopf(18, master=self)
self._kopf_bild = wasserzeichen.kopf(18, master=self,
dunkel=getattr(self, "_dark_mode", False))
if self._kopf_bild is not None:
ttk.Label(status, image=self._kopf_bild).pack(side="right", padx=(0, 6))
status.pack(side="bottom", fill="x")
@ -753,7 +826,8 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
def _wasserzeichen_setzen(self, groesse=240):
"""Das Programmlogo blass hinter die Arbeitsflaeche legen."""
bild = wasserzeichen.hole(groesse, 0.06, master=self)
bild = wasserzeichen.hole(groesse, 0.06, master=self,
dunkel=getattr(self, "_dark_mode", False))
if bild is None:
return
# Die Referenz MUSS bleiben - sonst raeumt Python das Bild weg und Tk
@ -766,7 +840,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
def _fenstersymbol(self):
"""Das Programmsymbol auf das Fenster legen."""
return wasserzeichen.symbol_setzen(self)
return wasserzeichen.symbol_setzen(self, dunkel=getattr(self, "_dark_mode", False))
print("[symbol] assets/programm.ico nicht gefunden")
return False
@ -1145,7 +1219,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
except Exception as e: # noqa: BLE001
print("[push]", e)
finally:
self.after(250, self._push_abholen)
self._wieder(250, self._push_abholen)
def _nachtragen(self):
"""Alles, was schon im Rechnungsordner liegt, ins Buch schreiben.
@ -1398,6 +1472,23 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
return jahr, zaehler, f"{jahr}-A{zaehler:03d}"
# ------------------------------------------------- Bedienung ueber die API
def _wieder(self, ms, was):
"""Einen sich selbst planenden Timer neu setzen - nur solange es das
Fenster noch gibt.
Ohne das melden die beiden Dauertimer beim Beenden
`invalid command name ..._api_pumpe`: Tk hat das Fenster samt Callback
schon abgeraeumt, der eingeplante Aufruf laeuft aber noch. Das
stuerzt nichts ab, landet aber als Fehler im Log - und ein Log, in dem
beim normalen Beenden immer zwei Fehler stehen, liest irgendwann
niemand mehr.
"""
try:
if self.winfo_exists():
self.after(ms, was)
except tk.TclError:
pass # Fenster ist weg - dann gibt es nichts mehr zu tun
def _api_pumpe(self):
"""Holt Aktionen aus der Warteschlange und fuehrt sie HIER aus.
@ -1407,7 +1498,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
try:
self._api_schlange.abarbeiten(self._api_aktion)
finally:
self.after(150, self._api_pumpe)
self._wieder(150, self._api_pumpe)
def _api_aktion(self, name, daten):
"""Eine Bedienung ausfuehren. Der Schluessel ist schon geprueft (api.py).