Steuerrechnungstool/theme.py
TheMockTv 96671027c8 Aufraeumen nach der Pruefkette: doppelter Code an eine Stelle
Die Runde vom 07.09.2026 hat neben den zwei echten Fehlern eine Reihe
Wiederholungen und stumme Stellen gemeldet. Abgearbeitet in Etappen, jede fuer
sich pruefbar - nach jeder liefen beide Pruefketten gruen.

* Menueleiste: der Wechsel von einem Titel zum anderen brauchte ZWEI Klicks.
  Der grab_set der offenen Liste schluckt den ersten, die <Button-1>-Bindung
  des Titels feuert gar nicht. Das native tk.Menu, das diese Leiste ersetzt
  hat, wechselte schon beim blossen Ueberfahren - zwei Klicks waren also eine
  Verschlechterung gegenueber dem, was vorher da war. Die Klappliste wird
  ausserdem jetzt wie jede andere Position ueber theme.auf_bildschirm()
  begrenzt; am unteren Fensterrand lief sie vorher hinaus.
  Neu: pruef_menueleiste.py misst beides ueber echte Klickereignisse.

* %LOCALAPPDATA%/ravokk wurde an drei Stellen einzeln ausgerechnet. Jetzt
  fragen alle gemeinsam.standard_ordner(). Laufen die je auseinander, fuehren
  die beiden Programme zwei Nummernbuecher - und das faellt erst auf, wenn
  eine Rechnungsnummer zum zweiten Mal vergeben ist (§ 14 Abs. 4 Nr. 4 UStG).

* pruefe() stand 35-mal im Quelltext, in sechs Fassungen -> pruefhelfer.py.
  Die zwei Ausreisser sind mitgezogen: pruef_storno_verrechnet.py zaehlte in
  einer Zahl und konnte am Ende nicht sagen, WAS fehlschlug, pruef_bilder.py
  im Rechnungstool schrieb ein eigenes Format.

* dlg_darstellung() und die drei Handgriffe des Umschaltens standen in beiden
  app.py fast wortgleich -> ThemeMixin in theme.py. Beide trugen inzwischen
  denselben langen Kommentar zu demselben Fehler; das war der Beweis, dass es
  eine Sache ist. Programmspezifisch bleiben der Hinweistext im Dialog (R31)
  und die Frage, ob die Einstellungen ausdruecklich gespeichert werden muessen.

* pruef_gemeinsam_automatisch.py sagte im Text "genau ein Buch" und prueft
  ">= 1" mit einem any() - eine Pruefung, die nicht rot werden kann. Jetzt
  == 1, und der Dateiname wird mitgeprueft.
  Dazu neu: die neun Dateien, die im Kopf zusagen, sie laegen in beiden
  Programmen gleich, werden byteweise verglichen. Bei hinweis.py stimmte die
  Zusage seit dem 06.09. nicht mehr - gleicher Inhalt, CRLF gegen LF.

* .gitattributes, damit die Zeilenenden nicht von der Maschine abhaengen.
  Ohne das meldet genau diese Pruefung nach einem frischen Checkout einen
  Unterschied, den es im Repository gar nicht gibt.

* Die stummen "except OSError" in api.py, updater.py und einzelinstanz.py
  sagen jetzt, warum sie schweigen duerfen. Die stummen tk.TclError bleiben:
  dort wird ein Widget angefasst, das gerade zerstoert wurde, und eine Meldung
  waere Rauschen.

* Tote Parameter und Variablen aus dem Umbau des Vortags: symbol_setzen(dunkel=),
  kachel(grund=, radius=), mit_trennlinie, sechs x/y-Berechnungen, die von
  theme.mittig() sofort ueberschrieben wurden, vier lokale "import ctypes".

39 Pruefstaende, 0 rot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 18:39:03 +02:00

819 lines
39 KiB
Python

# -*- coding: utf-8 -*-
"""Farben nach dem Windows-Theme (hell/dunkel) - inklusive Titelleiste.
Native tk-Menues und -Popups lassen sich unter Windows nicht einfaerben, darum
baut das Programm Menueleiste und Dropdowns selbst; hier stehen nur die Farben.
"""
import ctypes.wintypes
import os
import sys
import tkinter as tk
from tkinter import font as tkfont
from tkinter import messagebox
from tkinter import ttk
# ---------------------------------------------------------------------------
# Diese Datei liegt in BEIDEN Programmen gleich und darf deshalb NICHTS aus
# einem der beiden importieren. Frueher holte sie sich die Ordner und den
# Testlauf-Schalter aus config.py - die gibt es aber nur im Rechnungstool, und
# beim Uebernehmen ins Steuerrechnungstool flog sie beim Import auseinander.
# Die zwei Dinge, die gebraucht werden, stehen jetzt hier.
# ---------------------------------------------------------------------------
def _ordner():
"""Alle Orte, an denen assets/ liegen kann - Quelle wie gepackte EXE."""
hier = os.path.dirname(os.path.abspath(__file__))
aus_exe = getattr(sys, "_MEIPASS", "")
neben_exe = (os.path.dirname(os.path.abspath(sys.executable))
if getattr(sys, "frozen", False) else "")
return [p for p in (aus_exe, neben_exe, hier) if p]
def titelleiste_faerben(fenster, dunkel):
"""Die Titelleiste des Fensters mit einfaerben (Windows 10 ab 1809).
Tk kann das nicht - die Leiste mit Symbol, Titel und den drei Knoepfen
gehoert Windows, nicht dem Programm. Windows nimmt dafuer aber einen
Hinweis entgegen: DWMWA_USE_IMMERSIVE_DARK_MODE. Ohne den steht ein weisser
Balken ueber einem dunklen Fenster (sein Befund am 07.09.2026: "der icon
und titel leiste auch ... die sind noch hell").
Die Nummer des Attributs hat sich einmal geaendert: 19 in den fruehen
1809er-Fassungen, 20 seit 2004. Beide werden gesetzt; die falsche wird
ignoriert.
"""
if not sys.platform.startswith("win"):
return False
try:
fenster.update_idletasks()
hwnd = ctypes.windll.user32.GetParent(fenster.winfo_id()) or int(fenster.frame(), 16)
wert = ctypes.c_int(1 if dunkel else 0)
gesetzt = False
for attribut in (20, 19):
ergebnis = ctypes.windll.dwmapi.DwmSetWindowAttribute(
ctypes.wintypes.HWND(hwnd), ctypes.c_int(attribut),
ctypes.byref(wert), ctypes.sizeof(wert))
gesetzt = gesetzt or ergebnis == 0
# Den Rahmen neu zeichnen lassen, ohne Groesse, Platz, Reihenfolge oder
# Fokus anzufassen. Ohne das bleibt die Leiste stehen, wie sie war -
# die Umstellung wirkt dann erst beim naechsten Start.
SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED
ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP)
return gesetzt
except Exception: # noqa: BLE001 - ohne dunkle Titelleiste laeuft es weiter
return False
def pruefplatz():
"""Wo ein Pruefstand seine Fenster aufmachen soll: "+x+y" oder "".
Seine Ansage am 07.09.2026: *"teste links auf dem 1080p monitor nicht mehr
auf dem main ... weil da schau ich nun video"*.
Vorher lag der Platz bei `Bildschirmbreite + 20` - also RECHTS neben dem
Hauptmonitor. Bei ihm steht der zweite Monitor aber LINKS (x = -1920), und
rechts ist gar nichts: die Fenster gingen ins Leere. Sie liefen zwar, waren
aber nirgends zu sehen - das hat beim Bildermachen einige Zeit gekostet.
Gesucht wird deshalb der nicht-primaere Monitor. Gibt es keinen, bleibt es
beim alten Verhalten (neben den Bildschirm), denn im Weg sein soll ein
Pruefstand auch dann nicht.
"""
if not sys.platform.startswith("win"):
return ""
try:
class RECT(ctypes.Structure):
_fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long),
("right", ctypes.c_long), ("bottom", ctypes.c_long)]
class MONITORINFO(ctypes.Structure):
_fields_ = [("cbSize", ctypes.wintypes.DWORD), ("rcMonitor", RECT),
("rcWork", RECT), ("dwFlags", ctypes.wintypes.DWORD)]
gefunden = []
def je_monitor(hmon, hdc, lprect, daten):
info = MONITORINFO()
info.cbSize = ctypes.sizeof(MONITORINFO)
if ctypes.windll.user32.GetMonitorInfoW(hmon, ctypes.byref(info)):
r = info.rcWork
gefunden.append((bool(info.dwFlags & 1), # 1 = MONITORINFOF_PRIMARY
r.left, r.top, r.right - r.left))
return 1
RUECKRUF = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.wintypes.HMONITOR,
ctypes.wintypes.HDC, ctypes.POINTER(RECT),
ctypes.wintypes.LPARAM)
ctypes.windll.user32.EnumDisplayMonitors(0, 0, RUECKRUF(je_monitor), 0)
# Der breiteste Nebenmonitor - bei mehreren der linkeste.
neben = sorted((m for m in gefunden if not m[0]),
key=lambda m: (-m[3], m[1]))
if neben:
_, x, y, _ = neben[0]
return f"+{x + 30}+{y + 40}"
except Exception: # noqa: BLE001 - dann eben der alte Weg
pass
return ""
def auf_bildschirm(x, y, breite=0, hoehe=0):
"""Eine Fensterposition auf den sichtbaren Bereich begrenzen.
Frueher stand an diesen Stellen `max(0, x)`. Das sollte verhindern, dass
ein Dialog ausserhalb des Bildschirms aufgeht - klemmt aber jede NEGATIVE
Koordinate auf null. Steht der zweite Monitor links (bei ihm x = -1920),
sprang damit jedes Popup zurueck auf den Hauptbildschirm, obwohl das
Programm nebenan lief. Sein Befund am 07.09.2026: *"da gehen noch die
popups auf dem video monitor auf"*.
Begrenzt wird deshalb auf den GESAMTEN Desktop ueber alle Monitore
(SM_*VIRTUALSCREEN), nicht auf den Hauptmonitor.
"""
if not sys.platform.startswith("win"):
return int(x), int(y)
try:
hole = ctypes.windll.user32.GetSystemMetrics
links, oben = hole(76), hole(77)
rechts, unten = links + hole(78), oben + hole(79)
if breite:
x = min(x, rechts - breite)
if hoehe:
y = min(y, unten - hoehe)
return int(max(links, x)), int(max(oben, y))
except Exception: # noqa: BLE001 - dann eben ungebremst
return int(x), int(y)
def mittig(dialog, ueber):
"""Einen Dialog mittig ueber sein Elternfenster setzen.
Seine Ansage am 07.09.2026: *"einfach so programmieren das die popup auf
dem fenster mittig vom main fenster auf gehen"*.
Das ist zugleich die Antwort auf die Monitorfrage: steht das Programm auf
dem zweiten Bildschirm, geht der Dialog dort auf - ohne dass irgendwo ein
Monitor gesucht werden muesste. Vorher standen an den Dialogen feste
Versaetze ("+120+140") und ein `max(0, x)`, das jede negative Koordinate
auf null klemmte und die Fenster damit auf den Hauptmonitor zurueckriss.
Etwas oberhalb der Mitte (ein Drittel statt der Haelfte): so sitzt der
Dialog dort, wo der Blick ohnehin ist, und verdeckt nicht die Mitte des
Formulars darunter.
"""
try:
dialog.update_idletasks()
breite = dialog.winfo_reqwidth() or dialog.winfo_width()
hoehe = dialog.winfo_reqheight() or dialog.winfo_height()
x = ueber.winfo_rootx() + (ueber.winfo_width() - breite) // 2
y = ueber.winfo_rooty() + (ueber.winfo_height() - hoehe) // 3
x, y = auf_bildschirm(x, y, breite, hoehe)
dialog.geometry(f"+{x}+{y}")
except tk.TclError:
pass # Fenster schon zu - dann ist die Position gleichgueltig
def testlauf():
"""Faehrt gerade ein Pruefstand das Programm?
Muss mit der Definition in app.py uebereinstimmen - beide Programme
schalten ueber `--test` oder die Umgebungsvariable.
"""
return ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1"
# Schrift: Roboto, so wie in den Web-Oberflaechen. Sie wird NICHT auf dem
# Rechner installiert, sondern nur fuer diesen einen Prozess geladen (die
# Windows-Funktion AddFontResourceEx mit FR_PRIVATE). Damit braucht niemand
# Adminrechte, und nach dem Schliessen ist der Rechner wieder wie vorher.
# Findet sich die Datei nicht, bleibt es bei Segoe UI - das Programm muss auch
# ohne mitgelieferte Schrift laufen.
SCHRIFT_NAME = "Segoe UI"
SCHRIFT = (SCHRIFT_NAME, 10)
KLEIN = (SCHRIFT_NAME, 9)
_schrift_geladen = False
def schrift_vorbereiten():
"""Roboto laden und zur Schrift des Programms machen. Gibt den Namen zurueck."""
global SCHRIFT_NAME, SCHRIFT, KLEIN, _schrift_geladen
if _schrift_geladen:
return SCHRIFT_NAME
_schrift_geladen = True
if not sys.platform.startswith("win"):
return SCHRIFT_NAME
FR_PRIVATE = 0x10
geladen = 0
for basis in _ordner():
ordner = os.path.join(basis, "assets", "fonts")
if not os.path.isdir(ordner):
continue
for datei in sorted(os.listdir(ordner)):
if datei.lower().endswith((".ttf", ".otf")):
geladen += ctypes.windll.gdi32.AddFontResourceExW(
os.path.join(ordner, datei), FR_PRIVATE, 0)
if geladen:
break
if not geladen:
print("[schrift] Roboto nicht gefunden - bleibe bei Segoe UI")
return SCHRIFT_NAME
# Ob Windows sie wirklich angenommen hat, verraet erst die Schriftenliste.
if "Roboto" not in tkfont.families():
print("[schrift] Roboto liess sich nicht laden - bleibe bei Segoe UI")
return SCHRIFT_NAME
SCHRIFT_NAME = "Roboto"
SCHRIFT = (SCHRIFT_NAME, 10)
KLEIN = (SCHRIFT_NAME, 9)
for name in ("TkDefaultFont", "TkTextFont", "TkMenuFont", "TkHeadingFont",
"TkIconFont", "TkTooltipFont"):
try:
tkfont.nametofont(name).configure(family=SCHRIFT_NAME, size=10)
except tk.TclError:
pass
print("[schrift] Roboto geladen")
return SCHRIFT_NAME
def mischen(vorn, hinten, anteil):
"""Wie 'rgba(vorn, anteil)' auf 'hinten' - nur ausgerechnet statt gemalt.
Tk kann keine halbdurchsichtigen Flaechen (kein backdrop-filter, keine
Alpha-Kanaele auf Widgets). Seine Web-Oberflaechen bauen den Glas-Effekt aus
genau zwei Zutaten: eine getoente Flaeche und ein feiner Rand in
Akzent-Transparenz. Beides laesst sich vorher ausrechnen - das Ergebnis sieht
identisch aus, solange der Grund einfarbig ist. Und der ist es hier.
"""
v = vorn.lstrip("#")
h = hinten.lstrip("#")
teile = []
for i in (0, 2, 4):
a = int(v[i:i + 2], 16)
b = int(h[i:i + 2], 16)
teile.append(round(a * anteil + b * (1 - anteil)))
return "#%02x%02x%02x" % tuple(teile)
def windows_dark_mode():
"""True, wenn Windows auf das dunkle App-Theme eingestellt ist."""
if not sys.platform.startswith("win"):
return False
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as k:
return winreg.QueryValueEx(k, "AppsUseLightTheme")[0] == 0
except OSError:
return False
# ---------------------------------------------------------------------------
# Welche Darstellung ist gewuenscht?
# ---------------------------------------------------------------------------
# Drei Moeglichkeiten, gespeichert in der config unter "darstellung":
#
# "auto" - folgt Windows (Voreinstellung, wie es bisher immer war)
# "hell" - immer hell
# "dunkel" - immer dunkel
#
# "auto" ist die Voreinstellung, weil sich das Programm dann verhaelt wie jedes
# andere: wer sein Windows abends auf dunkel stellt, bekommt es auch hier.
MODI = ("auto", "hell", "dunkel")
MODUS_NAMEN = {"auto": "Automatisch (wie Windows)",
"hell": "Hell",
"dunkel": "Dunkel"}
def modus(cfg):
"""Der eingestellte Modus - immer einer aus MODI."""
wert = str((cfg or {}).get("darstellung", "auto")).strip().lower()
return wert if wert in MODI else "auto"
def ist_dunkel(cfg):
"""Soll dunkel gezeichnet werden? Die eine Stelle, die das entscheidet."""
m = modus(cfg)
if m == "hell":
return False
if m == "dunkel":
return True
return windows_dark_mode()
class ThemeMixin:
"""Farben setzen. Erwartet ein tk.Tk als Basis."""
# ----------------------------------------------------------------- Theme
def _apply_theme(self):
"""Farben passend zur Einstellung setzen (hell, dunkel oder wie Windows)."""
schrift_vorbereiten() # Roboto, sonst Segoe UI
dark = ist_dunkel(getattr(self, "cfg", None))
self._dark_mode = dark
# Die Farben sind die aus seinen anderen Sachen (FiveM-Oberflaechen,
# Adminpage, Webseiten): Tuerkis #06C6A4 auf sehr dunklen, gestuften
# Flaechen. Das PROGRAMM traegt diese Handschrift - die RECHNUNG traegt
# die Farbe der jeweiligen Firma (Einstellungen -> Farbe der Rechnung).
# Beides zu vermischen waere falsch: das Blatt gehoert dem Kunden.
if dark:
self.col_bg = "#0d0e10" # Fenster-Hintergrund (tiefstes Grau)
self.col_card = "#15171b" # Kachel / Abschnitt
self.col_fg = "#e8e8ea" # Schrift
self.col_field = "#2c2e32" # Eingabefelder (heller als die Kachel)
self.col_feldrand = "#57585b" # Rahmen der Eingabefelder
self.col_sel = "#1f3d38" # aktiv/hover (Akzent, stark abgedunkelt)
self.col_accent = "#06C6A4" # Akzent (Pcore-Tuerkis)
self.col_accent_hell = "#08dbb5"
self.col_auf_accent = "#08221d" # Schrift AUF der Akzentflaeche
self.col_gefahr = "#e85a5a"
self.col_line = "#25282e"
self.col_scroll = "#2f333a"
self.col_muted = "#8a8e96"
self.col_zebra = "#232830" # jede zweite Zeile der Liste
else:
self.col_bg = "#eef0f3"
self.col_card = "#ffffff"
self.col_fg = "#14161a"
self.col_field = "#ffffff"
self.col_feldrand = "#b9bec6"
self.col_sel = "#d7f5ee"
self.col_accent = "#04886f" # dunkler, damit es auf Weiss lesbar ist
self.col_accent_hell = "#06C6A4"
self.col_auf_accent = "#ffffff"
self.col_gefahr = "#c0392b"
self.col_line = "#d8dce1"
self.col_scroll = "#c2c6cc"
self.col_muted = "#5c626b"
self.col_zebra = "#e4e8ee"
self.configure(bg=self.col_bg)
# Die Titelleiste gehoert Windows - sie muss eigens umgestellt werden.
titelleiste_faerben(self, dark)
# Und das Fenstersymbol: in der dunklen Titelleiste geht die dunkle
# Haelfte des Zeichens sonst unter. Es haengt hier und nicht im
# __init__, damit es beim Umschalten mitgeht - dort wird _apply_theme
# erneut gerufen, das __init__ nicht.
symbol = getattr(self, "_fenstersymbol", None)
if callable(symbol):
symbol()
style = ttk.Style(self)
try:
style.theme_use("clam")
except tk.TclError:
pass
bg, fg, field, sel, accent = (self.col_bg, self.col_fg, self.col_field,
self.col_sel, self.col_accent)
style.configure(".", background=bg, foreground=fg, fieldbackground=field,
bordercolor=self.col_line, lightcolor=bg, darkcolor=bg)
style.configure("TFrame", background=bg)
style.configure("TLabel", background=bg, foreground=fg, font=SCHRIFT)
style.configure("TLabelframe", background=bg, bordercolor=self.col_line)
style.configure("TLabelframe.Label", background=bg, foreground=accent)
# --- Kacheln: ein Abschnitt ist eine Flaeche mit Rand, keine Linie ---
style.configure("Karte.TFrame", background=self.col_card,
bordercolor=self.col_line, relief="solid", borderwidth=1)
# Ein Abschnitt ist eine Kachel: eigene Flaeche, feiner Rand, die
# Ueberschrift in Akzentfarbe - genau wie die Kacheln in den
# Web-Oberflaechen.
# Der Kasten, den man gerade in der Hand hat: blass, gestrichelt.
style.configure("Gegriffen.TFrame", background=self.col_bg,
bordercolor=self.col_muted, relief="solid", borderwidth=1,
lightcolor=self.col_muted, darkcolor=self.col_muted)
# Der Kasten, auf den beim Anordnen gerade gezielt wird.
style.configure("Ziel.TFrame", background=self.col_card,
bordercolor=accent, relief="solid", borderwidth=2,
lightcolor=accent, darkcolor=accent)
style.configure("Karte.TLabelframe", background=self.col_card,
bordercolor=self.col_line, relief="solid", borderwidth=1,
lightcolor=self.col_card, darkcolor=self.col_card)
style.configure("Karte.TLabelframe.Label", background=self.col_card,
foreground=accent, font=(SCHRIFT_NAME, 9, "bold"))
style.configure("Karte.TEntry", fieldbackground=field, foreground=fg)
style.configure("Karte.TLabel", background=self.col_card, foreground=fg, font=SCHRIFT)
style.configure("KarteMuted.TLabel", background=self.col_card,
foreground=self.col_muted, font=KLEIN)
# Ueberschrift eines Abschnitts: klein, gross geschrieben, in Akzentfarbe -
# so wie die Kachelkoepfe in den Web-Oberflaechen.
style.configure("Abschnitt.TLabel", background=self.col_card,
foreground=accent, font=(SCHRIFT_NAME, 9, "bold"))
style.configure("Wert.TLabel", background=self.col_card, foreground=fg,
font=(SCHRIFT_NAME, 10))
style.configure("Summe.TLabel", background=bg, foreground=accent,
font=(SCHRIFT_NAME, 15, "bold"))
style.configure("Zebra.TLabel", background=self.col_zebra, foreground=fg, font=SCHRIFT)
# --- Knoepfe im Glas-Stil (wie in seinen Oberflaechen) ---
# Getoente Flaeche, feiner Rand in Akzent-Transparenz, Schrift in der
# Akzentfarbe. Kein Vollton: der wirkt in dieser Palette wie ein
# Fremdkoerper.
glas = mischen(accent, bg, 0.10) # ruhige Flaeche
glas_hover = mischen(accent, bg, 0.20)
rand = mischen(accent, bg, 0.35)
rand_hell = mischen(accent, bg, 0.55)
style.configure("TButton", background=glas, foreground=fg,
bordercolor=rand, padding=(14, 7), font=SCHRIFT,
focuscolor=rand, relief="solid", borderwidth=1,
lightcolor=rand, darkcolor=rand)
style.map("TButton",
background=[("pressed", mischen(accent, bg, 0.28)), ("active", glas_hover)],
bordercolor=[("active", rand_hell)],
lightcolor=[("active", rand_hell)], darkcolor=[("active", rand_hell)],
foreground=[("disabled", self.col_muted)])
# Der Knopf, um den es geht: dieselbe Glasflaeche, aber kraeftiger
# getoent, mit Akzentrand und Akzentschrift.
haupt = mischen(accent, bg, 0.22)
style.configure("Haupt.TButton", background=haupt,
foreground=self.col_accent_hell, bordercolor=self.col_accent,
lightcolor=self.col_accent, darkcolor=self.col_accent,
relief="solid", borderwidth=1,
padding=(18, 8), font=(SCHRIFT_NAME, 10, "bold"))
style.map("Haupt.TButton",
background=[("pressed", mischen(accent, bg, 0.45)),
("active", mischen(accent, bg, 0.34)),
("disabled", bg)],
foreground=[("disabled", self.col_muted)])
# Storno greift in eine ausgestellte Rechnung ein - gleicher Stil, rot.
g_rot = mischen(self.col_gefahr, bg, 0.10)
style.configure("Gefahr.TButton", background=g_rot, foreground=self.col_gefahr,
bordercolor=mischen(self.col_gefahr, bg, 0.35),
lightcolor=mischen(self.col_gefahr, bg, 0.35),
darkcolor=mischen(self.col_gefahr, bg, 0.35),
relief="solid", borderwidth=1,
padding=(14, 7), font=SCHRIFT)
style.map("Gefahr.TButton",
background=[("pressed", mischen(self.col_gefahr, bg, 0.30)),
("active", mischen(self.col_gefahr, bg, 0.20))],
bordercolor=[("active", mischen(self.col_gefahr, bg, 0.55))],
lightcolor=[("active", mischen(self.col_gefahr, bg, 0.55))],
darkcolor=[("active", mischen(self.col_gefahr, bg, 0.55))])
style.configure("Menu.TMenubutton", background=bg, foreground=fg, relief="flat",
arrowcolor=bg, padding=(10, 3))
style.map("Menu.TMenubutton", background=[("active", sel)], foreground=[("active", fg)])
style.configure("TCheckbutton", background=bg, foreground=fg)
style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)])
# Eingabefelder heben sich vom Grund ab - sonst sieht man nicht, wo etwas
# einzutragen ist. JEDES Feld, in das man tippen kann, traegt diese zwei
# Farben: die ttk-Felder ueber die Stile hier, die klassischen Widgets
# (Textfelder, Auswahlliste) ueber feld_optionen() weiter unten.
feld_hell, feld_rand = self.col_field, self.col_feldrand
style.configure("TEntry", fieldbackground=feld_hell, foreground=fg,
insertcolor=fg, bordercolor=feld_rand,
lightcolor=feld_rand, darkcolor=feld_rand,
relief="solid", borderwidth=1, padding=5)
style.map("TEntry",
bordercolor=[("focus", accent)], lightcolor=[("focus", accent)],
darkcolor=[("focus", accent)],
fieldbackground=[("focus", mischen(accent, self.col_card, 0.10))])
# Die Felder IN der Leistungsliste stehen auf den Zeilenstreifen und
# gingen darin unter - man sah nicht, wo man tippt. Sie bekommen einen
# helleren Grund und einen deutlichen Rahmen, beim Tippen den Akzent.
style.configure("Liste.TEntry", fieldbackground=feld_hell, foreground=fg,
insertcolor=fg, bordercolor=feld_rand,
lightcolor=feld_rand, darkcolor=feld_rand,
relief="solid", borderwidth=1, padding=4)
style.map("Liste.TEntry",
bordercolor=[("focus", accent)], lightcolor=[("focus", accent)],
darkcolor=[("focus", accent)],
fieldbackground=[("focus", mischen(accent, self.col_card, 0.10))])
# Bei state="readonly" zeichnet die Combobox ihren Text als MARKIERUNG,
# sobald sie den Fokus hat - ohne diese beiden Farben steht der Wert
# unsichtbar im Feld.
style.configure("TCombobox", fieldbackground=feld_hell, foreground=fg,
background=feld_hell, arrowcolor=fg,
bordercolor=feld_rand, lightcolor=feld_rand, darkcolor=feld_rand,
selectbackground=feld_hell, selectforeground=fg, padding=3)
style.map("TCombobox", fieldbackground=[("readonly", feld_hell)],
foreground=[("readonly", fg)], background=[("readonly", feld_hell)],
bordercolor=[("focus", accent)],
selectbackground=[("readonly", feld_hell)],
selectforeground=[("readonly", fg)])
# Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget
self.option_add("*TCombobox*Listbox.background", feld_hell)
self.option_add("*TCombobox*Listbox.foreground", fg)
self.option_add("*TCombobox*Listbox.selectBackground", sel)
self.option_add("*TCombobox*Listbox.selectForeground", fg)
for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"):
style.configure(sbs, troughcolor=bg, background=self.col_scroll,
bordercolor=bg, lightcolor=bg, darkcolor=bg,
arrowcolor=fg, relief="flat", gripcount=0)
style.map(sbs, background=[("active", accent)],
troughcolor=[("disabled", bg)])
# Treeview - die Tabelle des Steuerrechnungstools. Ohne diesen Block
# bleibt sie weiss im dunklen Fenster: ttk zeichnet Listen nicht ueber
# den "."-Grundstil, sondern nur ueber den eigenen.
style.configure("Treeview", background=self.col_card,
fieldbackground=self.col_card, foreground=fg,
bordercolor=self.col_line, rowheight=24,
font=SCHRIFT)
style.configure("Treeview.Heading", background=bg, foreground=accent,
relief="flat", font=(SCHRIFT_NAME, 9, "bold"))
style.map("Treeview.Heading", background=[("active", self.col_sel)])
style.map("Treeview",
background=[("selected", self.col_sel)],
foreground=[("selected", fg)])
# Der Rahmen um die Liste - ttk zeichnet ihn sonst im Systemgrau.
style.configure("Treeview.field", bordercolor=self.col_line)
style.configure("TSeparator", background=self.col_line)
# Reiter (Campingrechnung / Montage): ohne eigene Farben zeichnet clam den
# AKTIVEN Reiter dunkler als die ruhenden - man sieht dann nicht, wo man
# ist. Der offene Reiter bekommt darum den Hintergrund des Blattes und die
# Akzentfarbe, die ruhenden bleiben zurueckgenommen.
style.configure("TNotebook", background=bg, bordercolor=self.col_line,
tabmargins=(2, 4, 2, 0), borderwidth=0)
# Der OFFENE Reiter gehoert zur Kachel darunter und ist deshalb heller
# als die ruhenden. Vorher war es andersherum: die ruhenden lagen auf der
# helleren Flaeche und sahen aus, als waeren sie ausgewaehlt.
# ⚠️ clam zeichnet die Reiterflaeche aus lightcolor/darkcolor, nicht aus
# background allein - werden die nicht mitgesetzt, bleibt der ruhende
# Reiter hell und sieht aus wie der ausgewaehlte.
style.configure("TNotebook.Tab", background=bg, lightcolor=bg, darkcolor=bg,
foreground=self.col_muted, bordercolor=self.col_line,
# Schmal genug, dass zwoelf Monatsreiter nebeneinander
# passen: bei (20, 9) waren "September" und "Dezember"
# im Steuerrechnungstool abgeschnitten.
padding=(9, 7), font=(SCHRIFT_NAME, 10), borderwidth=1)
style.map("TNotebook.Tab",
background=[("selected", self.col_card),
("active", mischen(accent, bg, 0.12))],
lightcolor=[("selected", self.col_card),
("active", mischen(accent, bg, 0.12))],
darkcolor=[("selected", self.col_card),
("active", mischen(accent, bg, 0.12))],
bordercolor=[("selected", accent)],
foreground=[("selected", accent), ("active", fg)],
font=[("selected", (SCHRIFT_NAME, 10, "bold"))])
# Kein expand beim ausgewaehlten Reiter: er wuchs dadurch um ein paar
# Pixel, die anderen rueckten nach - beim Umschalten sprang die ganze
# Leiste. Erkannt wird der offene Reiter an Farbe und Fettschrift.
# Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults
self.option_add("*background", bg)
self.option_add("*foreground", fg)
def feld_optionen(self, mehrzeilig=False, einfuegemarke=True):
"""Farben und Rahmen fuer die klassischen Eingabe-Widgets (Text, Listbox).
ttk-Felder holen sich das aus dem Stil; tk.Text und tk.Listbox koennen
das nicht - sie muessen die Werte einzeln bekommen. Damit trotzdem JEDES
Feld gleich aussieht, kommen sie aus dieser einen Stelle.
"""
opts = {
"bg": self.col_field,
"fg": self.col_fg,
"relief": "solid",
"borderwidth": 1,
"highlightthickness": 0,
}
# Eine Auswahlliste hat keine Einfuegemarke - sie kennt die Option nicht
# und bricht daran ab.
if einfuegemarke:
opts["insertbackground"] = self.col_fg
if mehrzeilig:
opts["padx"] = 4
opts["pady"] = 3
return opts
def _style_dialog(self, d):
"""Toplevel-Dialog an das Theme angleichen.
Im Pruefstand wird derselbe Dialog gebaut und bedient, aber nicht
gezeigt - und sein grab_set faellt weg. Sonst haelt jeder Dialog Maus
und Tastatur fest, waehrend nebenan jemand schreibt.
"""
d.configure(bg=self.col_bg)
# Mittig ueber dem Programmfenster - und damit automatisch auf dem
# Bildschirm, auf dem das Programm gerade steht.
d.after(1, lambda: mittig(d, self))
if testlauf():
# Ohne grab_set: ein Dialog, der Maus und Tastatur festhaelt,
# blockiert den Menschen, der nebenher arbeitet.
d.grab_set = lambda *a, **k: None
try:
d.attributes("-toolwindow", True) # nicht in die Taskleiste
except tk.TclError:
pass
# erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
d.after(20, lambda: self._dark_titlebar(d))
def _dark_titlebar(self, win):
"""Die Titelleiste eines Fensters an die Darstellung anpassen.
Frueher machte diese Methode die Leiste nur DUNKEL und tat sonst
nichts. Solange das Programm der Windows-Einstellung folgte, fiel das
nicht auf - seit man hell und dunkel selbst waehlen kann, sehr wohl:
wer von dunkel auf hell stellte, behielt eine schwarze Titelleiste ueber
einem hellen Fenster. Sie zeigt jetzt in beide Richtungen und ist nur
noch eine Weiterleitung; die Arbeit macht titelleiste_faerben().
"""
titelleiste_faerben(win, bool(getattr(self, "_dark_mode", False)))
# ------------------------------------------------ Hell / dunkel umstellen
# Der Hinweistext gehoert dem einzelnen Programm - er sagt etwas ueber
# DIESES Programm. Hier steht nur, was dasteht, wenn ihn niemand setzt.
DARSTELLUNG_HINWEIS = "Gilt nur für das Programmfenster."
def _cfg_speichern(self):
"""Die Einstellungen wegschreiben - falls das noetig ist.
Im Rechnungstool ist die Konfiguration ein gewoehnliches Woerterbuch
und muss ausdruecklich gespeichert werden. Im Steuerrechnungstool ist
sie eine Ablage, die jeden Schreibzugriff sofort in die
Journaltabelle durchreicht - dort ist hier nichts zu tun.
"""
return
def variablen_sichern(self):
"""Alle Tk-Variablen des Fensters als {Name: Wert}.
WARUM ALLE UND NICHT EINE AUSWAHL
---------------------------------
Die _build-Methoden legen ihre Variablen selbst an
(`self.k_name = tk.StringVar()`). Nach dem Neubau stehen also neue,
leere da. Beim ersten Versuch waren Kundenname und Rechnungsnummer
weg, waehrend die Mengen blieben - die hatte ich einzeln gesichert.
Durchgegangen wird deshalb, was das Objekt an Tk-Variablen traegt.
Wer spaeter ein Feld hinzufuegt, muss nichts eintragen; 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
return gesichert
def variablen_zuruecksetzen(self, gesichert):
"""Die gesicherten Werte in die neu gebauten Variablen zurueckgeben."""
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
def kinder_loeschen(self):
"""Die Oberflaeche abraeumen - aber keine fremden Fenster.
Ein tk.Toplevel IST ein Kind des Hauptfensters. Der offene
Darstellungsdialog wurde hier bisher mitgeloescht, und die Zeile
danach fasste eine Leiche an ("invalid command name .!toplevel").
Gefunden am 07.09.2026 von der Pruefkette, mit dem echten Bedienweg
gemessen: Klick auf "Dunkel" -> Farbe wechselt, Fenster ist weg,
Traceback im Log.
Der Pruefstand hatte es nicht gefunden, weil er cfg["darstellung"] von
Hand setzte und _oberflaeche_neu() direkt rief - also alles ausser dem
Weg, den ein Mensch geht. Er klickt jetzt.
"""
for kind in self.winfo_children():
if not isinstance(kind, tk.Toplevel):
kind.destroy()
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=(SCHRIFT_NAME, 11, "bold"),
foreground=self.col_accent).pack(anchor="w")
ttk.Label(rahmen, justify="left", wraplength=340,
foreground=self.col_muted,
text=self.DARSTELLUNG_HINWEIS).pack(anchor="w", pady=(6, 12))
gewaehlt = tk.StringVar(value=modus(self.cfg))
def uebernehmen():
neu = gewaehlt.get()
if neu == modus(self.cfg):
return
self.cfg["darstellung"] = neu
self._cfg_speichern()
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 MODI:
ttk.Radiobutton(rahmen, text=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")
mittig(d, self)
return d
class MeldungMixin:
"""Eigene Meldungs- und Ja/Nein-Fenster im Theme des Programms.
Warum nicht `messagebox`: das sind Fenster von Windows selbst. Sie sind
immer hell, egal was das Programm einstellt - mitten in einer dunklen
Oberflaeche blendet das und sieht nach Fremdkoerper aus. Dieselbe Erfahrung
steckt schon in der eigenen Menueleiste weiter oben.
⚠️ Im PRUEFLAUF geht beides weiter ueber `messagebox`. Die Pruefstaende
haengen ihre Antworten dort ein ("auf diese Frage antworte Ja") und lesen
dort mit, was das Programm gemeldet hat. Wuerde hier ein eigenes Fenster
aufgehen, warteten sie auf einen Klick, den niemand macht. Geprueft wird
damit derselbe Weg - nur die Anzeige unterscheidet sich.
"""
# Die drei Sorten Meldung. `parent=`, `icon=` und `default=` kommen aus den
# frueheren messagebox-Aufrufen mit - sie werden geschluckt, damit an den
# Aufrufstellen nichts umgeschrieben werden muss.
def melden_info(self, titel, text="", **_kw):
return self.melden(titel, text, "info")
def melden_warnung(self, titel, text="", **_kw):
return self.melden(titel, text, "warnung")
def melden_fehler(self, titel, text="", **_kw):
return self.melden(titel, text, "fehler")
def melden(self, titel, text, art="warnung"):
"""Eine Meldung mit einem OK-Knopf."""
if testlauf():
{"info": messagebox.showinfo,
"warnung": messagebox.showwarning,
"fehler": messagebox.showerror}.get(art, messagebox.showwarning)(titel, text)
return None
return self._meldungsfenster(titel, text, art, knoepfe=None)
def frage(self, titel, text="", ja="Ja", nein="Nein", vorgabe=True, **_kw):
"""Ja/Nein. Gibt True oder False zurueck - wie askyesno."""
if testlauf():
return bool(messagebox.askyesno(titel, text))
return bool(self._meldungsfenster(titel, text, "frage",
knoepfe=(ja, nein), vorgabe=vorgabe))
def _meldungsfenster(self, titel, text, art, knoepfe=None, vorgabe=True):
farbe = {"fehler": self.col_gefahr, "warnung": self.col_gefahr,
"frage": self.col_accent}.get(art, self.col_accent)
d = tk.Toplevel(self)
d.title(titel)
d.transient(self)
d.resizable(False, False)
self._style_dialog(d)
antwort = {"wert": False}
rahmen = ttk.Frame(d, padding=20)
rahmen.pack(fill="both", expand=True)
tk.Label(rahmen, text=titel, bg=self.col_bg, fg=farbe, justify="left",
font=(SCHRIFT_NAME, 12, "bold")).pack(anchor="w")
tk.Label(rahmen, text=text, bg=self.col_bg, fg=self.col_fg, justify="left",
wraplength=460, font=(SCHRIFT_NAME, 10)).pack(anchor="w", pady=(10, 0))
leiste = ttk.Frame(rahmen)
leiste.pack(fill="x", pady=(20, 0))
def schliessen(wert):
antwort["wert"] = wert
d.destroy()
if knoepfe:
ja, nein = knoepfe
ttk.Button(leiste, text=ja, style="Haupt.TButton",
command=lambda: schliessen(True)).pack(side="right")
ttk.Button(leiste, text=nein,
command=lambda: schliessen(False)).pack(side="right", padx=(0, 8))
d.bind("<Return>", lambda _e: schliessen(vorgabe))
else:
ttk.Button(leiste, text="OK", style="Haupt.TButton",
command=lambda: schliessen(True)).pack(side="right")
d.bind("<Return>", lambda _e: schliessen(True))
d.bind("<Escape>", lambda _e: schliessen(False))
d.protocol("WM_DELETE_WINDOW", lambda: schliessen(False))
mittig(d, self)
# Nach vorn holen und den Tastaturfokus nehmen. Ohne das kann der Dialog
# beim Start hinter dem Hauptfenster liegen - der Nutzer sieht nur ein
# Fenster, das nicht reagiert, und haelt es fuer haengen geblieben.
d.lift()
d.attributes("-topmost", True)
d.after(100, lambda: d.attributes("-topmost", False))
d.focus_force()
d.grab_set()
d.focus_set()
self.wait_window(d)
return antwort["wert"]