Darstellung: das Programm hatte ueberhaupt kein Theme

Sein Befund
  "und beim steuertool fehlt das theam in genze oder wird nicht durch die
   automatik gesetzt"
  "nur die einstellunden hilfe leiste die ist noch hell und der icon und
   titel leiste auch"

  Er hatte recht, und zwar wortwoertlich: dieses Programm lief im
  Tk-Standard, mit dreimal fest eingetragenem foreground="black". Es gab hier
  gar keine theme.py.

Gebaut
  theme.py aus dem Rechnungstool uebernommen und dabei von config.py geloest
  - eine Datei, die in BEIDEN Programmen liegt, darf nichts aus einem der
  beiden importieren. Beim ersten Uebernehmen flog sie genau daran beim
  Import auseinander.

  Die Einstellungen liegen hier in der Journal-Tabelle, nicht in einer
  config.json. _einstellungen() ist die Bruecke: etwas mit .get() und []=,
  wie hinweis.py und theme.py es erwarten.

  Menueleiste aus menueleiste.py statt tk.Menu - ein natives Menue laesst
  sich unter Windows nicht einfaerben, das war der letzte helle Rest.
  Treeview-Stil in theme.py ergaenzt (ttk zeichnet Listen nicht ueber den
  Grundstil), Reiter schmaler, damit alle zwoelf Monatsnamen lesbar bleiben.
  Die Summenleisten in gui_monat.py holen ihre Farben jetzt vom Fenster statt
  sie fest zu tragen - sie waren ein weisser Block unten im dunklen Bild.

  Logo und Fenstersymbol werden fuer die dunkle Darstellung aufgehellt.

⚠️ Altfehler behoben: die Meldung des Rechnungstools stand nie
  pruef_wache lief mal gruen und mal rot - je nachdem, wann er hinsah. Das
  war kein Wackler im Pruefstand, sondern sein Symptom: meldet das
  Rechnungstool eine neue Rechnung, laeuft sofort das Einlesen an, und dessen
  Ergebnis landete in derselben Zeile. Der Benutzer sah NIE, dass etwas
  hereingekommen war. melde() kennt jetzt einen Vorrang in Sekunden; die
  Meldung des Rechnungstools bekommt sechs. Dreimal hintereinander stabil
  gruen.

  Ein wackelnder Pruefstand ist keine Nebensache, die man wegdrueckt.

AUFGABE_STEUERSAETZE_DYNAMISCH.md
  Seine naechste Ansage notiert: die festen Saetze sollen raus, dynamisch per
  Tabelle wie im Rechnungstool. Mit dem, worauf dabei zu achten ist - ein
  Satz gilt ab einem DATUM, und die Saetze bereits erfasster Buchungen
  bleiben stehen (§ 147 AO).

Fassung 1.9.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:38 +02:00
parent e4619259cb
commit 9d7aa1eec7
12 changed files with 1309 additions and 60 deletions

View file

@ -0,0 +1,68 @@
# Steuersätze dynamisch statt fest
**Stand 07.09.2026 — notiert, noch nicht gebaut.**
## Seine Ansage
> *„die festen steuern müssen aus dem steuertool eh raus das muss wie beim
> rechnungstool dynamisch per tabelle sein die man einfach eintragen kann"*
## Wie es heute ist
Es gibt **einen** Satz für alles:
| Stelle | heute |
|---|---|
| `modell.py` | `STANDARD_SATZ` als Konstante |
| `app.py:101` | `self.satz = float(self.journal.hole("satz", str(STANDARD_SATZ)))` |
| Menü | *Daten → Steuersatz ändern…* — ein einzelnes Eingabefeld |
| `db.py` | Spalte `satz REAL NOT NULL DEFAULT 5` je Buchung |
| `db.py:243` | `scanne(..., standard_satz=5.0)` — greift, wenn im PDF keiner steht |
Immerhin: der Satz steht **je Buchung** in der Datenbank, nicht nur global.
Historische Buchungen behalten also ihren Satz, wenn er einmal geändert wird.
Das ist die halbe Miete und darf beim Umbau nicht verlorengehen.
Das Rechnungstool kann es schon richtig: dort gibt es eine **Tabelle** von
Sätzen (Einstellungen → Steuersätze), und jede Leistung im Katalog trägt ihren
eigenen.
## Was gebraucht wird
Eine Tabelle im Steuerrechnungstool, in die man Sätze einträgt — wie im
Rechnungstool. Kein fester Wert mehr im Quelltext.
## Worauf beim Bauen zu achten ist
⚠️ **Ein Steuersatz gilt ab einem Datum, nicht „ab jetzt".** Ändert die Gemeinde
den Satz zum 1.1., dürfen die Buchungen des Vorjahres nicht mitwandern - sonst
stimmt die Meldung ans Amt für abgeschlossene Zeiträume plötzlich nicht mehr.
Die Tabelle braucht deshalb eine Spalte *gültig ab*, und die Zuordnung erfolgt
über das **Rechnungsdatum**, nicht über das heutige.
⚠️ **Die Sätze der bereits erfassten Buchungen bleiben stehen.** Sie stehen in
`buchungen.satz` und sind der Beleg dafür, womit damals gerechnet wurde
(§ 147 AO). Eine Änderung an der Tabelle darf sie nie rückwirkend überschreiben.
Wer alte Buchungen umrechnen will, muss das ausdrücklich anstoßen - und dann
gehört es protokolliert.
⚠️ **Was im PDF steht, gewinnt.** Der Satz aus der Tabelle ist nur die Annahme
für den Fall, dass die Rechnung selbst keinen nennt (`scanne(...,
standard_satz=...)`). Umgekehrt wäre es falsch: das Steuertool soll abbilden,
was abgerechnet wurde, nicht was hätte abgerechnet werden sollen.
Dazu passend die schon geltende Regel aus
[[reference_beherbergungssteuer_monat_und_kein_flag]]: kein vom Menschen
gesetzter Schalter in einer Rechnung, die er nicht nachprüfen kann.
## Reihenfolge, wenn es losgeht
1. Tabelle `steuersaetze` (gültig ab, Satz, Bemerkung) im Journal anlegen -
mit Migration wie bei den anderen Spalten.
2. Die Auswahl beim Einlesen über das Rechnungsdatum, nicht über „heute".
3. Den Dialog *Steuersatz ändern…* durch eine Tabelle ersetzen, wie im
Rechnungstool unter Einstellungen → Steuersätze.
4. `STANDARD_SATZ` bleibt als letzter Rückfall für eine leere Tabelle - und
nur dafür.
5. Prüfstand: eine Buchung von 2025 und eine von 2026, zwei verschiedene
Sätze, und die Änderung der Tabelle darf die alte Buchung nicht anfassen.

View file

@ -5,7 +5,7 @@ a = Analysis(
['app.py'],
pathex=[],
binaries=[],
datas=[('assets/programm.ico', 'assets'), ('assets/programm_logo.png', 'assets'),],
datas=[('assets/programm.ico', 'assets'), ('assets/programm_dunkel.ico', 'assets'), ('assets/programm_logo.png', 'assets'),],
hiddenimports=[],
hookspath=[],
hooksconfig={},

232
app.py
View file

@ -26,6 +26,8 @@ from tkinter import ttk, filedialog, messagebox
# Nur beim echten Start: Pruefstaende importieren app als Modul und sollen
# nicht an der Sperre des laufenden Programms scheitern.
import hinweis
import menueleiste
import theme
import wasserzeichen
from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen,
fenster_nach_vorn, laufende_pid_lesen)
@ -77,7 +79,7 @@ logging.basicConfig(
log = logging.getLogger("bst.app")
class App(tk.Tk):
class App(theme.ThemeMixin, tk.Tk):
def __init__(self):
super().__init__()
self.title("Steuerrechnungstool")
@ -86,6 +88,12 @@ class App(tk.Tk):
self.minsize(820, 520)
self.journal = db.Journal(JOURNAL)
# Die Einstellungen des Programms liegen in der Journal-Tabelle, nicht
# in einer config.json wie beim Rechnungstool. theme.py erwartet aber
# etwas mit .get() und []= - diese Ablage ist die Bruecke. So laeuft
# dieselbe theme.py in beiden Programmen, ohne Sonderfall.
self.cfg = self._einstellungen()
self._apply_theme()
self.buch = None # gemeinsames Nummernbuch, siehe gemeinsam.py
# Der eigene kleine Server. Klopft das Rechnungstool an, wird sofort
# nachgesehen - ohne auf den naechsten Durchgang der Wache zu warten.
@ -121,9 +129,13 @@ class App(tk.Tk):
self.after(50, self._hinweis_wenn_sichtbar)
# ------------------------------------------------- Erprobungshinweis
def _hinweis_ablage(self):
"""hinweis.py erwartet etwas mit .get() und []= - hier ist das die
Einstellungstabelle des Journals.
def _einstellungen(self):
"""Die Einstellungstabelle des Journals als Wörterbuch.
hinweis.py und theme.py erwarten beide etwas mit .get() und []= - im
Rechnungstool ist das die config.json, hier die Tabelle im Journal.
Diese Ablage ist die Bruecke, damit beide Programme dieselben Dateien
benutzen koennen.
"""
journal = self.journal
@ -136,13 +148,125 @@ class App(tk.Tk):
return Ablage()
# hinweis.py ruft die Ablage unter dem alten Namen - beides zeigt auf
# dieselbe Tabelle.
_hinweis_ablage = _einstellungen
def _wieder(self, ms, was):
"""Einen sich selbst planenden Timer neu setzen - nur solange es das
Fenster noch gibt.
Ohne das melden die Dauertimer beim Beenden `invalid command name
..._wache`: 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.
Gleiche Loesung wie im Rechnungstool.
"""
try:
if self.winfo_exists():
self.after(ms, was)
except tk.TclError:
pass # Fenster ist weg - dann gibt es nichts mehr zu tun
# ------------------------------------------------------- Darstellung
def _oberflaeche_neu(self):
"""Die Oberflaeche neu zeichnen - fuer den Wechsel hell/dunkel.
Neu bauen statt umfaerben: die Farben stecken in den Widgets, und wer
sie einzeln nachzieht, vergisst beim naechsten neuen Widget eine
Stelle - dann leuchtet ein heller Fleck im dunklen Fenster, den
niemand mehr findet. Neu bauen kann nichts vergessen.
Die Werte haengen an Tk-Variablen (Jahr, Suchfeld) und am Journal -
beides ueberlebt. Gesichert werden sie trotzdem, denn die
_build-Methoden legen ihre Variablen selbst an: nach dem Neubau
stuenden sonst neue, leere da. Genau das ist im Rechnungstool beim
ersten Versuch passiert.
"""
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
for kind in self.winfo_children():
kind.destroy()
self._apply_theme()
self._menue()
self._kopfleiste()
self._blaetter()
self._statusleiste()
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
self.aktualisieren()
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 für das Programmfenster. Das Menü bleibt in der "
"Farbe von Windows – ein natives Menü lässt sich dort "
"nicht einfärben.").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
self._oberflaeche_neu()
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 _fenstersymbol(self):
"""Das Programmsymbol auf das Fenster legen."""
return wasserzeichen.symbol_setzen(self, melden=log.info)
return wasserzeichen.symbol_setzen(self, melden=log.info,
dunkel=getattr(self, "_dark_mode", False))
def _wasserzeichen_setzen(self, groesse=220):
"""Das Programmlogo blass in die untere rechte Ecke."""
bild = wasserzeichen.hole(groesse, 0.06, master=self, melden=log.info)
bild = wasserzeichen.hole(groesse, 0.06, master=self, melden=log.info,
dunkel=getattr(self, "_dark_mode", False))
if bild is None:
return
# Die Referenz MUSS bleiben - sonst raeumt Python das Bild weg und Tk
@ -200,34 +324,39 @@ class App(tk.Tk):
# ------------------------------------------------------------ Aufbau ----
def _menue(self):
leiste = tk.Menu(self)
datei = tk.Menu(leiste, tearoff=0)
datei.add_command(label="Rechnungs-Ordner wählen…", command=self.ordner_waehlen)
datei.add_separator()
datei.add_command(label="Monatsbericht fürs Amt (PDF)…", command=self.bericht_monat)
datei.add_command(label="Jahresbericht fürs Amt (PDF)…", command=self.bericht_jahr)
datei.add_separator()
datei.add_command(label="Excel-Tabelle einlesen…", command=self.altbestand_importieren)
datei.add_separator()
datei.add_command(label="Beenden", command=self._beenden)
leiste.add_cascade(label="Datei", menu=datei)
"""Eigene Menueleiste statt tk.Menu.
daten = tk.Menu(leiste, tearoff=0)
daten.add_command(label="Rechnungen einlesen (F5)", command=self.einlesen)
daten.add_command(label="Alle Rechnungen neu einlesen", command=lambda: self.einlesen(voll=True))
daten.add_separator()
daten.add_separator()
daten.add_command(label="Steuersatz ändern…", command=self.satz_aendern)
daten.add_command(label="Firmendaten des Rechnungstools wählen…",
command=self.firma_waehlen)
daten.add_command(label="Gemeinsamer Ordner (Nummernbuch)…",
command=self.dlg_gemeinsam)
leiste.add_cascade(label="Daten", menu=daten)
hilfe = tk.Menu(leiste, tearoff=0)
hilfe.add_command(label="Info", command=self._info)
leiste.add_cascade(label="Hilfe", menu=hilfe)
self.config(menu=leiste)
`tk.Menu` ist unter Windows kein Tk-Widget, sondern ein Menue des
Betriebssystems: es nimmt Farben entgegen und zeichnet trotzdem im
Systemgrau. Sein Befund am 07.09.2026: *"datei und daten hilfe ist
immer noch hell"* - und er hatte recht, das war der letzte helle Rest
im dunklen Fenster. Die Leiste kommt jetzt aus menueleiste.py, der
gleichen Datei, die auch das Rechnungstool benutzt.
"""
self._menus = {
"Datei": [
("Rechnungs-Ordner wählen…", self.ordner_waehlen),
("-", None),
("Monatsbericht fürs Amt (PDF)…", self.bericht_monat),
("Jahresbericht fürs Amt (PDF)…", self.bericht_jahr),
("-", None),
("Excel-Tabelle einlesen…", self.altbestand_importieren),
("-", None),
("Beenden", self._beenden),
],
"Daten": [
("Rechnungen einlesen (F5)", self.einlesen),
("Alle Rechnungen neu einlesen", lambda: self.einlesen(voll=True)),
("-", None),
("Steuersatz ändern…", self.satz_aendern),
("Firmendaten des Rechnungstools wählen…", self.firma_waehlen),
("Gemeinsamer Ordner (Nummernbuch)…", self.dlg_gemeinsam),
("-", None),
("Darstellung…", self.dlg_darstellung),
],
"Hilfe": [("Info", self._info)],
}
self.menue = menueleiste.Menueleiste(self, self._menus, farben=self)
def _info(self):
"""Erprobungshinweis und Signatur - dauerhaft nachlesbar.
@ -262,7 +391,8 @@ class App(tk.Tk):
# Das Programmlogo rechts in der Kopfzeile - das Erkennungszeichen.
# Es steht rechts, damit es die Bedienelemente nicht nach hinten
# schiebt, wenn es einmal fehlt.
self._kopf_bild = wasserzeichen.kopf(28, master=self, melden=log.info)
self._kopf_bild = wasserzeichen.kopf(28, master=self, melden=log.info,
dunkel=getattr(self, "_dark_mode", False))
if self._kopf_bild is not None:
ttk.Label(leiste, image=self._kopf_bild).pack(side="right")
@ -307,9 +437,9 @@ class App(tk.Tk):
self.fuss = ttk.Frame(rahmen)
self.fuss.pack(fill="x")
self.status = ttk.Label(self.fuss, text="", foreground="black")
self.status = ttk.Label(self.fuss, text="", foreground=self.col_fg)
self.status.pack(side="left")
self.ordner_anzeige = ttk.Label(self.fuss, text="", foreground="black")
self.ordner_anzeige = ttk.Label(self.fuss, text="", foreground=self.col_fg)
self.ordner_anzeige.pack(side="right")
# Doppelt vergebene Rechnungsnummern. Die Leiste wird erst eingehaengt,
@ -424,7 +554,9 @@ class App(tk.Tk):
text = texte[-1] if len(texte) == 1 else f"{len(texte)} Meldungen, zuletzt: {texte[-1]}"
log.info("Rechnungstool meldet: %s", text)
self.einlesen(still=True)
self.melde(f"Rechnungstool meldet: {text}")
# Mit Vorrang: das Einlesen laeuft gleich darauf an und wuerde die
# Meldung sonst sofort ueberschreiben.
self.melde(f"Rechnungstool meldet: {text}", vorrang=6)
return True
def _letzte_nachricht(self):
@ -440,14 +572,32 @@ class App(tk.Tk):
except Exception as e: # noqa: BLE001 - die Wache darf nie sterben
log.warning("Wache: %s", e)
finally:
self.after(self.WACHE_SEKUNDEN * 1000, self._wache)
self._wieder(self.WACHE_SEKUNDEN * 1000, self._wache)
def suche_leeren(self):
self.suche.set("")
self.suchfeld.focus_set()
def melde(self, text: str):
def melde(self, text: str, vorrang: int = 0):
"""Eine Zeile in die Statusleiste schreiben.
`vorrang` in Sekunden: so lange darf eine gewoehnliche Meldung diese
hier NICHT ueberschreiben.
Warum es das braucht (07.09.2026): Meldet das Rechnungstool eine neue
Rechnung, laeuft sofort das Einlesen an - und dessen Ergebnis
("Nummernbuch: 1 Nummern eingetragen") landete in derselben Zeile.
Die Meldung stand also nur Sekundenbruchteile da; der Benutzer sah nie,
dass etwas hereingekommen war. Aufgefallen ist es an einem
Pruefstand, der mal gruen und mal rot war - je nachdem, wann er hinsah.
Das war kein Wackler im Pruefstand, sondern genau dieser Fehler.
"""
import time as _zeit
jetzt = _zeit.monotonic()
if vorrang <= 0 and jetzt < getattr(self, "_status_sperre", 0):
return # eine wichtigere Meldung steht noch
self.status.config(text=text)
self._status_sperre = jetzt + vorrang if vorrang > 0 else 0
log.info(text)
# ---- Gemeinsames Nummernbuch -------------------------------------------
@ -500,7 +650,7 @@ class App(tk.Tk):
except Exception as e: # noqa: BLE001 - darf das Fenster nie umwerfen
log.warning("Push: %s", e)
finally:
self.after(250, self._push_abholen)
self._wieder(250, self._push_abholen)
def _buch_oeffnen(self):
ordner = self.journal.hole("gemeinsam_ordner", "")
@ -721,7 +871,7 @@ class App(tk.Tk):
eingabe.grid(row=0, column=1)
ttk.Label(rahmen, text="Gilt für neu eingelesene und neue Buchungen.\n"
"Bereits erfasste Zeilen behalten ihren Satz.",
foreground="black").grid(row=1, column=0, columnspan=2, pady=(8, 10))
foreground=self.col_fg).grid(row=1, column=0, columnspan=2, pady=(8, 10))
def speichern():
from modell import zahl_aus_text

BIN
assets/programm_dunkel.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View file

@ -82,6 +82,15 @@ def main():
for ordner in ("build", "dist", "setup", "release"):
shutil.rmtree(os.path.join(HIER, ordner), ignore_errors=True)
# Die aufgehellte Fassung des Symbols fuer die dunkle Darstellung. Sie
# wird HIER erzeugt und nicht erst beim ersten dunklen Start: sonst muesste
# das Programm in seinen eigenen Ordner schreiben duerfen - unter
# "Programme" darf es das nicht.
sys.path.insert(0, HIER)
import wasserzeichen
dunkles = wasserzeichen.symbol_datei_dunkel()
print(f"--- Symbol dunkel: {dunkles or 'FEHLT'}")
lauf([sys.executable, "-m", "PyInstaller", "--noconfirm", f"{stamm}.spec"],
f"{stamm} {version.VERSION}")
lauf([sys.executable, "-m", "PyInstaller", "--noconfirm", "Updater.spec"],

View file

@ -23,12 +23,37 @@ SPALTEN = [
QUELLE_TEXT = {"pdf": "PDF", "xlsx": "Excel", "manuell": "manuell"}
# Rueckfallfarben. Sie gelten nur, wenn das Fenster (noch) keine Farben
# traegt - etwa in einem Pruefstand, der ein Widget einzeln baut. Im laufenden
# Programm kommen sie aus theme.py ueber das Hauptfenster; siehe farben().
BG = "#eef1ee" # heller Streifen wie im PDF-Bericht
RAHMEN = "#c3ccc5"
GRUEN = "#1f5c3d" # Campinghof-Grün für den Steuerbetrag
WARNUNG = "#a00000" # doppelt vergebene Rechnungsnummer
def farben(widget):
"""Die Farben des Fensters, zu dem dieses Widget gehoert.
Diese Datei zeichnet mit tk-Widgets (nicht ttk), und die tragen ihre Farbe
fest. Ohne diese Abfrage blieb die ganze Summenleiste hell, waehrend das
uebrige Fenster dunkel war - ein weisser Block unten im Bild
(07.09.2026 aufgefallen).
getattr mit Rueckfall statt einer harten Abhaengigkeit: so laesst sich ein
Widget aus dieser Datei auch ohne die App bauen.
"""
top = widget.winfo_toplevel()
return {
"bg": getattr(top, "col_card", BG),
"rahmen": getattr(top, "col_line", RAHMEN),
"betont": getattr(top, "col_accent", GRUEN),
"text": getattr(top, "col_fg", "black"),
"leise": getattr(top, "col_muted", "#4a544c"),
"warnung": getattr(top, "col_gefahr", WARNUNG),
}
class SummenLeiste(tk.Frame):
"""Kennzahlen als abgesetzte Felder statt einer langen Textzeile."""
@ -47,21 +72,23 @@ class SummenLeiste(tk.Frame):
]
def __init__(self, master, mit_schnitt: bool = False):
super().__init__(master, bg=BG, highlightbackground=RAHMEN, highlightthickness=1)
f = farben(master)
super().__init__(master, bg=f["bg"], highlightbackground=f["rahmen"],
highlightthickness=1)
self.werte = {}
self.felder = self.FELDER_JAHR if mit_schnitt else self.FELDER
for spalte, (schluessel, titel, betont) in enumerate(self.felder):
if spalte:
tk.Frame(self, bg=RAHMEN, width=1).grid(row=0, column=spalte * 2 - 1,
sticky="ns", pady=8)
zelle = tk.Frame(self, bg=BG)
tk.Frame(self, bg=f["rahmen"], width=1).grid(
row=0, column=spalte * 2 - 1, sticky="ns", pady=8)
zelle = tk.Frame(self, bg=f["bg"])
zelle.grid(row=0, column=spalte * 2, sticky="ew", padx=16, pady=8)
tk.Label(zelle, text=titel.upper(), bg=BG, fg="#4a544c",
tk.Label(zelle, text=titel.upper(), bg=f["bg"], fg=f["leise"],
font=("Segoe UI", 7, "bold")).pack(anchor="w")
wert = tk.Label(zelle, text="–", bg=BG,
fg=GRUEN if betont else "black",
wert = tk.Label(zelle, text="–", bg=f["bg"],
fg=f["betont"] if betont else f["text"],
font=("Segoe UI", 13 if betont else 12,
"bold" if betont else "normal"))
wert.pack(anchor="w")
@ -104,11 +131,15 @@ class MonatsTab(ttk.Frame):
self.baum.grid(row=0, column=0, sticky="nsew")
rollbalken.grid(row=0, column=1, sticky="ns")
# Woher eine Buchung stammt, steht in der Spalte "Quelle" – nicht in der Schriftfarbe
# Woher eine Buchung stammt, steht in der Spalte "Quelle" – nicht in der
# Schriftfarbe. Die normale Schriftfarbe kommt aus dem Fenster: fest
# "black" wuerde in der dunklen Darstellung schwarz auf dunkelgrau
# ergeben - also unlesbar.
f = farben(self)
for tag in ("pdf", "xlsx", "manuell"):
self.baum.tag_configure(tag, foreground="black")
self.baum.tag_configure(tag, foreground=f["text"])
# Rot ist einzig für den Fehlerfall reserviert: die Nummer gibt es zweimal.
self.baum.tag_configure("doppelt", foreground=WARNUNG)
self.baum.tag_configure("doppelt", foreground=f["warnung"])
# Storno und die von ihm aufgehobene Rechnung stehen NICHT grau da
# (Ansage des Auftraggebers 04.09.2026: "ausgrauen wuerde ich es nicht,
# ich wuerde es nur als Storno taggen"). Der Hinweis steht in der Spalte

120
menueleiste.py Normal file
View file

@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
"""Eine Menueleiste, die sich einfaerben laesst.
WARUM NICHT DAS MENUE VON TK
----------------------------
`tk.Menu` ist unter Windows kein Tk-Widget, sondern ein Menue des
Betriebssystems. Es nimmt Farben zwar entgegen, zeichnet aber trotzdem im
Systemgrau - ein weisser Balken ueber einem dunklen Fenster. Sein Befund am
07.09.2026 zum Steuerrechnungstool: *"nur die einstellunden hilfe leiste die
ist noch hell"*.
Diese Leiste ist deshalb aus gewoehnlichen Labels gebaut: eine Zeile mit den
Titeln, und beim Klick geht ein randloses Toplevel als Klappliste auf. Beides
traegt die Farben des Programms.
WAS SIE KANN UND WAS NICHT
--------------------------
Sie kann, was diese beiden Programme brauchen: Titel, Eintraege, Trennlinien.
Sie kann bewusst NICHT: Untermenues, Tastenkuerzel neben dem Eintrag,
Ankreuzfelder. Wer das braucht, baut es hier ein - nicht daneben.
BENUTZUNG
self.menue = menueleiste.Menueleiste(self, farben=self, menues={
"Datei": [("Beenden", self._beenden)],
"Hilfe": [("Info", self._info)],
})
`farben` ist das Fenster mit den col_*-Feldern (das ThemeMixin). Ein "-" als
Beschriftung ergibt eine Trennlinie.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber.
"""
import tkinter as tk
from tkinter import ttk
class Menueleiste(tk.Frame):
def __init__(self, master, menues, farben=None, mit_trennlinie=True):
self.f = farben if farben is not None else master
super().__init__(master, bg=self.f.col_bg)
self.menues = menues
self._offen = None
self.pack(side="top", fill="x")
for titel, eintraege in menues.items():
lbl = tk.Label(self, text=titel, bg=self.f.col_bg, fg=self.f.col_fg,
padx=8, pady=3)
lbl.pack(side="left")
lbl.bind("<Enter>", lambda e, w=lbl: w.configure(bg=self.f.col_sel))
lbl.bind("<Leave>", lambda e, w=lbl: w.configure(bg=self.f.col_bg))
lbl.bind("<Button-1>",
lambda e, b=lbl, it=eintraege: self.aufklappen(b, it))
if mit_trennlinie:
ttk.Separator(master, orient="horizontal").pack(side="top", fill="x")
# ---------------------------------------------------------------- Klappen
def aufklappen(self, anker, eintraege):
if self._offen: # zweiter Klick schliesst wieder
self.zuklappen()
return
pop = tk.Toplevel(self)
pop.overrideredirect(True)
# Der Rahmen ist der Hintergrund des Toplevels, einen Pixel breit -
# sonst zeichnet Windows einen weissen Rand um die Liste.
pop.configure(bg=self.f.col_line)
innen = tk.Frame(pop, bg=self.f.col_field)
innen.pack(padx=1, pady=1)
for beschriftung, befehl in eintraege:
if beschriftung == "-":
tk.Frame(innen, bg=self.f.col_line, height=1).pack(fill="x", pady=3)
continue
eintrag = tk.Label(innen, text=beschriftung, bg=self.f.col_field,
fg=self.f.col_fg, anchor="w", padx=16, pady=5)
eintrag.pack(fill="x")
eintrag.bind("<Enter>",
lambda e, w=eintrag: w.configure(bg=self.f.col_sel))
eintrag.bind("<Leave>",
lambda e, w=eintrag: w.configure(bg=self.f.col_field))
eintrag.bind("<Button-1>", lambda e, c=befehl: self._ausfuehren(c))
pop.update_idletasks()
pop.geometry(f"+{anker.winfo_rootx()}"
f"+{anker.winfo_rooty() + anker.winfo_height()}")
self._offen = pop
pop.grab_set()
pop.bind("<Button-1>", self._klick_ausserhalb, add="+")
pop.bind("<Escape>", lambda e: self.zuklappen())
faerben = getattr(self.f, "_dark_titlebar", None)
if faerben:
faerben(pop)
def _klick_ausserhalb(self, ereignis):
pop = self._offen
if not pop:
return
if (ereignis.x_root < pop.winfo_rootx()
or ereignis.x_root > pop.winfo_rootx() + pop.winfo_width()
or ereignis.y_root < pop.winfo_rooty()
or ereignis.y_root > pop.winfo_rooty() + pop.winfo_height()):
self.zuklappen()
def _ausfuehren(self, befehl):
# Erst zuklappen, dann ausfuehren: sonst haelt der grab_set der Liste
# einen Dialog fest, der aus dem Eintrag heraus aufgeht.
self.zuklappen()
if befehl:
befehl()
def zuklappen(self):
if self._offen:
try:
self._offen.grab_release()
self._offen.destroy()
except tk.TclError:
pass
self._offen = None

183
pruef_darstellung.py Normal file
View file

@ -0,0 +1,183 @@
# -*- coding: utf-8 -*-
"""Pruefstand: hell, dunkel, automatisch - und nichts geht dabei verloren.
Sein Wortlaut vom 07.09.2026:
"waere nun noch gut das mann in den einstellungen einstellen kann hell
oder dunkel oder automatik das fehlt sollte eigendlich drin sein"
"und beim steuertool fehlt das theam in genze oder wird nicht durch die
automatik gesetzt"
Er hatte recht: dieses Programm hatte ueberhaupt kein Theme - es lief im
Tk-Standard, mit dreimal fest eingetragenem foreground="black".
GEPRUEFT WIRD DREIERLEI
1. Die Einstellung wirkt. "hell" ist hell, "dunkel" ist dunkel, "auto" folgt
Windows.
2. Beim Umschalten bleibt der Stand stehen - Jahr und Suchfeld.
3. NACH dem Wechsel traegt kein Widget mehr eine Farbe der anderen Palette.
Das ist die eigentliche Falle beim Umfaerben: ein einzelnes vergessenes
Label leuchtet hell im dunklen Fenster, und niemand findet die Stelle
wieder. Deshalb wird der ganze Widget-Baum abgegangen und mit den Farben
der jeweils ANDEREN Palette verglichen.
"""
import os
import shutil
import sys
import tempfile
import time
import tkinter as tk
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kod = sys.stdout.encoding or "utf-8"
print(zeile.encode(kod, "replace").decode(kod))
if not bedingung:
fehler.append(text)
os.chdir(QUELLE)
os.environ["CAMPINGHOF_TEST"] = "1"
from tkinter import messagebox # noqa: E402
messagebox.askyesno = lambda *a, **k: True
for _n in ("showinfo", "showwarning", "showerror"):
setattr(messagebox, _n, lambda *a, **k: None)
# Auf einer KOPIE des Programms arbeiten: der Datenordner liegt aus der Quelle
# heraus neben app.py (ablage.py), also legt eine Kopie im Temp-Ordner auch ein
# eigenes Journal an. Das echte Journal des Betriebs wird nicht angefasst -
# hier wird eine Einstellung geschrieben, und die gehoert nicht in seine Daten.
tmp = tempfile.mkdtemp(prefix="darstellung_")
prog = os.path.join(tmp, "programm")
os.makedirs(prog)
for name in os.listdir(QUELLE):
if name.endswith(".py"):
shutil.copy(os.path.join(QUELLE, name), os.path.join(prog, name))
for name in ("assets",):
if os.path.isdir(os.path.join(QUELLE, name)):
shutil.copytree(os.path.join(QUELLE, name), os.path.join(prog, name))
sys.path.insert(0, prog)
os.chdir(prog)
import app as A # noqa: E402
import theme # noqa: E402
f = A.App()
def pumpen(sekunden=1.0):
ende = time.time() + sekunden
while time.time() < ende:
f.update()
time.sleep(0.02)
pumpen(2.0)
# ---- Ohne Einstellung: automatisch --------------------------------------
pruefe(theme.modus(f.cfg) == "auto",
f"ohne Eintrag gilt 'auto' ({theme.modus(f.cfg)!r})")
pruefe(f._dark_mode == theme.windows_dark_mode(),
"und folgt damit dem Windows-Theme")
# ---- Stand, der den Wechsel ueberleben muss ----------------------------
f.jahr.set(2025)
f.suche.set("Muster")
pumpen(0.5)
def farben(dunkel):
"""Die Palette der einen oder anderen Darstellung, als Menge."""
merker = f._dark_mode
f.cfg["darstellung"] = "dunkel" if dunkel else "hell"
f._apply_theme()
satz = {getattr(f, n).lower() for n in dir(f)
if n.startswith("col_") and isinstance(getattr(f, n), str)}
f._dark_mode = merker
return satz
HELL = farben(False)
DUNKEL = farben(True)
# Was in BEIDEN Paletten vorkommt, taugt nicht als Beweis.
NUR_HELL = HELL - DUNKEL
NUR_DUNKEL = DUNKEL - HELL
def fremdfarben(verboten):
"""Alle Widgets, die eine Farbe der falschen Palette tragen."""
treffer = []
def durchgehen(w):
for kind in w.winfo_children():
for eigenschaft in ("bg", "background", "fg", "foreground"):
try:
wert = str(kind.cget(eigenschaft)).lower()
except tk.TclError:
continue
if wert in verboten:
treffer.append((str(kind), eigenschaft, wert))
durchgehen(kind)
durchgehen(f)
return treffer
# ---- Die drei Modi ------------------------------------------------------
for wunsch, soll_dunkel in (("hell", False), ("dunkel", True)):
f.cfg["darstellung"] = wunsch
f._oberflaeche_neu()
pumpen(0.8)
pruefe(f._dark_mode == soll_dunkel,
f"'{wunsch}' zeichnet {'dunkel' if soll_dunkel else 'hell'} "
f"(Hintergrund {f.col_bg})")
# Nichts darf verlorengehen.
pruefe(f.jahr.get() == 2025,
f"nach dem Wechsel steht das gewaehlte Jahr noch da ({f.jahr.get()})")
pruefe(f.suche.get() == "Muster",
f"und der Suchbegriff ({f.suche.get()!r})")
# Kein Rest der anderen Palette.
reste = fremdfarben(NUR_DUNKEL if not soll_dunkel else NUR_HELL)
pruefe(not reste,
f"in '{wunsch}' ist keine Farbe der anderen Darstellung "
f"uebrig ({reste[:3]})")
# ---- Zurueck auf automatisch --------------------------------------------
f.cfg["darstellung"] = "auto"
f._oberflaeche_neu()
pumpen(0.8)
pruefe(f._dark_mode == theme.windows_dark_mode(),
"'auto' folgt wieder dem Windows-Theme")
# ---- Und die Einstellung ueberlebt den Neustart -------------------------
f.cfg["darstellung"] = "hell"
f.destroy()
f = A.App()
pumpen(2.0)
pruefe(f._dark_mode is False,
"die Einstellung steht beim naechsten Start wieder da")
try:
f.destroy()
except Exception: # noqa: BLE001
pass
shutil.rmtree(tmp, ignore_errors=True)
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Hell, dunkel und automatisch wirken - ohne Datenverlust")

View file

@ -186,6 +186,16 @@ pruefe(not journal._push_kasten.empty(),
"der Push des Rechnungstools ist im Fenster angekommen")
journal._push_abholen()
journal.update()
# Auf die Meldung WARTEN statt einmal hinzusehen. Das Einlesen laeuft
# nebenher und schreibt danach sein eigenes Ergebnis in dieselbe Zeile - wer
# genau dazwischen misst, sieht "Nummernbuch: 1 Nummern eingetragen" und
# meldet einen Fehler, den es nicht gibt. Genau das ist am 07.09.2026 einmal
# in einer Prueflauf-Runde passiert und beim naechsten Lauf nicht mehr.
for _ in range(60):
if "Rechnungstool meldet" in journal.status.cget("text"):
break
journal.update()
time.sleep(0.05)
buchungen = journal.journal.buchungen(jahr=2026)
pruefe(len(buchungen) == 1 and buchungen[0].nachname == "Meldung",
f"die Rechnung steht im Journal ({[(b.voll_nummer, b.nachname) for b in buchungen]})")

601
theme.py Normal file
View file

@ -0,0 +1,601 @@
# -*- 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:
import ctypes
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 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
import ctypes
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)
if testlauf():
# Wie das Hauptfenster: neben den Bildschirm, und ohne grab_set -
# ein Dialog, der Maus und Tastatur festhaelt, blockiert den
# Menschen, der nebenher schreibt.
d.geometry(f"+{d.winfo_screenwidth() + 20}+40")
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)))
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))
d.update_idletasks()
x = self.winfo_rootx() + max(0, (self.winfo_width() - d.winfo_reqwidth()) // 2)
y = self.winfo_rooty() + 140
d.geometry(f"+{max(0, x)}+{max(0, y)}")
# 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"]

View file

@ -10,11 +10,11 @@ und NUR hier; alles andere (Info-Dialog, Updater, Release) liest sie von hier.
Regel
-----
Die Nummer wird beim Ausliefern erhoeht und entspricht dem Release-Tag ohne das
"v": Tag `v1.8.0` -> VERSION "1.8.0". Wer das eine ohne das andere aendert,
"v": Tag `v1.9.0` -> VERSION "1.9.0". Wer das eine ohne das andere aendert,
baut dem Updater eine Falle.
"""
VERSION = "1.8.0"
VERSION = "1.9.0"
# Zu welchem Repository dieses Programm gehoert - der Updater sucht dort.
REPOSITORY = "TheMockTv/Steuerrechnungstool"

View file

@ -45,14 +45,51 @@ def pfad(name):
return ""
def symbol_setzen(fenster, melden=print):
def symbol_datei_dunkel():
"""Pfad zur aufgehellten ICO - wird angelegt, wenn sie fehlt.
Sein Befund am 07.09.2026: *"icon musst wenn es dunkel ist mit ändern auf
das hellere geht sonst mit unter"*. Die dunkle Haelfte des Zeichens liegt
genau auf der Farbe der dunklen Titelleiste.
`iconbitmap` braucht eine DATEI - ein Bild im Speicher genuegt nicht.
Deshalb wird sie einmal neben die normale gelegt. Gelingt das nicht (kein
Schreibrecht im Programmordner), landet sie im Temp-Ordner; und wenn auch
das nicht geht, bleibt es beim normalen Symbol.
"""
fertig = pfad("programm_dunkel.ico")
if fertig:
return fertig
quelle = pfad("programm_logo.png")
if not quelle:
return ""
ziel = os.path.join(os.path.dirname(quelle), "programm_dunkel.ico")
try:
from PIL import Image
bild = _fuer_dunkel(Image.open(quelle).convert("RGBA"))
groessen = [(g, g) for g in (16, 24, 32, 48, 64, 128, 256)]
try:
bild.save(ziel, sizes=groessen)
except OSError:
import tempfile
ziel = os.path.join(tempfile.gettempdir(), "programm_dunkel.ico")
bild.save(ziel, sizes=groessen)
return ziel
except Exception: # noqa: BLE001 - ohne helles Symbol laeuft es weiter
return ""
def symbol_setzen(fenster, melden=print, dunkel=False):
"""Das Programmsymbol auf ein Fenster legen. True bei Erfolg.
Die EXE traegt es ohnehin - das Fenster aber nicht von selbst. Ohne das
steht in der Titelleiste das nackte Tk-Symbol, obwohl die Datei ein eigenes
hat.
In der dunklen Darstellung wird die aufgehellte Fassung genommen, sonst
verschwindet die dunkle Haelfte des Zeichens in der dunklen Titelleiste.
"""
p = pfad("programm.ico")
p = (symbol_datei_dunkel() if dunkel else "") or pfad("programm.ico")
if not p:
melden("[symbol] assets/programm.ico nicht gefunden")
return False
@ -64,7 +101,45 @@ def symbol_setzen(fenster, melden=print):
return False
def hole(groesse=240, deckkraft=0.06, master=None, melden=print):
def _fuer_dunkel(bild):
"""Das Logo fuer die dunkle Darstellung aufhellen.
Sein Befund am 07.09.2026: *"wir muessen mal schauen das wir die svg fuer
das dunkle heller machen weil die geht nun unter"*.
Gemessen sind es zwei Farben: das Petrol #004860 (Helligkeit 53) und ein
fast schwarzes #181818 (Helligkeit 24). Der zweite Wert liegt genau auf der
Kachelfarbe #15171b - deshalb verschwindet die Haelfte des Zeichens.
Umgestellt wird nur die HELLIGKEIT, Farbton und Saettigung bleiben: sonst
waere es nicht mehr sein Logo, sondern ein anderes. Aus dunkel wird hell,
aus hell dunkel (L -> 1-L), und weil eine reine Umkehr das kraeftige Petrol
fast weiss werden liesse, wird sie auf 0.78 gedeckelt.
Die Kanten (halbdurchsichtige Punkte aus dem Skalieren) gehen denselben Weg
- deshalb Pixel fuer Pixel und nicht ein Farbtausch mit Toleranz.
"""
import colorsys
bild = bild.convert("RGBA")
punkte = list(bild.getdata())
neu = []
zwischen = {}
for r, g, b, a in punkte:
if a == 0:
neu.append((r, g, b, a))
continue
schluessel = (r, g, b)
if schluessel not in zwischen:
h, l, s2 = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
hell = min(1.0 - l, 0.78)
r2, g2, b2 = colorsys.hls_to_rgb(h, hell, s2)
zwischen[schluessel] = (int(r2 * 255), int(g2 * 255), int(b2 * 255))
neu.append(zwischen[schluessel] + (a,))
bild.putdata(neu)
return bild
def hole(groesse=240, deckkraft=0.06, master=None, melden=print, dunkel=False):
"""Das Logo blass als PhotoImage - oder None.
`deckkraft` 0..1, klein halten. Ein Wasserzeichen, das man LIEST, stoert
@ -77,7 +152,7 @@ def hole(groesse=240, deckkraft=0.06, master=None, melden=print):
fenster = master if master is not None else tk._default_root
if fenster is None:
return None
schluessel = (id(fenster), groesse, round(deckkraft, 3))
schluessel = (id(fenster), groesse, round(deckkraft, 3), bool(dunkel))
if schluessel in _lager:
return _lager[schluessel]
p = pfad("programm_logo.png")
@ -87,6 +162,8 @@ def hole(groesse=240, deckkraft=0.06, master=None, melden=print):
try:
from PIL import Image, ImageTk
bild = Image.open(p).convert("RGBA").resize((groesse, groesse), Image.LANCZOS)
if dunkel:
bild = _fuer_dunkel(bild)
bild.putalpha(bild.getchannel("A").point(lambda w: int(w * deckkraft)))
fertig = ImageTk.PhotoImage(bild, master=fenster)
except Exception as e: # noqa: BLE001 - ein Wasserzeichen darf nichts blockieren
@ -96,11 +173,11 @@ def hole(groesse=240, deckkraft=0.06, master=None, melden=print):
return fertig
def kopf(hoehe=28, master=None, melden=print):
def kopf(hoehe=28, master=None, melden=print, dunkel=False):
"""Das Logo fuer die Kopfzeile - volle Deckkraft, kleine Hoehe.
Getrennt von hole(): dort geht es um ein blasses Wasserzeichen, hier um ein
Erkennungszeichen. Zwei Zwecke, zwei Funktionen - sonst haette eine davon
einen Schalter, den man beim Lesen nachschlagen muss.
"""
return hole(hoehe, 1.0, master=master, melden=melden)
return hole(hoehe, 1.0, master=master, melden=melden, dunkel=dunkel)