Darstellung waehlbar: hell, dunkel oder wie Windows

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

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

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

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

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

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

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

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

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

Fassung 1.8.0.

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

View file

@ -108,21 +108,28 @@ kein Nebenbei — Menüs, Tabellen, Dialoge, Statusleiste. Vorher planen.
---
## ⚠️ Gefundener Altfehler — `pruef_wache` (Steuerrechnungstool) ⬜ OFFEN
## ✅ Erledigter Altfehler — `pruef_wache` (Steuerrechnungstool) **07.09.2026 behoben**
**Nicht von den Arbeiten am 06.09.2026 verursacht** — bei der Ausgangsmessung vor dem Umbau bereits
rot, nur mit anderem Text.
`pruef_wache.py` erwartet nach einem Push des Rechnungstools in der Statuszeile den Hinweis
**„Rechnungstool meldet"**. Tatsächlich steht dort das Ergebnis des anschließenden Einlesens
(`0 neu, 0 aktualisiert, 1 unverändert`; bei der Ausgangsmessung: `Nummernbuch: 1 Nummern
eingetragen`). Das Einlesen **überschreibt die Meldung**, bevor der Nutzer sie lesen kann.
`pruef_wache.py` erwartete nach einem Push des Rechnungstools in der Statuszeile den Hinweis
**„Rechnungstool meldet"**. Tatsächlich stand dort das Ergebnis des anschließenden Einlesens.
**Zu klären:** Ist das ein Fehler im Programm (die Meldung soll stehen bleiben, das Ergebnis
danebengehören) oder eine veraltete Erwartung im Prüfstand? Nicht nebenbei entscheiden — die
Statuszeile ist das Einzige, was dem Nutzer sagt, dass die andere Anwendung etwas gemeldet hat.
**Die Frage war:** Fehler im Programm oder veraltete Erwartung im Prüfstand?
Folgefehler derselben Stelle: „dann kommt keine Meldung dazu".
**Die Antwort: Fehler im Programm.** Sichtbar wurde das, weil der Prüfstand *mal grün und mal rot*
lief — je nachdem, wann er hinsah. Ein Prüfstand, der würfelt, ist nicht der eigentliche Befund,
sondern sein Symptom: die Meldung stand nur Sekundenbruchteile da, danach überschrieb sie das
Einlese-Ergebnis. Der Benutzer sah **nie**, dass eine Rechnung hereingekommen war.
**Behoben:** `melde()` kennt jetzt einen `vorrang` in Sekunden. Eine Meldung mit Vorrang lässt sich
in dieser Zeit nicht von einer gewöhnlichen überschreiben; die Meldung des Rechnungstools bekommt
sechs Sekunden. Dreimal hintereinander stabil grün.
⚠️ **Zum Merken:** Erst die Warteschleife im Prüfstand („warten, bis der Text dasteht" statt einmal
hinsehen) machte den Fehler *reproduzierbar* — und drehte ihn von zufällig grün auf konstant rot.
Ein wackelnder Prüfstand ist keine Nebensache, die man wegdrückt: er zeigt auf eine echte Stelle.
---

View file

@ -9,7 +9,7 @@ a = Analysis(
# nur fuer den Prozess geladen (theme.schrift_vorbereiten). Fehlt sie, faellt
# das Programm still auf Segoe UI zurueck - dann sieht es beim Kunden anders
# aus als hier.
datas=[('assets/programm.ico', 'assets'),
datas=[('assets/programm.ico', 'assets'), ('assets/programm_dunkel.ico', 'assets'),
('assets/programm_logo.png', 'assets'),('assets/logo.png', 'assets'),
('assets/fonts', 'assets/fonts'),
# Die Icons (Material Symbols vom Hub) - ohne sie zeigen die Knoepfe

View file

@ -9,7 +9,7 @@ a = Analysis(
# nur fuer den Prozess geladen (theme.schrift_vorbereiten). Fehlt sie, faellt
# das Programm still auf Segoe UI zurueck - dann sieht es beim Kunden anders
# aus als hier.
datas=[('assets/programm.ico', 'assets'),
datas=[('assets/programm.ico', 'assets'), ('assets/programm_dunkel.ico', 'assets'),
('assets/programm_logo.png', 'assets'),('assets/logo.png', 'assets'),
('assets/fonts', 'assets/fonts'),
# Die Icons (Material Symbols vom Hub) - ohne sie zeigen die Knoepfe

221
app.py
View file

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

BIN
assets/programm_dunkel.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 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"],

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

196
pruef_darstellung.py Normal file
View file

@ -0,0 +1,196 @@
# -*- 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"
GEPRUEFT WIRD DREIERLEI
1. Die Einstellung wirkt. "hell" ist hell, "dunkel" ist dunkel, "auto" folgt
Windows.
2. Beim Umschalten bleibt jede Eingabe stehen. Das ist der Punkt, an dem es
beim Bauen schiefging: die Mengen der Leistungszeilen hatte ich einzeln
gesichert, Kundenname und Rechnungsnummer nicht - die _build_-Methoden
legen ihre Variablen selbst an, beim Neubau standen also leere da. Wer
waehrend einer halb getippten Rechnung auf dunkel stellt, darf sie nicht
verlieren.
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 json
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)
import config # noqa: E402
# Auf einer Kopie arbeiten - die echte Konfiguration wird nicht angefasst.
tmp = tempfile.mkdtemp(prefix="darstellung_")
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["nummer_eingerichtet"] = True
roh.pop("darstellung", None)
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
import app as A # noqa: E402
import theme # noqa: E402
f = A.RechnungsApp()
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")
# ---- Eingaben, die den Wechsel ueberleben muessen ------------------------
f.k_vorname.set("Erika")
f.k_name.set("Mustermann")
f.k_strasse.set("Musterweg 12")
f.k_plzort.set("04416 Markkleeberg")
f.r_nummer.set("2026-099")
f.r_anreise.set("01.09.2026")
erste = sorted(f.anzahl_vars)[0]
f.anzahl_vars[erste].set("3")
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
config.save_config(f.cfg)
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.k_name.get() == "Mustermann",
f"nach dem Wechsel steht der Kundenname noch da ({f.k_name.get()!r})")
pruefe(f.r_nummer.get() == "2026-099",
f"und die Rechnungsnummer ({f.r_nummer.get()!r})")
pruefe(f.k_plzort.get() == "04416 Markkleeberg",
f"und die Anschrift ({f.k_plzort.get()!r})")
pruefe(f.r_anreise.get() == "01.09.2026",
f"und das Anreisedatum ({f.r_anreise.get()!r})")
pruefe(f.anzahl_vars.get(erste) is not None
and f.anzahl_vars[erste].get() == "3",
"und die eingetragene Menge in der Leistungszeile")
# 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"
config.save_config(f.cfg)
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"
config.save_config(f.cfg)
f.destroy()
f = A.RechnungsApp()
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")

165
theme.py
View file

@ -5,13 +5,74 @@ 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
import config
# ---------------------------------------------------------------------------
# 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
@ -35,10 +96,9 @@ def schrift_vorbereiten():
if not sys.platform.startswith("win"):
return SCHRIFT_NAME
import ctypes
import os
FR_PRIVATE = 0x10
geladen = 0
for basis in (config.BASE_DIR, config.RESOURCE_DIR, os.path.dirname(os.path.abspath(__file__))):
for basis in _ordner():
ordner = os.path.join(basis, "assets", "fonts")
if not os.path.isdir(ordner):
continue
@ -100,15 +160,48 @@ def windows_dark_mode():
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 zum Windows-Theme (hell/dunkel) setzen."""
"""Farben passend zur Einstellung setzen (hell, dunkel oder wie Windows)."""
schrift_vorbereiten() # Roboto, sonst Segoe UI
dark = windows_dark_mode()
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
@ -147,6 +240,15 @@ class ThemeMixin:
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")
@ -292,6 +394,22 @@ class ThemeMixin:
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
@ -307,7 +425,10 @@ class ThemeMixin:
# 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,
padding=(20, 9), font=(SCHRIFT_NAME, 10), borderwidth=1)
# 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))],
@ -356,7 +477,7 @@ class ThemeMixin:
und Tastatur fest, waehrend nebenan jemand schreibt.
"""
d.configure(bg=self.col_bg)
if config.testlauf():
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.
@ -370,22 +491,16 @@ class ThemeMixin:
d.after(20, lambda: self._dark_titlebar(d))
def _dark_titlebar(self, win):
"""Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode)."""
if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)):
return
try:
import ctypes
win.update_idletasks()
hwnd = ctypes.windll.user32.GetParent(win.winfo_id())
val = ctypes.c_int(1)
for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE (Win11=20, frühes Win10=19)
ctypes.windll.dwmapi.DwmSetWindowAttribute(
hwnd, attr, ctypes.byref(val), ctypes.sizeof(val))
# Frame neu zeichnen lassen (ohne Flackern/Fokusverlust)
SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED
ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP)
except Exception as e: # noqa: BLE001
print("[theme] Titelleiste:", e)
"""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:
@ -417,7 +532,7 @@ class MeldungMixin:
def melden(self, titel, text, art="warnung"):
"""Eine Meldung mit einem OK-Knopf."""
if config.testlauf():
if testlauf():
{"info": messagebox.showinfo,
"warnung": messagebox.showwarning,
"fehler": messagebox.showerror}.get(art, messagebox.showwarning)(titel, text)
@ -426,7 +541,7 @@ class MeldungMixin:
def frage(self, titel, text="", ja="Ja", nein="Nein", vorgabe=True, **_kw):
"""Ja/Nein. Gibt True oder False zurueck - wie askyesno."""
if config.testlauf():
if testlauf():
return bool(messagebox.askyesno(titel, text))
return bool(self._meldungsfenster(titel, text, "frage",
knoepfe=(ja, nein), vorgabe=vorgabe))

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.7.1` -> VERSION "1.7.1". Wer das eine ohne das andere aendert,
"v": Tag `v1.8.0` -> VERSION "1.8.0". Wer das eine ohne das andere aendert,
baut dem Updater eine Falle.
"""
VERSION = "1.7.1"
VERSION = "1.8.0"
# Zu welchem Repository dieses Programm gehoert - der Updater sucht dort.
REPOSITORY = "TheMockTv/rechnungstool"

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)