Modernes Design (Standard, Win95 als Legacy), Zeitraum abrechnen, Abrechnung erledigt, Linux-Build

- Design: „modern“ (hell, flach, wie Windows 11, normaler Windows-Rahmen) ist Standard;
  „win95“ bleibt unter Datei > Einstellungen wählbar (Einstellung „design“).
- Export: Zeitraum von–bis (nach Aktivierungsdatum oder Abrechnungszeitraum des Anbieters),
  exportierte Positionen werden als abgerechnet gekennzeichnet (Betrag/Händler eingefroren) und
  per Fingerabdruck auch in späteren Dateien/Abrechnungen nicht noch einmal abgerechnet.
- Datei > Abrechnung erledigt / wieder öffnen: erledigte Abrechnungen sind gegen Änderungen gesperrt.
- DB-Schema v4 (Migration inkl. Fingerabdrücke für alte Positionen).
- Linux-Build (python:3.12-bookworm) als zweiter Pipeline-Job mit Tests und Selbsttest;
  Release-Upload für beide Jobs über build/release_hochladen.sh.
- Behoben: normaler Windows-Rahmen stürzte beim Start ab (Hauptrahmen.nativ fehlte).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kerim AtilganandClaude Opus 5.5 committed 2026-11-02 20:47:00 +01:00
1 parent 97c73b82c7
commit 851fd6600a
17 files changed
+829 -80

No files matched your search

+104 -23
View File
@@ -15,7 +15,7 @@ from tkinter import filedialog
from provtool import APP_NAME, config
from provtool import export as exp
from provtool import regeln
from provtool.db import EIGEN, UNZUGEORDNET, Datenbank
from provtool.db import EIGEN, UNZUGEORDNET, AbrechnungErledigt, Datenbank
from provtool.gui import assistent as ass
from provtool.gui import dialogs as dlg
from provtool.gui import menue as mn
@@ -70,13 +70,14 @@ class App:
self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen)
self._beendet = False # Datenbank und Fenster sind zu
w.design_setzen(self.settings.get("design", "modern"))
w.apply(root)
self.icons = w.Icons(root)
root._app_icon = self.icons("app", 2) # type: ignore[attr-defined]
root.iconphoto(True, self.icons("app", 2), self.icons("app", 1))
root.protocol("WM_DELETE_WINDOW", self.beenden)
root.report_callback_exception = self._fehler_handler
klassisch = bool(self.settings.get("klassischer_rahmen", True))
klassisch = w.DESIGN == "win95" # Win95: eigene Titelleiste; modern: normaler Windows-Rahmen
w.KLASSISCH = klassisch
self.rahmen = rh.Hauptrahmen(root, APP_NAME, self.icons("app"), self.beenden, klassisch=klassisch)
self.rahmen.set_titel(APP_NAME)
@@ -116,6 +117,7 @@ class App:
d.add_command(label="Dateien importieren...", accelerator="Strg+I", command=self.importieren)
d.add_command(label="Importierte Dateien...", command=self.dateien)
d.add_command(label="Exportieren...", accelerator="Strg+E", command=self.exportieren)
d.add_command(label="Abrechnung erledigt / wieder öffnen...", command=self.erledigt_umschalten)
d.add_separator()
d.add_command(label="Datensicherung erstellen...", command=self.sicherung)
d.add_command(label="Datensicherung einspielen...", command=self.wiederherstellen)
@@ -152,7 +154,7 @@ class App:
hi.add_command(label=f"Info über {APP_NAME}...", command=lambda: dlg.InfoDialog(self.root).zeigen())
def _toolbar(self):
tb = tk.Frame(self.inhalt, bg=w.FACE, bd=1, relief="raised")
tb = tk.Frame(self.inhalt, bg=w.FACE, bd=0 if w.MODERN else 1, relief="raised")
self.tb: dict[str, w.ToolbarButton] = {}
tb.pack(side="top", fill="x")
for text, icon, cmd, tip in (
@@ -188,7 +190,8 @@ class App:
# Trennlinie zwischen den Spalten: grau und erhaben wie ein Win95-Splitter, mit der Maus ziehbar.
# Wie im Win95-Explorer wandert beim Ziehen nur eine Linie mit; neu aufgebaut wird erst beim
# Loslassen (flüssig, auch mit vielen Positionen).
pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.FACE, sashwidth=7, sashrelief="raised",
pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.FACE, sashwidth=7,
sashrelief="flat" if w.MODERN else "raised",
bd=0, opaqueresize=False, sashcursor="sb_h_double_arrow")
pw.pack(fill="both", expand=True, padx=6, pady=6)
@@ -227,6 +230,7 @@ class App:
self.ptree.tag_configure("manuell", foreground="#0000c0")
self.ptree.tag_configure("abw", foreground="#a00000")
self.ptree.tag_configure("negativ", foreground="#a00000")
self.ptree.tag_configure("abgerechnet", foreground=w.SHADOW)
r2.pack(fill="both", expand=True)
self.ptree.bind("<Double-1>", self._doppelklick)
self.ptree.bind("<Button-3>", self._kontextmenue)
@@ -241,7 +245,7 @@ class App:
self.v_alle_auf = tk.BooleanVar(value=True)
w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right")
w.knopf(leiste, "Alle aufklappen", lambda: self._klappen(True)).pack(side="right", padx=4)
tk.Label(leiste, text="Blau = von Hand · Rot = abweichend", fg=w.SHADOW).pack(
tk.Label(leiste, text="Blau = von Hand · Rot = abweichend · Grau = abgerechnet", fg=w.SHADOW).pack(
side="left", padx=8)
pw.add(self.f_p, minsize=500)
@@ -272,7 +276,7 @@ class App:
self.haendler_namen = {h.id: h.name for h in self.db.haendler_liste()}
if self.aid and self.db.abrechnung(self.aid):
self.positionen = self.db.positionen(self.aid)
titel = self.db.abrechnung(self.aid)["titel"]
titel = self.db.abrechnung(self.aid)["titel"] + (" – erledigt" if self.db.erledigt(self.aid) else "")
self.rahmen.set_titel(f"{APP_NAME} - [{titel}]")
self.status.set("info", f" Abrechnung: {titel}")
else:
@@ -380,7 +384,9 @@ class App:
values=("", "", "", "", "", fmt(so), pz, fmt(sa)), tags=("gruppe",))
for p in plist:
tags = []
if p["manuell"]:
if p["abgerechnet"] or p["doppelt"]:
tags.append("abgerechnet")
elif p["manuell"]:
tags.append("manuell")
elif p["betrag"] != p["betrag_orig"]:
tags.append("abw")
@@ -389,6 +395,7 @@ class App:
values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]),
p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""),
fmt(p["betrag_orig"]),
"abgerechnet" if p["abgerechnet"] or p["doppelt"] else
"manuell" if p["manuell"] else ("Staffel" if _ist_staffel(p) else fmt_percent(p["prozent"])),
fmt(p["betrag"])),
tags=tuple(tags))
@@ -470,7 +477,7 @@ class App:
if not dateien:
return
self.settings["import_ordner"] = str(Path(dateien[0]).parent)
if not self._brauche_abrechnung():
if not self._brauche_abrechnung() or self._gesperrt():
return
bericht = []
self.root.configure(cursor="watch")
@@ -494,8 +501,12 @@ class App:
bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)"))
continue
n = self.db.importieren(self.aid, erg)
did = self.db.dateien(self.aid)[-1]["id"]
doppelt = sum(1 for p in self.db.positionen(self.aid) if p["datei_id"] == did and p["doppelt"])
hinweise = list(erg.warnungen) + ([f"{doppelt} Pos. bereits abgerechnet (werden nicht erneut "
"exportiert)"] if doppelt else [])
bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen),
"; ".join(erg.warnungen) or "OK"))
"; ".join(hinweise) or "OK"))
finally:
self.root.configure(cursor="")
self._aktualisieren()
@@ -567,7 +578,45 @@ class App:
ps = [p for p in self.positionen if p["id"] in set(pids)]
return sel, pids, ps
def _gesperrt(self) -> bool:
"""Erledigte Abrechnung: keine Änderungen (Hinweis statt stillem Nichtstun)."""
if self.aid and self.db.erledigt(self.aid):
w.info(self.root, "Abrechnung erledigt", "Diese Abrechnung ist als erledigt gekennzeichnet und kann "
"nicht mehr geändert werden.\n\nZum Ändern: Datei > Abrechnung erledigt / wieder öffnen.")
return True
return False
def erledigt_umschalten(self):
if not self.aid:
return
titel = self.db.abrechnung(self.aid)["titel"]
if self.db.erledigt(self.aid):
if not w.frage(self.root, "Wieder öffnen", f"Abrechnung „{titel}“ wieder öffnen?"):
return
auch = w.frage(self.root, "Wieder öffnen", "Sollen die Positionen auch wieder als NICHT abgerechnet "
"gelten (z. B. um sie neu zu exportieren)?", ja="Ja, freigeben", nein="Nein",
default_ja=False)
self.db.erledigt_setzen(self.aid, False, positionen_aufheben=auch)
else:
if not w.frage(self.root, "Abrechnung erledigt",
f"Abrechnung „{titel}“ als erledigt kennzeichnen?\n\nAlle Händler-Positionen gelten dann "
"als abgerechnet und werden auch in späteren Dateien nicht noch einmal abgerechnet. "
"Die Abrechnung kann danach nicht mehr geändert werden."):
return
self.db.erledigt_setzen(self.aid, True)
self._aktualisieren()
def abgerechnet_aufheben(self):
if self._gesperrt():
return
_, pids, _ = self._auswahl_info()
n = self.db.abrechnung_aufheben(pids)
self._aktualisieren()
self.status.set("info", f" {n} Position(en) wieder offen")
def prozent(self):
if self._gesperrt():
return
sel, pids, ps = self._auswahl_info()
if not ps:
w.info(self.root, "Auszahlung", "Bitte zuerst eine Provisionsart (Gruppe) oder einzelne Positionen "
@@ -601,6 +650,8 @@ class App:
self.ptree.see(vorhanden[0])
def zuruecksetzen(self):
if self._gesperrt():
return
sel, pids, ps = self._auswahl_info()
if not ps:
return
@@ -610,7 +661,7 @@ class App:
self._auswahl_wiederherstellen(sel)
def neu_berechnen(self):
if not self.aid:
if not self.aid or self._gesperrt():
return
manuell = sum(1 for p in self.positionen if p["manuell"])
auch = False
@@ -638,6 +689,8 @@ class App:
self.betrag_bearbeiten(iid)
def betrag_bearbeiten(self, iid: str | None = None):
if self._gesperrt():
return
if iid is None:
sel = [i for i in self.ptree.selection() if i.startswith("p")]
if len(sel) != 1:
@@ -652,6 +705,10 @@ class App:
bbox = self.ptree.bbox(iid, "ausz")
pid = int(iid[1:])
p = next(x for x in self.positionen if x["id"] == pid)
if p["abgerechnet"]:
w.info(self.root, "Betrag ändern", "Diese Position ist bereits abgerechnet.\n\n"
"Zum Ändern: Rechtsklick > Kennzeichnung „abgerechnet“ aufheben.")
return
if not bbox:
# Zelle gerade nicht sichtbar (z. B. Spalte zu schmal) -> kleiner Dialog statt Inline-Feld
self._betrag_dialog(p)
@@ -748,6 +805,9 @@ class App:
if any(p["haendler_id"] == EIGEN for p in ps):
m.add_separator()
m.add_command(label="Kennung wieder einblenden", command=self.eigene_einblenden)
if any(p["abgerechnet"] for p in ps):
m.add_separator()
m.add_command(label="Kennzeichnung „abgerechnet“ aufheben", command=self.abgerechnet_aufheben)
m.tk_popup(e.x_root, e.y_root)
# ----- Eigene Kennungen (eigene Filiale) ausblenden
@@ -780,6 +840,8 @@ class App:
self._aktualisieren()
def haendler_zuweisen(self, automatisch: bool = False):
if self._gesperrt():
return
sel, pids, _ = self._auswahl_info()
if not pids:
w.info(self.root, "Händler zuweisen", "Bitte zuerst Positionen oder eine Gruppe auswählen.")
@@ -795,6 +857,8 @@ class App:
self._aktualisieren()
def _null(self):
if self._gesperrt():
return
sel, pids, _ = self._auswahl_info()
self.db.prozent_anwenden(pids, 0.0)
self.db.touch(self.aid)
@@ -807,14 +871,19 @@ class App:
w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n"
"Bitte zuerst Dateien importieren.")
return
# nur offene Positionen echter Händler (nicht: nicht zugeordnet, eigene, schon abgerechnet)
offen = [p for p in self.positionen if p["haendler_id"] > 0 and not p["abgerechnet"] and not p["doppelt"]]
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0])
for p in self.positionen:
if p["haendler_id"] > 0: # nicht: nicht zugeordnet / eigene Kennungen
stat[p["haendler_id"]][0] += 1
stat[p["haendler_id"]][1] += p["betrag"]
for p in offen:
stat[p["haendler_id"]][0] += 1
stat[p["haendler_id"]][1] += p["betrag"]
if not stat:
w.warnung(self.root, "Export", "Keine Position ist einem Händler zugeordnet.\n\n"
"Lege unter „Händler“ die Unterhändler mit ihren Kennungen an.")
if any(p["haendler_id"] > 0 for p in self.positionen):
w.info(self.root, "Export", "Alle Positionen dieser Abrechnung sind bereits abgerechnet.\n\n"
"Zum erneuten Export: Datei > Abrechnung erledigt / wieder öffnen (Positionen freigeben).")
else:
w.warnung(self.root, "Export", "Keine Position ist einem Händler zugeordnet.\n\n"
"Lege unter „Händler“ die Unterhändler mit ihren Kennungen an.")
return
liste = [(hid, self.haendler_namen[hid], n, s) for hid, (n, s) in
sorted(stat.items(), key=lambda x: self.haendler_namen[x[0]].lower())]
@@ -824,19 +893,27 @@ class App:
if not res:
return
config.save(self.settings)
erzeugt = []
erzeugt, exportiert = [], []
zeit = res["zeitraum"]
try:
for hid in res["ids"]:
pos = [p for p in self.positionen if p["haendler_id"] == hid]
pos = [p for p in offen if p["haendler_id"] == hid and (zeit is None or zeit.enthaelt(p))]
erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"],
pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"],
summe=res["summe"], null_weglassen=res["null_weglassen"])
exportiert += pos
except PermissionError as exc:
w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n"
"Ist sie vielleicht noch in Excel geöffnet?")
return
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}\n\n"
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n..." if len(erzeugt) > 15 else ""))
hinweis = ""
if res["kennzeichnen"] and exportiert:
n = self.db.abrechnen([p["id"] for p in exportiert])
hinweis = f"\n\n{n} Position(en) als abgerechnet gekennzeichnet."
self._aktualisieren()
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}"
+ (f"\nZeitraum: {zeit.text()}" if zeit else "") + "\n\n"
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n..." if len(erzeugt) > 15 else "") + hinweis)
if res["oeffnen"]:
_oeffne_ordner(res["ordner"])
@@ -891,10 +968,10 @@ class App:
res = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen()
if not res:
return
if res["klassisch"] != bool(self.settings.get("klassischer_rahmen", True)):
self.settings["klassischer_rahmen"] = res["klassisch"]
if res["design"] != w.DESIGN:
self.settings["design"] = res["design"]
config.save(self.settings)
w.info(self.root, "Einstellungen", "Die Änderung der Titelleiste wird beim nächsten Programmstart wirksam.")
w.info(self.root, "Einstellungen", "Das neue Design wird beim nächsten Programmstart wirksam.")
if res["pfad"] and Path(res["pfad"]) != self.db.path:
try:
self.db_wechseln(res["pfad"])
@@ -919,6 +996,10 @@ class App:
"5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat (freenet\n"
" und Vodafone zusammen, Airtime als eine Summenzeile) und eine Airtime-Liste.\n"
" Positionen mit 0,00 € werden weggelassen.\n\n"
"Zeitraum: Im Export-Dialog „Nur von … bis …“ (oder „Vormonat“). Exportierte Positionen\n"
"gelten als abgerechnet (grau) und werden nicht noch einmal abgerechnet – auch nicht, wenn\n"
"sie in einer späteren Datei wieder auftauchen. Datei > Abrechnung erledigt sperrt eine\n"
"fertige Abrechnung.\n\n"
"Welche Provisionsart Airtime bzw. Jahresbonus ist, wird automatisch erkannt –\n"
"umstellbar unter Händler > Provisionsarten.\n\n"
"Alles wird automatisch in der Datenbank gespeichert.")
+51 -11
View File
@@ -6,7 +6,8 @@ from tkinter import filedialog, ttk
from provtool import APP_NAME, APP_ORG, __version__
from provtool import regeln
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Datenbank, Haendler
from provtool import zeitraum as zr
from provtool.db import CAPS_ANBIETER, CAPS_WERT, AbrechnungErledigt, Datenbank, Haendler
from provtool.gui import win95 as w
from provtool.importer import ANBIETER
from provtool.money import fmt, fmt_percent, parse_cent, parse_percent
@@ -561,9 +562,9 @@ class AbrechnungenDialog(w.Dialog):
super().__init__(master, "Abrechnung öffnen", resizable=True)
self.db = db
tk.Label(self, text="Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
r, self.tree = w.scroll_tree(self, columns=("titel", "geaendert", "anzahl", "dateien"), show="headings",
height=12, selectmode="browse")
for c, t, b in (("titel", "Bezeichnung", 180), ("geaendert", "Zuletzt geändert", 120),
r, self.tree = w.scroll_tree(self, columns=("titel", "status", "geaendert", "anzahl", "dateien"),
show="headings", height=12, selectmode="browse")
for c, t, b in (("titel", "Bezeichnung", 180), ("status", "Status", 110), ("geaendert", "Zuletzt geändert", 120),
("anzahl", "Positionen", 70), ("dateien", "Dateien", 260)):
self.tree.heading(c, text=t)
self.tree.column(c, width=b, anchor="e" if c == "anzahl" else "w")
@@ -581,8 +582,9 @@ class AbrechnungenDialog(w.Dialog):
def _laden(self, sel=None):
self.tree.delete(*self.tree.get_children())
for a in self.db.abrechnungen():
status = "erledigt" if a["erledigt"] else (f"{a['abgerechnet']} abgerechnet" if a["abgerechnet"] else "offen")
self.tree.insert("", "end", iid=str(a["id"]),
values=(a["titel"], a["geaendert"][:16], a["anzahl"], a["dateien"] or ""))
values=(a["titel"], status, a["geaendert"][:16], a["anzahl"], a["dateien"] or ""))
kids = self.tree.get_children()
if sel and self.tree.exists(str(sel)):
self.tree.selection_set(str(sel))
@@ -655,7 +657,11 @@ class DateienDialog(w.Dialog):
name = self.tree.item(s[0], "values")[0]
if w.frage(self, "Datei entfernen", f"Alle Positionen aus „{name}“ aus dieser Abrechnung entfernen?\n"
"Von Hand geänderte Beträge dieser Datei gehen verloren.", default_ja=False):
self.db.datei_entfernen(int(s[0]))
try:
self.db.datei_entfernen(int(s[0]))
except AbrechnungErledigt as exc:
w.info(self, "Datei entfernen", str(exc))
return
self.geaendert = True
self._laden()
@@ -900,8 +906,32 @@ class ExportDialog(w.Dialog):
"eigene Datei „... - Airtime - ...xlsx“.", fg=w.SHADOW, justify="left", anchor="w").grid(
row=7, column=0, columnspan=3, sticky="w", pady=(4, 0))
g.columnconfigure(1, weight=1)
z = w.gruppe(f, "Zeitraum und Abrechnung")
z.pack(fill="x", pady=(8, 0))
zf = tk.Frame(z, bg=w.FACE)
zf.pack(fill="x")
tk.Label(zf, text="Nur von:").pack(side="left")
self.v_von, self.v_bis = tk.StringVar(), tk.StringVar()
tk.Entry(zf, textvariable=self.v_von, width=11).pack(side="left", padx=4)
tk.Label(zf, text="bis:").pack(side="left")
tk.Entry(zf, textvariable=self.v_bis, width=11).pack(side="left", padx=4)
w.knopf(zf, "Vormonat", self._vormonat).pack(side="left", padx=(4, 0))
self.v_basis = tk.StringVar(value=zr.BASEN[settings.get("export_basis", zr.AKTIVIERUNG)])
ttk.Combobox(z, textvariable=self.v_basis, values=list(zr.BASEN.values()), state="readonly",
width=40).pack(anchor="w", pady=(4, 0))
tk.Label(z, text="(leer = alle offenen Positionen; Datum als TT.MM.JJJJ)", fg=w.SHADOW, anchor="w").pack(
fill="x")
self.v_kennz = tk.BooleanVar(value=True)
tk.Checkbutton(z, text="Exportierte Positionen als abgerechnet kennzeichnen (werden dann nicht noch "
"einmal abgerechnet)", variable=self.v_kennz, anchor="w").pack(fill="x", pady=(4, 0))
self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
def _vormonat(self):
von, bis = zr.vormonat()
self.v_von.set(von.strftime("%d.%m.%Y"))
self.v_bis.set(bis.strftime("%d.%m.%Y"))
def _klick(self, e):
if self.tree.identify_column(e.x) == "#0":
iid = self.tree.identify_row(e.y)
@@ -928,6 +958,12 @@ class ExportDialog(w.Dialog):
if not ids:
w.warnung(self, "Export", "Bitte mindestens einen Händler anhaken.")
return
try:
von, bis = zr.datum_lesen(self.v_von.get()), zr.datum_lesen(self.v_bis.get())
except ValueError as exc:
w.warnung(self, "Zeitraum", str(exc))
return
basis = next(k for k, v in zr.BASEN.items() if v == self.v_basis.get())
if not self.v_ordner.get().strip():
self._ordner()
if not self.v_ordner.get().strip():
@@ -938,7 +974,10 @@ class ExportDialog(w.Dialog):
self.settings.pop("export_pro_anbieter", None)
self.result = dict(ids=ids, ordner=self.v_ordner.get().strip(), titel=self.v_titel.get().strip(),
pro_anbieter=self.v_anb.get(), null_weglassen=self.v_null.get(),
kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(), oeffnen=self.v_auf.get())
kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(), oeffnen=self.v_auf.get(),
zeitraum=zr.Zeitraum(von, bis, basis) if (von or bis) else None,
kennzeichnen=self.v_kennz.get())
self.settings["export_basis"] = basis
self.destroy()
@@ -960,9 +999,10 @@ class EinstellungenDialog(w.Dialog):
justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w")
g2 = w.gruppe(self, "Darstellung")
g2.pack(fill="x", padx=10, pady=(0, 10))
self.v_klassisch = tk.BooleanVar(value=bool(settings.get("klassischer_rahmen", True)))
tk.Checkbutton(g2, text="Windows-95-Titelleiste verwenden (sonst die normale von Windows)",
variable=self.v_klassisch).pack(anchor="w")
self.v_design = tk.StringVar(value=settings.get("design", "modern"))
tk.Radiobutton(g2, text="Modern (wie Windows 11)", variable=self.v_design, value="modern").pack(anchor="w")
tk.Radiobutton(g2, text="Klassisch (Windows 95, Legacy)", variable=self.v_design, value="win95").pack(
anchor="w")
tk.Label(g2, text="Wirkt beim nächsten Programmstart.", fg=w.SHADOW).pack(anchor="w")
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
@@ -974,7 +1014,7 @@ class EinstellungenDialog(w.Dialog):
self.v.set(p)
def _ok(self):
self.result = dict(pfad=self.v.get().strip(), klassisch=self.v_klassisch.get())
self.result = dict(pfad=self.v.get().strip(), design=self.v_design.get())
self.destroy()
+14 -8
View File
@@ -51,25 +51,28 @@ class Popup:
self.leiste = leiste
root = self.master._root()
f = self.fenster = tk.Toplevel(root, bg=w.FACE, bd=2, relief="raised")
if w.MODERN:
f.configure(bg=w.WHITE, bd=0, relief="flat", highlightthickness=1, highlightbackground=w.RAND)
f.withdraw()
f.overrideredirect(True)
self.zeilen = []
grund, py = (w.WHITE, 4) if w.MODERN else (w.FACE, 2)
for e in self.eintraege:
if e["art"] == "trenner":
t = tk.Frame(f, bg=w.FACE, height=8)
t = tk.Frame(f, bg=grund, height=8)
t.pack(fill="x")
linie = w.trenner(t, vertikal=False)
linie.place(relx=0, rely=0.5, relwidth=1, y=-1)
continue
zeile = tk.Frame(f, bg=w.FACE)
zeile = tk.Frame(f, bg=grund)
zeile.pack(fill="x")
grau = e["state"] == "disabled"
text = tk.Label(zeile, text=e["label"], bg=w.FACE, anchor="w", padx=18, pady=2,
text = tk.Label(zeile, text=e["label"], bg=grund, anchor="w", padx=18, pady=py,
fg=w.SHADOW if grau else w.DARK)
text.pack(side="left", fill="x", expand=True)
labels = [text]
if e["accelerator"]:
acc = tk.Label(zeile, text=e["accelerator"], bg=w.FACE, anchor="e", padx=12, pady=2,
acc = tk.Label(zeile, text=e["accelerator"], bg=grund, anchor="e", padx=12, pady=py,
fg=w.SHADOW if grau else w.DARK)
acc.pack(side="right")
labels.append(acc)
@@ -170,8 +173,8 @@ class Popup:
self.aktiv = i
for j, (e, zeile, labels) in enumerate(self.zeilen):
an = j == i and e.get("state") != "disabled"
bg = w.NAVY if an else w.FACE
fg = w.WHITE if an else (w.SHADOW if e.get("state") == "disabled" else w.DARK)
bg = w.SEL_BG if an else (w.WHITE if w.MODERN else w.FACE)
fg = w.SEL_FG if an else (w.SHADOW if e.get("state") == "disabled" else w.DARK)
zeile.configure(bg=bg)
for lbl in labels:
lbl.configure(bg=bg, fg=fg)
@@ -248,7 +251,7 @@ class Menueleiste(tk.Frame):
def _darstellen(self):
for k in self.knoepfe.values():
if k is self.offener_knopf:
k.configure(bg=w.NAVY, fg=w.WHITE, relief="flat")
k.configure(bg=w.SEL_BG, fg=w.SEL_FG, relief="flat")
else:
k.configure(bg=w.FACE, fg=w.DARK, relief="flat")
@@ -289,7 +292,10 @@ class Menueleiste(tk.Frame):
def _hover(self, knopf, drin: bool):
if self.offener_knopf is None:
knopf.configure(relief="raised" if drin else "flat")
if w.MODERN:
knopf.configure(bg=w.LIGHT if drin else w.FACE)
else:
knopf.configure(relief="raised" if drin else "flat")
def _alt(self, knopf):
self.oeffnen(knopf)
+1 -1
View File
@@ -231,6 +231,7 @@ class Hauptrahmen:
self.maximiert = False
self._normal_geo: str | None = None
self._schliessen = schliessen
self.nativ = None
root.minsize(minbreite, minhoehe)
if not klassisch:
self.aussen = tk.Frame(root, bg=w.FACE)
@@ -266,7 +267,6 @@ class Hauptrahmen:
self.aussen.bind("<B1-Motion>", self._resize)
root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget))
self.nativ = None
if IS_WIN and NATIVER_RAHMEN:
from provtool.gui.nativ import NativerRahmen
self.nativ = NativerRahmen(root, self.RAND, self.ECKE)
+115 -5
View File
@@ -1,4 +1,8 @@
"""Windows-95-Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine."""
"""Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine.
Zwei Designs: „modern“ (hell und flach, wie Windows 11 – Standard) und „win95“ (klassisch, Legacy).
design_setzen() muss VOR dem Aufbau der Oberfläche laufen; alle Module lesen die Farben als w.FACE usw.
"""
from __future__ import annotations
import sys
@@ -14,6 +18,19 @@ NAVY = "#000080"
TEAL = "#008080"
WHITE = "#ffffff"
INFO_BG = "#ffffe1"
SEL_BG, SEL_FG = NAVY, WHITE # Auswahl/Markierung
RAND = SHADOW # dünne Rahmenlinien (nur modern)
DESIGN = "win95"
MODERN = False
_PALETTEN = {
"win95": dict(FACE="#c0c0c0", HILITE="#ffffff", LIGHT="#dfdfdf", SHADOW="#808080", DARK="#000000",
NAVY="#000080", TEAL="#008080", WHITE="#ffffff", INFO_BG="#ffffe1", SEL_BG="#000080",
SEL_FG="#ffffff", RAND="#808080"),
"modern": dict(FACE="#f3f3f3", HILITE="#ffffff", LIGHT="#ebebeb", SHADOW="#8a8a8a", DARK="#1b1b1b",
NAVY="#005fb8", TEAL="#e6eef6", WHITE="#ffffff", INFO_BG="#ffffff", SEL_BG="#cce4f7",
SEL_FG="#1b1b1b", RAND="#d1d1d1"),
}
IS_WIN = sys.platform.startswith("win")
FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans"
@@ -23,6 +40,24 @@ FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
FONT_MONO = ("Courier New" if IS_WIN else "DejaVu Sans Mono", FONT_SIZE + 1)
def design_setzen(name: str) -> None:
"""„modern“ oder „win95“ – setzt Farben und Schriften (Feinschliff der Schrift in apply())."""
global DESIGN, MODERN, FONT_FAMILY, FONT_SIZE
DESIGN = name if name in _PALETTEN else "modern"
MODERN = DESIGN == "modern"
globals().update(_PALETTEN[DESIGN])
FONT_FAMILY = ("Segoe UI" if MODERN else "MS Sans Serif") if IS_WIN else "DejaVu Sans"
FONT_SIZE = 9 if (MODERN or not IS_WIN) else 8
_schriften()
def _schriften():
global FONT, FONT_BOLD, FONT_BIG
FONT = (FONT_FAMILY, FONT_SIZE)
FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
# --------------------------------------------------------------------------- Pixel-Icons (16x16)
PALETTE = {
"k": "#000000", "w": "#ffffff", "g": "#c0c0c0", "d": "#808080", "y": "#ffff00", "Y": "#808000",
@@ -288,6 +323,13 @@ class Icons:
# --------------------------------------------------------------------------- Theme
def apply(root: tk.Tk) -> None:
global FONT_FAMILY
if MODERN and not IS_WIN: # Linux: die erste vorhandene moderne Schrift
from tkinter import font as tkfont
vorhanden = set(tkfont.families(root))
FONT_FAMILY = next((f for f in ("Segoe UI", "Noto Sans", "Cantarell", "Ubuntu", "DejaVu Sans")
if f in vorhanden), FONT_FAMILY)
_schriften()
root.configure(bg=FACE)
o = root.option_add
o("*Font", FONT)
@@ -343,6 +385,45 @@ def apply(root: tk.Tk) -> None:
selectforeground=[("readonly", DARK)])
st.configure("TPanedwindow", background=FACE)
st.configure("Sash", sashthickness=6, background=FACE)
if MODERN:
_modern(root, st, zeile)
def _modern(root: tk.Tk, st: ttk.Style, zeile: int) -> None:
"""Flach und hell: dünne graue Rahmen statt 3D-Kanten, Akzentfarbe beim Fokus."""
o = root.option_add
for klasse in ("Entry", "Text", "Listbox", "Spinbox"):
o(f"*{klasse}.relief", "flat")
o(f"*{klasse}.borderWidth", 3)
o(f"*{klasse}.highlightThickness", 1)
o(f"*{klasse}.highlightBackground", RAND)
o(f"*{klasse}.highlightColor", NAVY)
o("*Button.relief", "flat")
o("*Button.borderWidth", 0)
o("*Button.highlightThickness", 1)
o("*Button.highlightBackground", RAND)
o("*Button.background", WHITE)
o("*Button.activeBackground", SEL_BG)
o("*Button.padY", 3)
o("*Checkbutton.activeBackground", FACE)
o("*Radiobutton.activeBackground", FACE)
o("*Menu.relief", "flat")
st.theme_use("clam")
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=FACE, selectbackground=SEL_BG,
selectforeground=SEL_FG, fieldbackground=WHITE, bordercolor=RAND, lightcolor=WHITE,
darkcolor=RAND, focuscolor=NAVY)
st.configure("Treeview", background=WHITE, fieldbackground=WHITE, rowheight=zeile + 4, borderwidth=0, font=FONT)
st.map("Treeview", background=[("selected", SEL_BG)], foreground=[("selected", SEL_FG)])
st.configure("Treeview.Heading", background=WHITE, relief="flat", borderwidth=1, font=FONT, padding=(6, 3),
bordercolor=RAND, lightcolor=WHITE, darkcolor=RAND)
st.map("Treeview.Heading", background=[("active", LIGHT)], relief=[("pressed", "flat")])
st.configure("TScrollbar", background=LIGHT, troughcolor=FACE, arrowcolor=SHADOW, bordercolor=FACE,
lightcolor=LIGHT, darkcolor=LIGHT, relief="flat", arrowsize=12, width=12)
st.map("TScrollbar", background=[("active", RAND)])
st.configure("TCombobox", fieldbackground=WHITE, background=WHITE, arrowcolor=DARK, padding=3,
bordercolor=RAND, lightcolor=WHITE, darkcolor=WHITE)
st.map("TCombobox", fieldbackground=[("readonly", WHITE)], selectbackground=[("readonly", WHITE)],
selectforeground=[("readonly", DARK)], bordercolor=[("focus", NAVY)])
# --------------------------------------------------------------------------- Wort löschen
@@ -423,6 +504,8 @@ class Sunken(tk.Frame):
"""Eingelassener Rahmen (Doppellinie wie bei Win95-Listen)."""
def __init__(self, master, **kw):
if MODERN:
kw.update(bd=0, relief="flat", highlightthickness=1, highlightbackground=RAND)
kw.setdefault("bd", 2)
kw.setdefault("relief", "sunken")
super().__init__(master, **kw)
@@ -432,6 +515,15 @@ class Fenster(tk.Frame):
"""Ein „Fenster im Fenster“ mit blauer Titelleiste – reine Optik, gliedert die Oberfläche."""
def __init__(self, master, titel: str, icon: tk.PhotoImage | None = None, **kw):
if MODERN: # schlichte Überschrift statt blauer Titelleiste
super().__init__(master, bd=0, bg=FACE, **kw)
self.titel_var = tk.StringVar(value=titel)
self.titelleiste = tk.Label(self, textvariable=self.titel_var, bg=FACE, fg=DARK, font=FONT_BOLD,
anchor="w")
self.titelleiste.pack(fill="x", pady=(0, 4))
self.inhalt = tk.Frame(self, bg=FACE)
self.inhalt.pack(fill="both", expand=True)
return
super().__init__(master, bd=2, relief="raised", bg=FACE, **kw)
self.titelleiste = tk.Frame(self, bg=NAVY, height=18)
self.titelleiste.pack(fill="x", padx=1, pady=1)
@@ -453,7 +545,7 @@ class Statusleiste(tk.Frame):
self.vars: dict[str, tk.StringVar] = {}
for i, (key, breite) in enumerate(felder):
v = tk.StringVar()
lbl = tk.Label(self, textvariable=v, bd=1, relief="sunken", anchor="w", padx=4)
lbl = tk.Label(self, textvariable=v, bd=1, relief="flat" if MODERN else "sunken", anchor="w", padx=4)
if breite:
lbl.configure(width=breite)
lbl.pack(side="left", padx=(0, 2), pady=2)
@@ -466,8 +558,11 @@ class Statusleiste(tk.Frame):
def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None, **kw):
b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE,
activebackground=FACE, **kw)
if MODERN:
b = tk.Button(master, text=text, command=command, **kw) # Aussehen aus apply()/_modern()
else:
b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE,
activebackground=FACE, **kw)
if icon is not None:
b.configure(image=icon, compound="left", padx=6)
if breite:
@@ -482,6 +577,15 @@ class ToolbarButton(tk.Label):
super().__init__(master, text=text, image=icon, compound="top", bd=1, relief="flat", bg=FACE,
padx=6, pady=2, font=FONT)
self.command = command
if MODERN: # Hervorhebung durch Farbe statt 3D-Kante
self.configure(bd=0, padx=8, pady=4)
self.bind("<Enter>", lambda e: self.configure(bg=LIGHT))
self.bind("<Leave>", lambda e: self.configure(bg=FACE))
self.bind("<ButtonPress-1>", lambda e: self.configure(bg=SEL_BG))
self.bind("<ButtonRelease-1>", self._release)
if tooltip:
Tooltip(self, tooltip)
return
self.bind("<Enter>", lambda e: self.configure(relief="raised"))
self.bind("<Leave>", lambda e: self.configure(relief="flat"))
self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken"))
@@ -490,7 +594,7 @@ class ToolbarButton(tk.Label):
Tooltip(self, tooltip)
def _release(self, e):
self.configure(relief="raised")
self.configure(bg=LIGHT) if MODERN else self.configure(relief="raised")
if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height() and self.command:
self.command()
@@ -528,6 +632,10 @@ class Tooltip:
def trenner(master, vertikal: bool = True):
f = tk.Frame(master, bg=FACE)
if MODERN:
tk.Frame(f, width=1, height=1, bg=RAND).pack(side="left", fill="y") if vertikal else \
tk.Frame(f, height=1, bg=RAND).pack(fill="x")
return f
if vertikal:
tk.Frame(f, width=1, bg=SHADOW).pack(side="left", fill="y")
tk.Frame(f, width=1, bg=HILITE).pack(side="left", fill="y")
@@ -538,6 +646,8 @@ def trenner(master, vertikal: bool = True):
def gruppe(master, titel: str, **kw) -> tk.LabelFrame:
if MODERN:
return tk.LabelFrame(master, text=titel, bd=0, relief="flat", bg=FACE, padx=4, pady=2, font=FONT_BOLD, **kw)
return tk.LabelFrame(master, text=titel, bd=2, relief="groove", bg=FACE, padx=6, pady=4, **kw)