From 6b520bce35a4e67a275164d2c23e8b67357b28c3 Mon Sep 17 00:00:00 2001 From: Handy-Insel Date: Mon, 2 Nov 2026 10:14:19 +0100 Subject: [PATCH] =?UTF-8?q?Win95-Titelleiste=20und=20-Men=C3=BCleiste,=20E?= =?UTF-8?q?inrichtungs-Assistent,=20Rundgang,=20Tipp=20des=20Tages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - selbst gezeichneter Fensterrahmen (verschieben, maximieren, minimieren, Größe ändern), abschaltbar - Dialoge mit Win95-Titelleiste; vorherige Modalsperre wird nach Rückfragen wiederhergestellt - Assistent: Speicherort, Unterhändler (Nummern aus Abrechnung vorschlagen), erste Abrechnung - Rundgang mit Hinweis-Sprechblasen, Tipp des Tages Co-Authored-By: Claude Opus 5.5 --- README.md | 13 + provtool/__init__.py | 2 +- provtool/gui/app.py | 159 ++++++++--- provtool/gui/assistent.py | 569 ++++++++++++++++++++++++++++++++++++++ provtool/gui/dialogs.py | 8 +- provtool/gui/rahmen.py | 372 +++++++++++++++++++++++++ provtool/gui/win95.py | 48 +++- 7 files changed, 1128 insertions(+), 43 deletions(-) create mode 100644 provtool/gui/assistent.py create mode 100644 provtool/gui/rahmen.py diff --git a/README.md b/README.md index 4bcb157..17c1b93 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,17 @@ von **freenet** (CSV/Excel) und **Vodafone/Euronics** (Excel) ein, ordnet die Po Unterhändlern zu, erlaubt freie Anpassung der Auszahlung und exportiert je Händler eine fertige Excel-Abrechnung. Optik: Windows 95. +## Erster Start + +Beim ersten Start führt ein **Einrichtungs-Assistent** durch Speicherort, Unterhändler +(inkl. „Nummern aus Abrechnung vorschlagen“) und die erste Abrechnung. Danach zeigt ein +**Rundgang** mit Hinweis-Sprechblasen die wichtigsten Stellen. Beides sowie den +**Tipp des Tages** gibt es jederzeit im Menü **?**. + +Die Windows-95-Titelleiste ist selbst gezeichnet (Windows lässt die echte nicht umfärben). +Falls sie auf einem PC Probleme macht: *Datei ▸ Einstellungen ▸ Darstellung* – dann wird die +normale Windows-Titelleiste verwendet. + ## Bedienung in Kürze 1. **Händler anlegen** (`Strg+H`): Name, Standard-Auszahlung in % und die *Kennungen*: @@ -75,6 +86,8 @@ Aufbau: | `provtool/export.py` | Excel-Export im Zielformat | | `provtool/money.py` | Beträge (intern immer in Cent) | | `provtool/gui/` | Oberfläche (Tkinter im Win95-Stil) | +| `provtool/gui/rahmen.py` | selbst gezeichnete Titelleiste, Menüleiste, Größenänderung | +| `provtool/gui/assistent.py` | Einrichtungs-Assistent, Rundgang, Tipp des Tages | **Datenschutz:** Echte Abrechnungen enthalten Kundendaten und gehören nicht ins Repository (`.gitignore` schließt `*.csv`, `*.xlsx` im Hauptordner und `beispieldaten/` aus). diff --git a/provtool/__init__.py b/provtool/__init__.py index 45c15b8..44a64cc 100644 --- a/provtool/__init__.py +++ b/provtool/__init__.py @@ -1,5 +1,5 @@ """Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach.""" -__version__ = "1.0.0" +__version__ = "1.1.0" APP_NAME = "Provisions-Manager 95" APP_ORG = "Handy-Insel Miesbach" diff --git a/provtool/gui/app.py b/provtool/gui/app.py index f5f29d6..d992327 100644 --- a/provtool/gui/app.py +++ b/provtool/gui/app.py @@ -15,7 +15,9 @@ from tkinter import filedialog from provtool import APP_NAME, config from provtool import export as exp from provtool.db import UNZUGEORDNET, Datenbank +from provtool.gui import assistent as ass from provtool.gui import dialogs as dlg +from provtool.gui import rahmen as rh from provtool.gui import win95 as w from provtool.importer import ANBIETER, ImportFehler, lies_datei from provtool.money import fmt, fmt_percent, parse_cent @@ -67,30 +69,40 @@ class App: 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.title(APP_NAME) - root.geometry(self.settings.get("fenster", "1200x720")) - root.minsize(900, 520) root.protocol("WM_DELETE_WINDOW", self.beenden) root.report_callback_exception = self._fehler_handler + klassisch = bool(self.settings.get("klassischer_rahmen", True)) + w.KLASSISCH = klassisch + self.rahmen = rh.Hauptrahmen(root, APP_NAME, self.icons("app"), self.beenden, klassisch=klassisch) + self.rahmen.set_titel(APP_NAME) + self.inhalt = self.rahmen.inhalt self._menue() self._toolbar() self._body() - self.status = w.Statusleiste(root, [("info", 0), ("pos", 16), ("orig", 22), ("ausz", 22)]) + self.status = w.Statusleiste(self.inhalt, [("info", 0), ("pos", 16), ("orig", 22), ("ausz", 22)]) + griff = self.rahmen.griff(self.status) + if griff: + griff.pack(side="right", anchor="se") self.status.pack(side="bottom", fill="x") self._tasten() + self.rahmen.geometrie_setzen(self.settings.get("fenster"), self.settings.get("maximiert", False)) abr = self.db.abrechnungen() if abr: self.oeffnen(abr[0]["id"]) else: self._aktualisieren() - root.after(300, self._willkommen) + if not self.settings.get("assistent_erledigt") and not self.db.haendler_liste(): + root.after(400, self.assistent) + elif self.settings.get("tipps_beim_start", True): + root.after(500, self.tipp_des_tages) # ================================================================= Aufbau def _menue(self): - m = tk.Menu(self.root, tearoff=0) - d = tk.Menu(m, tearoff=0) + self.menueleiste = rh.Menueleiste(self.inhalt, self.root) + self.menueleiste.pack(side="top", fill="x") + d = self.menueleiste.menue("Datei") d.add_command(label="Neue Abrechnung…", accelerator="Strg+N", command=self.neu) d.add_command(label="Abrechnung öffnen…", accelerator="Strg+O", command=self.abrechnung_waehlen) d.add_separator() @@ -103,32 +115,32 @@ class App: d.add_command(label="Einstellungen…", command=self.einstellungen) d.add_separator() d.add_command(label="Beenden", accelerator="Alt+F4", command=self.beenden) - m.add_cascade(label="Datei", menu=d, underline=0) - b = tk.Menu(m, tearoff=0) + b = self.menueleiste.menue("Bearbeiten") b.add_command(label="Betrag ändern…", accelerator="F2", command=self.betrag_bearbeiten) b.add_command(label="Auszahlung in Prozent…", accelerator="F4", command=self.prozent) b.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen) b.add_separator() b.add_command(label="Alle Beträge neu berechnen", command=self.neu_berechnen) - m.add_cascade(label="Bearbeiten", menu=b, underline=0) - h = tk.Menu(m, tearoff=0) + h = self.menueleiste.menue("Händler") h.add_command(label="Händler verwalten…", accelerator="Strg+H", command=self.haendler) h.add_command(label="Kennung des Eintrags zuordnen…", command=self.kennung_zuordnen_auswahl) h.add_command(label="Auswahl fest einem Händler zuweisen…", command=self.haendler_zuweisen) h.add_command(label="Auswahl automatisch zuordnen", command=lambda: self.haendler_zuweisen(automatisch=True)) - m.add_cascade(label="Händler", menu=h, underline=0) - hi = tk.Menu(m, tearoff=0) + hi = self.menueleiste.menue("?", underline=None) hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe) + hi.add_command(label="Rundgang durch das Programm", command=self.rundgang) + hi.add_command(label="Tipp des Tages…", command=self.tipp_des_tages) + hi.add_separator() + hi.add_command(label="Einrichtungs-Assistent…", command=self.assistent) hi.add_separator() hi.add_command(label=f"Info über {APP_NAME}…", command=lambda: dlg.InfoDialog(self.root).zeigen()) - m.add_cascade(label="?", menu=hi) - self.root.configure(menu=m) def _toolbar(self): - tb = tk.Frame(self.root, bg=w.FACE, bd=1, relief="raised") + tb = tk.Frame(self.inhalt, bg=w.FACE, bd=1, relief="raised") + self.tb: dict[str, w.ToolbarButton] = {} tb.pack(side="top", fill="x") for text, icon, cmd, tip in ( ("Neu", "neu", self.neu, "Neue Abrechnung anlegen (Strg+N)"), @@ -144,7 +156,8 @@ class App: if text is None: w.trenner(tb).pack(side="left", fill="y", padx=4, pady=3) continue - w.ToolbarButton(tb, text, self.icons(icon), cmd, tip).pack(side="left", padx=1, pady=1) + self.tb[text] = w.ToolbarButton(tb, text, self.icons(icon), cmd, tip) + self.tb[text].pack(side="left", padx=1, pady=1) self.v_suche = tk.StringVar() sf = tk.Frame(tb, bg=w.FACE) sf.pack(side="right", padx=8) @@ -153,9 +166,10 @@ class App: e.pack(side="left", padx=4) self.v_suche.trace_add("write", lambda *_: self._tabelle()) self._suchfeld = e + self._suchrahmen = sf def _body(self): - desk = tk.Frame(self.root, bg=w.TEAL) + desk = tk.Frame(self.inhalt, bg=w.TEAL) desk.pack(side="top", fill="both", expand=True) pw = tk.PanedWindow(desk, orient="horizontal", bg=w.TEAL, sashwidth=6, bd=0) pw.pack(fill="both", expand=True, padx=6, pady=6) @@ -202,7 +216,8 @@ class App: leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE) leiste.pack(fill="x", pady=(4, 0)) - w.knopf(leiste, "Prozent…", self.prozent, self.icons("prozent")).pack(side="left") + self.b_prozent = w.knopf(leiste, "Prozent…", self.prozent, self.icons("prozent")) + self.b_prozent.pack(side="left") w.knopf(leiste, "Betrag ändern…", self.betrag_bearbeiten, self.icons("rechner")).pack(side="left", padx=4) w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left") self.v_alle_auf = tk.BooleanVar(value=True) @@ -235,12 +250,12 @@ class App: if self.aid and self.db.abrechnung(self.aid): self.positionen = self.db.positionen(self.aid) titel = self.db.abrechnung(self.aid)["titel"] - self.root.title(f"{APP_NAME} - [{titel}]") + self.rahmen.set_titel(f"{APP_NAME} - [{titel}]") self.status.set("info", f" Abrechnung: {titel}") else: self.aid = None self.positionen = [] - self.root.title(APP_NAME) + self.rahmen.set_titel(APP_NAME) self.status.set("info", " Keine Abrechnung geöffnet – Datei ▸ Neue Abrechnung oder Import") self._haendlerliste(behalte_auswahl) self._tabelle() @@ -746,22 +761,32 @@ class App: self.ansicht = ALLE self._aktualisieren() + def db_wechseln(self, pfad: str | None): + """Öffnet (oder legt an) eine andere Datenbank. pfad=None: Standard-Speicherort.""" + ziel = Path(pfad) if pfad else config.app_dir() / "provisionen.db" + db = Datenbank(ziel) # wirft bei Fehler – alte DB bleibt dann offen + self.db.close() + self.db = db + self.settings["db_pfad"] = str(ziel) if pfad else None + config.save(self.settings) + abr = self.db.abrechnungen() + self.aid = abr[0]["id"] if abr else None + self.ansicht = ALLE + self._aktualisieren() + def einstellungen(self): - neu = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen() - if neu and Path(neu) != self.db.path: + 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"] + config.save(self.settings) + w.info(self.root, "Einstellungen", "Die Änderung der Titelleiste wird beim nächsten Programmstart wirksam.") + if res["pfad"] and Path(res["pfad"]) != self.db.path: try: - db = Datenbank(neu) + self.db_wechseln(res["pfad"]) except Exception as exc: w.fehler(self.root, "Einstellungen", f"Datenbank konnte nicht geöffnet werden:\n{exc}") - return - self.db.close() - self.db = db - self.settings["db_pfad"] = neu - config.save(self.settings) - abr = self.db.abrechnungen() - self.aid = abr[0]["id"] if abr else None - self.ansicht = ALLE - self._aktualisieren() def hilfe(self): w.info(self.root, "Kurzanleitung", @@ -779,12 +804,65 @@ class App: "5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat.\n\n" "Alles wird automatisch in der Datenbank gespeichert.") - def _willkommen(self): - if not self.db.haendler_liste(): - if w.frage(self.root, "Willkommen", - f"Willkommen beim {APP_NAME}!\n\nEs sind noch keine Händler angelegt.\n" - "Jetzt die Unterhändler mit ihren Kennungen anlegen?"): - self.haendler() + def assistent(self): + res = ass.Einrichtungsassistent(self).zeigen() + self.settings["assistent_erledigt"] = True + config.save(self.settings) + self._aktualisieren() + if not res: + return + if res["titel"]: + self.aid = self.db.abrechnung_neu(res["titel"]) + self.ansicht = ALLE + self._aktualisieren() + if res["dateien"]: + self.importieren(res["dateien"]) + if res["rundgang"]: + self.root.after(200, self.rundgang) + + def rundgang(self): + tb = self.tb + schritte = [ + (self.rahmen.titelleiste or self.menueleiste, "Willkommen zum Rundgang!", + "In ein paar Schritten sehen Sie, wo Sie was finden.\n\nWeiter geht es mit „Weiter >“ oder der " + "Eingabetaste, beenden können Sie jederzeit mit Esc."), + (tb["Händler"], "Händler", + "Hier legen Sie Ihre Unterhändler an: Name, Auszahlung in % und woran ihre Positionen erkannt werden " + "(freenet-Unterhändlernummer, Vodafone-VO-Nr. oder Kundenname in GROSSBUCHSTABEN)."), + (tb["Neu"], "Neue Abrechnung", + "Für jeden Abrechnungszeitraum – meist ein Monat – legen Sie eine neue Abrechnung an. " + "Mit „Öffnen“ holen Sie frühere Abrechnungen wieder hervor."), + (tb["Import"], "Dateien importieren", + "Wählen Sie die CSV- und Excel-Dateien von freenet und Vodafone – gerne mehrere auf einmal. " + "Das Programm erkennt das Format selbst und warnt bei doppelt importierten Dateien."), + (self.f_h, "Händlerliste", + "Links sehen Sie jeden Händler mit Anzahl der Positionen und Auszahlungssumme.\n\n" + "„(Nicht zugeordnet)“ ist alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. " + "Das wird nicht exportiert."), + (self.f_p, "Positionen prüfen", + "Rechts stehen die Positionen des gewählten Händlers, gruppiert nach Provisionsart.\n\n" + "• Doppelklick auf eine Position: Betrag frei ändern\n" + "• Rechtsklick: Auszahlung 0 €, Händler zuweisen, …\n" + "• Blau = von Hand geändert, Rot = durch Prozent-Regel geändert"), + (self.b_prozent, "Auszahlung in Prozent", + "Graue Gruppenzeile (Provisionsart) markieren und hier klicken – z. B. 80 % für den Sonderbonus. " + "Mit „Als Standard merken“ gilt das auch in den nächsten Monaten automatisch."), + (tb["Export"], "Exportieren", + "Zum Schluss erstellt „Export“ für jeden Händler eine Excel-Datei im gewohnten Abrechnungsformat."), + (self.status, "Summen", + "Unten sehen Sie immer die Summe der angezeigten bzw. markierten Positionen: " + "Provision laut Anbieter und Ihre Auszahlung."), + (self._suchrahmen, "Suchen", + "Rufnummer, Name oder Tarif eingeben – die Liste filtert sofort. Tastenkürzel: Strg+F."), + (self.menueleiste.knoepfe["?"], "Das war's!", + "Diesen Rundgang, den Einrichtungs-Assistenten und die Tipps finden Sie jederzeit hier im Menü „?“.\n\n" + "Viel Erfolg mit dem Provisions-Manager 95!"), + ] + self._rundgang = ass.Rundgang(self, schritte) + + def tipp_des_tages(self): + ass.TippDesTages(self).zeigen() + config.save(self.settings) def _fehler_handler(self, exc, val, tb): text = "".join(traceback.format_exception(exc, val, tb)) @@ -799,7 +877,8 @@ class App: def beenden(self): try: - self.settings["fenster"] = self.root.geometry() + self.settings["fenster"] = self.rahmen.normale_geometrie() + self.settings["maximiert"] = self.rahmen.maximiert config.save(self.settings) self.db.close() finally: diff --git a/provtool/gui/assistent.py b/provtool/gui/assistent.py new file mode 100644 index 0000000..44f1c15 --- /dev/null +++ b/provtool/gui/assistent.py @@ -0,0 +1,569 @@ +"""Ersteinrichtung im Stil der Windows-95-Setup-Assistenten, Programm-Rundgang und „Tipp des Tages“.""" +from __future__ import annotations + +import re +import tkinter as tk +from collections import Counter, defaultdict +from pathlib import Path +from tkinter import filedialog, ttk + +from provtool import APP_NAME +from provtool.db import CAPS_ANBIETER, CAPS_WERT, Haendler, ist_grossgeschrieben +from provtool.gui import win95 as w +from provtool.importer import lies_datei +from provtool.money import fmt, parse_percent + +DATEITYPEN = [("Abrechnungen (CSV, Excel)", "*.csv *.xlsx *.xlsm *.xls *.txt"), ("Alle Dateien", "*.*")] + + +def _nummern(text: str) -> list[str]: + """'27518902 (6 Pos.), 27518999' → ['27518902', '27518999']""" + out = [] + for teil in text.split(","): + m = re.match(r"\s*([A-Za-z0-9]+)", teil) + if m: + out.append(m.group(1)) + return out + + +# =========================================================================== Assistent + +class Einrichtungsassistent(w.Dialog): + BREITE, HOEHE = 470, 340 + + def __init__(self, app): + super().__init__(app.root, "Provisions-Manager 95 – Einrichtung") + self.app = app + self.dateien: list[str] = [] + self.vorschlaege: dict[str, list[str]] = {"freenet": [], "vodafone": []} + self.caps_anzahl = 0 + + haupt = tk.Frame(self, bg=w.FACE) + haupt.pack(fill="both", expand=True, padx=10, pady=10) + bild = tk.Canvas(haupt, width=150, height=self.HOEHE, bg=w.TEAL, bd=2, relief="sunken", + highlightthickness=0) + bild.pack(side="left", fill="y") + self._bild(bild) + self.seite = tk.Frame(haupt, bg=w.FACE, width=self.BREITE, height=self.HOEHE) + self.seite.pack(side="left", fill="both", expand=True, padx=(14, 0)) + self.seite.pack_propagate(False) + + w.trenner(self, vertikal=False).pack(fill="x", padx=10) + k = tk.Frame(self, bg=w.FACE) + k.pack(fill="x", padx=10, pady=10) + self.b_abbr = w.knopf(k, "Abbrechen", self.abbrechen, breite=10) + self.b_abbr.pack(side="right") + self.b_weiter = w.knopf(k, "Weiter >", self._weiter, breite=10, default="active") + self.b_weiter.pack(side="right", padx=(0, 10)) + self.b_zurueck = w.knopf(k, "< Zurück", self._zurueck, breite=10) + self.b_zurueck.pack(side="right") + self.bind("", lambda e: self._weiter() if not isinstance(e.widget, tk.Entry) else None) + + self.v_titel = tk.StringVar(value=app._vorschlag_titel()) + self.v_abrechnung = tk.BooleanVar(value=True) + self.v_rundgang = tk.BooleanVar(value=True) + self.v_db_eigen = tk.BooleanVar(value=bool(app.settings.get("db_pfad"))) + self.v_db_pfad = tk.StringVar(value=str(app.db.path)) + + self.seiten = [self._s_willkommen, self._s_datenbank, self._s_haendler, self._s_abrechnung, self._s_fertig] + self.nr = 0 + self._zeige(0) + + # ---------------------------------------------------------------- Rahmen + def _bild(self, c: tk.Canvas): + self._img = w.make_icon(self, "app", 7) + c.create_rectangle(0, self.HOEHE - 70, 160, self.HOEHE + 10, fill="#006060", outline="") + for i, (x, y) in enumerate(((20, 30), (120, 55), (35, 120), (128, 150), (60, 20))): + c.create_rectangle(x, y, x + 2 + i % 2, y + 2 + i % 2, fill=w.WHITE, outline="") + c.create_image(77, self.HOEHE // 2 - 10, image=self._img) + c.create_text(77, self.HOEHE - 38, text="Provisions-\nManager 95", fill=w.WHITE, font=w.FONT_BOLD, + justify="center") + + def _leeren(self): + for kind in self.seite.winfo_children(): + kind.destroy() + + def _ueberschrift(self, text: str): + tk.Label(self.seite, text=text, font=w.FONT_BIG, anchor="w", justify="left", + wraplength=self.BREITE).pack(fill="x", pady=(0, 10)) + + def _text(self, text: str, master=None, **kw): + lbl = tk.Label(master or self.seite, text=text, anchor="w", justify="left", wraplength=self.BREITE, **kw) + lbl.pack(fill="x", pady=(0, 6)) + return lbl + + def _zeige(self, nr: int): + self.nr = nr + self._leeren() + self.seiten[nr]() + self.b_zurueck.configure(state="normal" if nr > 0 else "disabled") + self.b_weiter.configure(text="Fertig stellen" if nr == len(self.seiten) - 1 else "Weiter >") + if self.titelleiste: + self.titelleiste.set_titel(f"Provisions-Manager 95 – Einrichtung (Schritt {nr + 1} von {len(self.seiten)})") + + def _weiter(self): + pruefer = getattr(self, f"_pruefe_{self.nr}", None) + if pruefer and not pruefer(): + return + if self.nr == len(self.seiten) - 1: + self.result = dict(titel=self.v_titel.get().strip() if self.v_abrechnung.get() else "", + dateien=list(self.dateien) if self.v_abrechnung.get() else [], + rundgang=self.v_rundgang.get()) + self.destroy() + return + self._zeige(self.nr + 1) + + def _zurueck(self): + if self.nr > 0: + self._zeige(self.nr - 1) + + def abbrechen(self): + if w.frage(self, "Einrichtung abbrechen", + "Möchten Sie die Einrichtung wirklich abbrechen?\n\n" + "Bereits angelegte Händler bleiben gespeichert. Den Assistenten finden Sie jederzeit " + "unter ? ▸ Einrichtungs-Assistent.", default_ja=False): + self.result = None + self.destroy() + + # ---------------------------------------------------------------- Seite 1 + def _s_willkommen(self): + self._ueberschrift(f"Willkommen beim {APP_NAME}!") + self._text("Dieser Assistent richtet das Programm in wenigen Schritten ein:") + for punkt in ("Speicherort der Daten festlegen", + "Unterhändler mit ihren Kennungen anlegen", + "die erste Abrechnung anlegen und Dateien importieren"): + self._text(f" • {punkt}") + self._text("\nDanach zeigt Ihnen ein kurzer Rundgang, wo Sie was finden.\n\n" + "Alle Angaben lassen sich später jederzeit ändern.") + self._text("Klicken Sie auf „Weiter“, um fortzufahren.", fg=w.NAVY) + + # ---------------------------------------------------------------- Seite 2 + def _s_datenbank(self): + self._ueberschrift("Wo sollen die Daten gespeichert werden?") + self._text("In der Datenbank liegen Ihre Händler, die Auszahlungsregeln und alle Abrechnungen " + "mit Ihren Änderungen.") + f = tk.Frame(self.seite, bg=w.FACE) + f.pack(fill="x", pady=6) + from provtool import config + standard = str(config.app_dir() / "provisionen.db") + tk.Radiobutton(f, text="Auf diesem Computer (empfohlen)", variable=self.v_db_eigen, value=False, + command=lambda: self.v_db_pfad.set(standard)).pack(anchor="w") + tk.Label(f, text=standard, fg=w.SHADOW, anchor="w", wraplength=self.BREITE - 30, + justify="left").pack(anchor="w", padx=22) + tk.Radiobutton(f, text="An einem eigenen Ort, z. B. auf einem Netzlaufwerk:", variable=self.v_db_eigen, + value=True).pack(anchor="w", pady=(10, 0)) + zeile = tk.Frame(f, bg=w.FACE) + zeile.pack(fill="x", padx=22, pady=2) + e = tk.Entry(zeile, textvariable=self.v_db_pfad) + e.pack(side="left", fill="x", expand=True) + e.bind("", lambda ev: self.v_db_eigen.set(True)) + w.knopf(zeile, "Durchsuchen…", self._db_waehlen).pack(side="left", padx=(4, 0)) + self._text("\nTipp: Auf einem Netzlaufwerk können mehrere PCs dieselben Händler nutzen – " + "bitte aber nicht gleichzeitig damit arbeiten.", fg=w.SHADOW) + + def _db_waehlen(self): + p = filedialog.asksaveasfilename(parent=self, title="Speicherort der Datenbank", defaultextension=".db", + initialfile="provisionen.db", confirmoverwrite=False, + filetypes=[("Datenbank", "*.db"), ("Alle Dateien", "*.*")]) + if p: + self.v_db_pfad.set(p) + self.v_db_eigen.set(True) + + def _pruefe_1(self) -> bool: + from provtool import config + ziel = self.v_db_pfad.get().strip() if self.v_db_eigen.get() else str(config.app_dir() / "provisionen.db") + if not ziel: + w.warnung(self, "Speicherort", "Bitte einen Speicherort angeben.") + return False + if Path(ziel) != Path(self.app.db.path): + try: + self.app.db_wechseln(ziel if self.v_db_eigen.get() else None) + except Exception as exc: + w.fehler(self, "Speicherort", f"Die Datenbank konnte nicht angelegt werden:\n{exc}") + return False + return True + + # ---------------------------------------------------------------- Seite 3 + def _s_haendler(self): + db = self.app.db + tk.Label(self.seite, text="Unterhändler anlegen", font=w.FONT_BIG, anchor="w").pack(fill="x") + self._text("Für jeden Unterhändler: Name, Auszahlung und woran seine Positionen zu erkennen sind. " + "Ihre eigene Filiale legen Sie nicht an.", fg=w.SHADOW) + + g = tk.Frame(self.seite, bg=w.FACE) + g.pack(fill="x") + self.v_name, self.v_proz = tk.StringVar(), tk.StringVar(value="100") + self.v_fn, self.v_vf, self.v_caps = tk.StringVar(), tk.StringVar(), tk.BooleanVar() + tk.Label(g, text="Name / Firma:").grid(row=0, column=0, sticky="w", pady=1) + e_name = tk.Entry(g, textvariable=self.v_name) + e_name.grid(row=0, column=1, columnspan=2, sticky="we", pady=1) + tk.Label(g, text="Auszahlung:").grid(row=1, column=0, sticky="w", pady=1) + pf = tk.Frame(g, bg=w.FACE) + pf.grid(row=1, column=1, sticky="w") + tk.Entry(pf, textvariable=self.v_proz, width=6, justify="right").pack(side="left") + tk.Label(pf, text="% der Provision").pack(side="left", padx=4) + tk.Label(g, text="freenet Unterhändler-Nr.:").grid(row=2, column=0, sticky="w", pady=1) + self.cb_fn = ttk.Combobox(g, textvariable=self.v_fn, values=self.vorschlaege["freenet"]) + self.cb_fn.grid(row=2, column=1, columnspan=2, sticky="we", pady=1) + tk.Label(g, text="Vodafone VO-Nr.:").grid(row=3, column=0, sticky="w", pady=1) + self.cb_vf = ttk.Combobox(g, textvariable=self.v_vf, values=self.vorschlaege["vodafone"]) + self.cb_vf.grid(row=3, column=1, columnspan=2, sticky="we", pady=1) + caps_besitzer = db.kennung_map().get((CAPS_ANBIETER, CAPS_WERT)) + caps_text = "Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN gehören zu diesem Händler" + if self.caps_anzahl: + caps_text += f" ({self.caps_anzahl} Pos. in der Datei)" + cb = tk.Checkbutton(g, text=caps_text, variable=self.v_caps, wraplength=self.BREITE - 30, justify="left") + cb.grid(row=4, column=0, columnspan=3, sticky="w", pady=(2, 0)) + if caps_besitzer: + cb.configure(state="disabled", text=f"GROSSBUCHSTABEN-Regel: bereits bei „{db.haendler(caps_besitzer).name}“") + g.columnconfigure(1, weight=1) + + k = tk.Frame(self.seite, bg=w.FACE) + k.pack(fill="x", pady=(4, 4)) + w.knopf(k, "Händler hinzufügen", self._haendler_dazu, self.app.icons("haendler")).pack(side="left") + w.knopf(k, "Nummern aus Abrechnung vorschlagen…", self._vorschlagen).pack(side="left", padx=6) + + tk.Label(self.seite, text="Bereits angelegt:", anchor="w").pack(fill="x") + lf = tk.Frame(self.seite, bg=w.FACE) + lf.pack(fill="both", expand=True) + self.lb = tk.Listbox(lf, height=4, activestyle="none") + self.lb.pack(side="left", fill="both", expand=True) + w.knopf(lf, "Entfernen", self._haendler_weg).pack(side="left", anchor="n", padx=(6, 0)) + self._liste() + e_name.focus_set() + + def _liste(self): + self.lb.delete(0, "end") + self._lb_ids = [] + for h in self.app.db.haendler_liste(): + teile = [] + for anb, wert in self.app.db.kennungen(h.id): + teile.append("GROSSBUCHSTABEN" if anb == CAPS_ANBIETER else f"{anb} {wert}") + proz = f"{h.standard_prozent:g} %".replace(".", ",") + self.lb.insert("end", f"{h.name} – {proz} – {', '.join(teile) or 'keine Kennung'}") + self._lb_ids.append(h.id) + + def _haendler_dazu(self) -> bool: + name = self.v_name.get().strip() + if not name: + w.warnung(self, "Händler", "Bitte einen Namen eingeben.") + return False + try: + proz = parse_percent(self.v_proz.get()) + except ValueError: + w.warnung(self, "Händler", "Die Auszahlung muss eine Zahl sein, z. B. 100 oder 85.") + return False + kenn = [("freenet", n) for n in _nummern(self.v_fn.get())] + kenn += [("vodafone", n) for n in _nummern(self.v_vf.get())] + if self.v_caps.get(): + kenn.append((CAPS_ANBIETER, CAPS_WERT)) + if not kenn and not w.frage(self, "Händler", f"Für „{name}“ ist keine Kennung angegeben – ihm werden " + "daher keine Positionen zugeordnet.\n\nTrotzdem anlegen?"): + return False + try: + self.app.db.haendler_speichern(Haendler(0, name, standard_prozent=proz), kenn) + except Exception as exc: + msg = str(exc) + if "UNIQUE" in msg: + msg = f"Es gibt bereits einen Händler „{name}“." + w.fehler(self, "Händler", msg) + return False + for v in (self.v_name, self.v_fn, self.v_vf): + v.set("") + self.v_proz.set("100") + self._zeige(self.nr) # neu aufbauen (GROSSBUCHSTABEN-Regel evtl. vergeben) + return True + + def _haendler_weg(self): + sel = self.lb.curselection() + if sel: + self.app.db.haendler_loeschen(self._lb_ids[sel[0]]) + self._zeige(self.nr) + + def _vorschlagen(self): + dateien = filedialog.askopenfilenames(parent=self, title="Abrechnung zum Auslesen der Nummern wählen", + filetypes=DATEITYPEN) + if not dateien: + return + anzahl: dict[str, Counter] = defaultdict(Counter) + summe: dict[tuple[str, str], int] = defaultdict(int) + caps = 0 + fehler = [] + for f in dateien: + try: + erg = lies_datei(f) + except Exception as exc: + fehler.append(f"{Path(f).name}: {exc}") + continue + if f not in self.dateien: + self.dateien.append(f) + for p in erg.positionen: + anzahl[p.anbieter][p.kennung] += 1 + summe[(p.anbieter, p.kennung)] += p.betrag_cent + if p.anbieter == "vodafone" and ist_grossgeschrieben(p.name): + caps += 1 + vergeben = self.app.db.kennung_map() + for anb in ("freenet", "vodafone"): + self.vorschlaege[anb] = [f"{k} ({n} Pos., {fmt(summe[(anb, k)])})" + for k, n in anzahl[anb].most_common() if k and (anb, k) not in vergeben] + self.caps_anzahl = caps + self._zeige(self.nr) + if fehler: + w.warnung(self, "Nummern vorschlagen", "Nicht lesbar:\n" + "\n".join(fehler)) + else: + w.info(self, "Nummern vorschlagen", + "Die gefundenen Nummern stehen jetzt in den Auswahllisten (▼) bei freenet und Vodafone – " + "sortiert nach Anzahl der Positionen.\n\nDie Nummer mit den meisten Positionen ist meist " + "Ihre eigene Filiale.\n\nDie Dateien werden im nächsten Schritt gleich mit importiert.") + + def _pruefe_2(self) -> bool: + if self.v_name.get().strip(): + if w.frage(self, "Händler", f"„{self.v_name.get().strip()}“ wurde noch nicht hinzugefügt.\n\n" + "Jetzt hinzufügen?"): + return self._haendler_dazu() and False # Seite bleibt offen, Liste ist aktualisiert + if not self.app.db.haendler_liste(): + return w.frage(self, "Händler", "Es ist noch kein Händler angelegt.\n\n" + "Trotzdem weiter? (Händler lassen sich später unter „Händler“ anlegen.)", + default_ja=False) + return True + + # ---------------------------------------------------------------- Seite 4 + def _s_abrechnung(self): + self._ueberschrift("Die erste Abrechnung") + self._text("Pro Abrechnungszeitraum (meist ein Monat) legen Sie eine Abrechnung an und importieren " + "dort alle Dateien dieses Zeitraums – z. B. alle freenet-Wochenlisten und die Vodafone-Datei.") + tk.Checkbutton(self.seite, text="Jetzt eine Abrechnung anlegen", variable=self.v_abrechnung, + command=self._s_abrechnung_status).pack(anchor="w") + f = tk.Frame(self.seite, bg=w.FACE) + f.pack(fill="both", expand=True, padx=(22, 0)) + z = tk.Frame(f, bg=w.FACE) + z.pack(fill="x", pady=4) + tk.Label(z, text="Bezeichnung:").pack(side="left") + self._e_titel = tk.Entry(z, textvariable=self.v_titel, width=28) + self._e_titel.pack(side="left", padx=6) + tk.Label(f, text="Diese Dateien gleich importieren (optional):", anchor="w").pack(fill="x", pady=(6, 2)) + lf = tk.Frame(f, bg=w.FACE) + lf.pack(fill="both", expand=True) + self.lb_d = tk.Listbox(lf, height=5) + self.lb_d.pack(side="left", fill="both", expand=True) + kf = tk.Frame(lf, bg=w.FACE) + kf.pack(side="left", anchor="n", padx=(6, 0)) + self._b_dazu = w.knopf(kf, "Hinzufügen…", self._datei_dazu, breite=11) + self._b_dazu.pack(pady=(0, 4)) + self._b_weg = w.knopf(kf, "Entfernen", self._datei_weg, breite=11) + self._b_weg.pack() + for d in self.dateien: + self.lb_d.insert("end", Path(d).name) + self._s_abrechnung_status() + + def _s_abrechnung_status(self): + st = "normal" if self.v_abrechnung.get() else "disabled" + for wdg in (self._e_titel, self.lb_d, self._b_dazu, self._b_weg): + wdg.configure(state=st) + + def _datei_dazu(self): + for f in filedialog.askopenfilenames(parent=self, title="Dateien zum Import wählen", filetypes=DATEITYPEN): + if f not in self.dateien: + self.dateien.append(f) + self.lb_d.insert("end", Path(f).name) + + def _datei_weg(self): + sel = self.lb_d.curselection() + if sel: + del self.dateien[sel[0]] + self.lb_d.delete(sel[0]) + + def _pruefe_3(self) -> bool: + if self.v_abrechnung.get() and not self.v_titel.get().strip(): + w.warnung(self, "Abrechnung", "Bitte eine Bezeichnung eingeben, z. B. „Oktober 2026“.") + return False + return True + + # ---------------------------------------------------------------- Seite 5 + def _s_fertig(self): + self._ueberschrift("Fertig!") + n = len(self.app.db.haendler_liste()) + self._text(f"Der {APP_NAME} ist eingerichtet.") + self._text(f" • {n} Händler angelegt") + if self.v_abrechnung.get(): + self._text(f" • Abrechnung „{self.v_titel.get().strip()}“" + + (f" mit {len(self.dateien)} Datei(en)" if self.dateien else " (noch ohne Dateien)")) + self._text("\nSo geht es danach jeden Monat weiter:\n" + " Neu → Import → prüfen & anpassen → Export") + tk.Checkbutton(self.seite, text="Kurzen Rundgang durch das Programm starten", variable=self.v_rundgang + ).pack(anchor="w", pady=(12, 0)) + self._text("\nKlicken Sie auf „Fertig stellen“.", fg=w.NAVY) + + +# =========================================================================== Rundgang + +class Rundgang: + """Führt mit gelben Hinweis-Sprechblasen durch die Oberfläche und rahmt das jeweilige Element rot ein.""" + + def __init__(self, app, schritte: list[tuple]): + """schritte: (widget_oder_funktion, titel, text)""" + self.app, self.root, self.schritte = app, app.root, schritte + self.nr = 0 + self.blase: tk.Toplevel | None = None + self.rahmen: list[tk.Toplevel] = [] + self._zeige(0) + + def _weg(self): + for r in self.rahmen: + r.destroy() + self.rahmen = [] + if self.blase is not None: + try: + self.blase.grab_release() + except tk.TclError: + pass + self.blase.destroy() + self.blase = None + + def beenden(self): + self._weg() + + def _markieren(self, x, y, bw, bh, dicke=3, farbe="#ff0000"): + for gx, gy, gw, gh in ((x - dicke, y - dicke, bw + 2 * dicke, dicke), (x - dicke, y + bh, bw + 2 * dicke, dicke), + (x - dicke, y, dicke, bh), (x + bw, y, dicke, bh)): + t = tk.Toplevel(self.root, bg=farbe) + t.overrideredirect(True) + t.transient(self.root) + t.geometry(f"{max(1, gw)}x{max(1, gh)}+{gx}+{gy}") + self.rahmen.append(t) + + def _zeige(self, nr: int): + self._weg() + self.nr = nr + ziel, titel, text = self.schritte[nr] + if callable(ziel) and not isinstance(ziel, tk.Misc): + ziel = ziel() + self.root.update_idletasks() + x, y = ziel.winfo_rootx(), ziel.winfo_rooty() + bw, bh = ziel.winfo_width(), ziel.winfo_height() + self._markieren(x, y, bw, bh) + + b = self.blase = tk.Toplevel(self.root, bg=w.DARK) + b.withdraw() + b.overrideredirect(True) + b.transient(self.root) + innen = tk.Frame(b, bg=w.INFO_BG, padx=10, pady=8) + innen.pack(padx=1, pady=1) + kopf = tk.Frame(innen, bg=w.INFO_BG) + kopf.pack(fill="x") + self._ic = w.make_icon(b, "birne") + tk.Label(kopf, image=self._ic, bg=w.INFO_BG).pack(side="left") + tk.Label(kopf, text=titel, bg=w.INFO_BG, font=w.FONT_BOLD).pack(side="left", padx=6) + tk.Label(innen, text=text, bg=w.INFO_BG, justify="left", anchor="w", wraplength=330).pack(fill="x", pady=(6, 8)) + fuss = tk.Frame(innen, bg=w.INFO_BG) + fuss.pack(fill="x") + tk.Label(fuss, text=f"Schritt {nr + 1} von {len(self.schritte)}", bg=w.INFO_BG, + fg=w.SHADOW).pack(side="left") + letzter = nr == len(self.schritte) - 1 + w.knopf(fuss, "Fertig" if letzter else "Weiter >", self._weiter, default="active").pack(side="right") + zb = w.knopf(fuss, "< Zurück", self._zurueck) + zb.pack(side="right", padx=4) + if nr == 0: + zb.configure(state="disabled") + if not letzter: + w.knopf(fuss, "Beenden", self.beenden).pack(side="right", padx=(0, 8)) + b.bind("", lambda e: self._weiter()) + b.bind("", lambda e: self._weiter()) + b.bind("", lambda e: self._zurueck()) + b.bind("", lambda e: self.beenden()) + + # Position: unter dem Element, sonst darüber, sonst innen oben links; immer im Hauptfenster + b.update_idletasks() + pw, ph = b.winfo_reqwidth(), b.winfo_reqheight() + rx, ry = self.root.winfo_rootx(), self.root.winfo_rooty() + rw, rh = self.root.winfo_width(), self.root.winfo_height() + if y + bh + 12 + ph <= ry + rh: + px, py = x, y + bh + 12 + elif y - 12 - ph >= ry: + px, py = x, y - 12 - ph + else: + px, py = x + 40, y + 40 + px = min(max(rx + 8, px), rx + rw - pw - 8) + py = min(max(ry + 8, py), ry + rh - ph - 8) + b.geometry(f"{pw}x{ph}+{px}+{py}") + b.deiconify() + b.lift() + try: + b.grab_set() + except tk.TclError: + pass + b.focus_force() + + def _weiter(self): + if self.nr >= len(self.schritte) - 1: + self.beenden() + else: + self._zeige(self.nr + 1) + + def _zurueck(self): + if self.nr > 0: + self._zeige(self.nr - 1) + + +# =========================================================================== Tipp des Tages + +TIPPS = [ + "Sie können mehrere Dateien gleichzeitig importieren – zum Beispiel alle freenet-Wochenlisten eines Monats " + "und die Vodafone-Datei in einem Rutsch.", + "Markieren Sie eine ganze Provisionsart (die graue Zeile) und klicken Sie auf „Prozent…“, um z. B. nur 80 % " + "auszuzahlen. Mit dem Häkchen „Als Standard merken“ gilt das automatisch auch in den nächsten Monaten.", + "Doppelklick auf eine Position – und Sie können den Auszahlungsbetrag direkt überschreiben. Mit ↓ geht es " + "gleich in die nächste Zeile.", + "Blau dargestellte Beträge wurden von Hand geändert, rote weichen durch eine Prozent-Regel von der " + "Provision ab.", + "Eine Position soll gar nicht ausgezahlt werden? Rechtsklick ▸ „Auszahlung 0 €“.", + "Die GROSSBUCHSTABEN-Regel hat einen Kunden falsch erwischt? Rechtsklick ▸ „Händler zuweisen…“ – diese " + "Zuordnung hat immer Vorrang.", + "Unter „(Nicht zugeordnet)“ sehen Sie alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. " + "Diese Positionen werden nicht exportiert.", + "Mit Strg+F springen Sie ins Suchfeld. Gesucht wird in Rufnummer, Name, Tarif, Provisionsart und mehr.", + "Wurde eine Datei versehentlich doppelt oder falsch importiert? Datei ▸ Importierte Dateien… – dort lässt " + "sie sich wieder entfernen.", + "Erstellen Sie ab und zu eine Datensicherung: Datei ▸ Datensicherung erstellen…", + "Alle Änderungen werden sofort gespeichert. Ein „Speichern“-Knopf ist daher nicht nötig.", + "Ein Händler wurde erst nach dem Import angelegt? Kein Problem – die Zuordnung passt sich sofort an.", +] + + +class TippDesTages(w.Dialog): + def __init__(self, app): + super().__init__(app.root, "Tipp des Tages") + self.app = app + haupt = tk.Frame(self, bg=w.FACE) + haupt.pack(padx=12, pady=12) + box = tk.Frame(haupt, bg=w.WHITE, bd=2, relief="sunken") + box.pack(side="left", fill="both") + links = tk.Frame(box, bg=w.SHADOW, width=60) + links.pack(side="left", fill="y") + self._ic = w.make_icon(self, "birne", 3) + tk.Label(links, image=self._ic, bg=w.SHADOW).pack(padx=6, pady=10) + rechts = tk.Frame(box, bg=w.WHITE, padx=12, pady=10) + rechts.pack(side="left", fill="both") + tk.Label(rechts, text="Wussten Sie schon…", bg=w.WHITE, font=(w.FONT_FAMILY, w.FONT_SIZE + 6, "bold"), + anchor="w").pack(fill="x") + tk.Frame(rechts, bg=w.DARK, height=1).pack(fill="x", pady=6) + self.text = tk.Label(rechts, bg=w.WHITE, justify="left", anchor="nw", wraplength=330, width=48, height=6) + self.text.pack(fill="both") + knoepfe = tk.Frame(haupt, bg=w.FACE) + knoepfe.pack(side="left", anchor="n", padx=(10, 0)) + w.knopf(knoepfe, "Schließen", self.abbrechen, breite=12, default="active").pack(pady=(0, 6)) + w.knopf(knoepfe, "Nächster Tipp", self._naechster, breite=12).pack() + self.v_start = tk.BooleanVar(value=app.settings.get("tipps_beim_start", True)) + tk.Checkbutton(self, text="Tipps beim Start anzeigen", variable=self.v_start).pack(anchor="w", padx=12, + pady=(0, 10)) + self.bind("", lambda e: self.abbrechen()) + self._naechster() + + def _naechster(self): + nr = self.app.settings.get("tipp_nr", 0) % len(TIPPS) + self.text.configure(text=TIPPS[nr]) + self.app.settings["tipp_nr"] = nr + 1 + + def abbrechen(self): + self.app.settings["tipps_beim_start"] = self.v_start.get() + self.destroy() diff --git a/provtool/gui/dialogs.py b/provtool/gui/dialogs.py index 60dadfe..ef1b031 100644 --- a/provtool/gui/dialogs.py +++ b/provtool/gui/dialogs.py @@ -641,6 +641,12 @@ class EinstellungenDialog(w.Dialog): tk.Label(g, text="Tipp: Liegt die Datei auf einem Netzlaufwerk, können mehrere PCs dieselben\n" "Händler nutzen – aber bitte nicht gleichzeitig damit arbeiten.", 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") + 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)) def _waehlen(self): @@ -651,7 +657,7 @@ class EinstellungenDialog(w.Dialog): self.v.set(p) def _ok(self): - self.result = self.v.get().strip() + self.result = dict(pfad=self.v.get().strip(), klassisch=self.v_klassisch.get()) self.destroy() diff --git a/provtool/gui/rahmen.py b/provtool/gui/rahmen.py new file mode 100644 index 0000000..6d6a7cd --- /dev/null +++ b/provtool/gui/rahmen.py @@ -0,0 +1,372 @@ +"""Selbst gezeichneter Windows-95-Fensterrahmen. + +Windows erlaubt Tk nicht, die echte Titelleiste umzufärben. Deshalb wird sie ausgeblendet +(overrideredirect) und hier nachgebaut: blaue Titelleiste mit Icon und _ □ x, Verschieben, +Doppelklick = Maximieren, Größenänderung über den Rand und den Anfasser unten rechts, +graue Menüleiste. Unter Windows sorgen ein paar Win32-Aufrufe dafür, dass das Fenster +trotzdem in der Taskleiste erscheint, per Alt+Tab erreichbar ist und minimiert werden kann. +""" +from __future__ import annotations + +import sys +import tkinter as tk + +from provtool.gui import win95 as w + +IS_WIN = sys.platform.startswith("win") + +# --------------------------------------------------------------------------- Glyphen + +GLYPHEN = { + "min": """ + ........ + ........ + ........ + ........ + ........ + ........ + kkkkkk.. + kkkkkk..""", + "max": """ + kkkkkkkkk + kkkkkkkkk + k.......k + k.......k + k.......k + k.......k + k.......k + kkkkkkkkk""", + "restore": """ + ..kkkkkk + ..kkkkkk + ..k....k + kkkkkk.k + kkkkkk.k + k....kkk + k....k.. + kkkkkk..""", + "close": """ + kk....kk + .kk..kk. + ..kkkk.. + ...kk... + ..kkkk.. + .kk..kk. + kk....kk""", +} + + +def glyph(master, name: str) -> tk.PhotoImage: + rows = [r.strip() for r in GLYPHEN[name].strip("\n").splitlines() if r.strip()] + breite = max(len(r) for r in rows) + img = tk.PhotoImage(master=master, width=breite, height=len(rows)) + for y, row in enumerate(rows): + for x, ch in enumerate(row): + if ch != ".": + img.put(w.PALETTE[ch], (x, y)) + return img + + +def griff_bild(master, groesse: int = 12) -> tk.PhotoImage: + """Der typische schraffierte Größen-Anfasser unten rechts.""" + img = tk.PhotoImage(master=master, width=groesse, height=groesse) + for y in range(groesse): + for x in range(groesse): + d = x + y - (groesse - 1) + if d >= 0: + farbe = (w.HILITE, w.SHADOW, w.SHADOW, None)[d % 4] + if farbe: + img.put(farbe, (x, y)) + return img + + +# --------------------------------------------------------------------------- Win32-Hilfen + +def _hwnd(fenster: tk.Misc) -> int: + import ctypes + return ctypes.windll.user32.GetParent(fenster.winfo_id()) + + +def taskleiste_anzeigen(fenster: tk.Tk) -> None: + """Ein overrideredirect-Fenster erscheint unter Windows sonst nicht in Taskleiste/Alt+Tab.""" + if not IS_WIN: + return + try: + import ctypes + GWL_EXSTYLE, WS_EX_APPWINDOW, WS_EX_TOOLWINDOW = -20, 0x00040000, 0x00000080 + hwnd = _hwnd(fenster) + stil = ctypes.windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE) + stil = (stil & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW + ctypes.windll.user32.SetWindowLongW(hwnd, GWL_EXSTYLE, stil) + fenster.wm_withdraw() + fenster.after(10, lambda: (fenster.wm_deiconify(), fenster.focus_force())) + except Exception: + pass + + +def arbeitsbereich(fenster: tk.Misc) -> tuple[int, int, int, int]: + """Bildschirmbereich ohne Taskleiste (des Monitors, auf dem das Fenster steht): x, y, breite, höhe.""" + if IS_WIN: + try: + import ctypes + from ctypes import wintypes + + class MONITORINFO(ctypes.Structure): + _fields_ = [("cbSize", wintypes.DWORD), ("rcMonitor", wintypes.RECT), + ("rcWork", wintypes.RECT), ("dwFlags", wintypes.DWORD)] + + mon = ctypes.windll.user32.MonitorFromWindow(_hwnd(fenster), 2) # MONITOR_DEFAULTTONEAREST + mi = MONITORINFO() + mi.cbSize = ctypes.sizeof(MONITORINFO) + if ctypes.windll.user32.GetMonitorInfoW(mon, ctypes.byref(mi)): + r = mi.rcWork + return r.left, r.top, r.right - r.left, r.bottom - r.top + except Exception: + pass + return 0, 0, fenster.winfo_screenwidth(), fenster.winfo_screenheight() + + +# --------------------------------------------------------------------------- Bausteine + +class TitelKnopf(tk.Label): + def __init__(self, master, bild: tk.PhotoImage, command): + super().__init__(master, image=bild, bg=w.FACE, bd=2, relief="raised", width=14, height=12) + self.command = command + self.bind("", lambda e: self.configure(relief="sunken")) + self.bind("", self._los) + + def _los(self, e): + self.configure(relief="raised") + if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height(): + self.command() + + +class Titelleiste(tk.Frame): + """Blaue Win95-Titelleiste. knoepfe: Teilmenge von ("min", "max", "close").""" + + def __init__(self, master, fenster: tk.Misc, titel: str, icon: tk.PhotoImage | None, knoepfe=("close",), + schliessen=None, minimieren=None, maximieren=None): + super().__init__(master, bg=w.NAVY, height=20) + self.fenster = fenster + self._bilder = {n: glyph(self, n) for n in ("min", "max", "restore", "close")} + if icon is not None: + tk.Label(self, image=icon, bg=w.NAVY).pack(side="left", padx=(2, 0)) + self.titel_var = tk.StringVar(value=titel) + self.titel_lbl = tk.Label(self, textvariable=self.titel_var, bg=w.NAVY, fg=w.WHITE, font=w.FONT_BOLD, + anchor="w") + self.titel_lbl.pack(side="left", fill="x", expand=True, padx=3) + self.knoepfe: dict[str, TitelKnopf] = {} + if "close" in knoepfe: + self.knoepfe["close"] = TitelKnopf(self, self._bilder["close"], schliessen) + self.knoepfe["close"].pack(side="right", padx=(2, 2), pady=2) + if "max" in knoepfe: + self.knoepfe["max"] = TitelKnopf(self, self._bilder["max"], maximieren) + self.knoepfe["max"].pack(side="right", pady=2) + if "min" in knoepfe: + self.knoepfe["min"] = TitelKnopf(self, self._bilder["min"], minimieren) + self.knoepfe["min"].pack(side="right", pady=2) + self._maximieren = maximieren + for widget in (self, self.titel_lbl): + widget.bind("", self._start) + widget.bind("", self._ziehen) + if maximieren: + widget.bind("", lambda e: maximieren()) + self._ab = (0, 0) + self.ziehen_erlaubt = lambda: True + + def set_titel(self, t: str): + self.titel_var.set(t) + + def max_symbol(self, maximiert: bool): + if "max" in self.knoepfe: + self.knoepfe["max"].configure(image=self._bilder["restore" if maximiert else "max"]) + + def _start(self, e): + self._ab = (e.x_root - self.fenster.winfo_rootx(), e.y_root - self.fenster.winfo_rooty()) + + def _ziehen(self, e): + if not self.ziehen_erlaubt(): + return + self.fenster.geometry(f"+{e.x_root - self._ab[0]}+{e.y_root - self._ab[1]}") + + +class Menueleiste(tk.Frame): + """Graue Menüleiste (die native von Windows 10/11 ist weiß und lässt sich nicht umfärben).""" + + def __init__(self, master, root: tk.Misc): + super().__init__(master, bg=w.FACE) + self.root = root + self.knoepfe: dict[str, tk.Menubutton] = {} + + def menue(self, label: str, underline: int | None = 0) -> tk.Menu: + mb = tk.Menubutton(self, text=label, bg=w.FACE, relief="flat", bd=1, padx=6, pady=1, + activebackground=w.NAVY, activeforeground=w.WHITE, + underline=-1 if underline is None else underline) + menu = tk.Menu(mb, tearoff=0) + mb.configure(menu=menu) + mb.pack(side="left") + self.knoepfe[label] = mb + if underline is not None: + taste = label[underline].lower() + self.root.bind_all(f"", lambda e, m=mb: self.oeffnen(m)) + return menu + + def oeffnen(self, mb: tk.Menubutton): + menu = mb.nametowidget(mb.cget("menu")) + menu.tk_popup(mb.winfo_rootx(), mb.winfo_rooty() + mb.winfo_height()) + return "break" + + +# --------------------------------------------------------------------------- Hauptfenster + +class Hauptrahmen: + """Umgibt das Hauptfenster. Im klassischen Modus mit eigener Titelleiste, sonst nur ein Container.""" + + RAND = 5 # Pixel am Rand, an denen man die Größe ziehen kann + + def __init__(self, root: tk.Tk, titel: str, icon: tk.PhotoImage, schliessen, klassisch: bool = True, + minbreite: int = 900, minhoehe: int = 520): + self.root, self.klassisch = root, klassisch + self.minbreite, self.minhoehe = minbreite, minhoehe + self.maximiert = False + self._normal_geo: str | None = None + self._schliessen = schliessen + root.minsize(minbreite, minhoehe) + if not klassisch: + self.aussen = tk.Frame(root, bg=w.FACE) + self.aussen.pack(fill="both", expand=True) + self.inhalt = self.aussen + self.titelleiste = None + return + + root.overrideredirect(True) + self.aussen = tk.Frame(root, bg=w.FACE, bd=2, relief="raised", padx=2, pady=2) + self.aussen.pack(fill="both", expand=True) + self.titelleiste = Titelleiste(self.aussen, root, titel, icon, ("min", "max", "close"), + schliessen=schliessen, minimieren=self.minimieren, + maximieren=self.umschalten) + self.titelleiste.pack(fill="x") + self.titelleiste.ziehen_erlaubt = lambda: not self.maximiert + self.inhalt = tk.Frame(self.aussen, bg=w.FACE) + self.inhalt.pack(fill="both", expand=True) + + self._rand: str = "" + self.aussen.bind("", self._rand_cursor) + self.aussen.bind("", lambda e: self.aussen.configure(cursor="")) + self.aussen.bind("", self._resize_start) + self.aussen.bind("", self._resize) + root.bind_all("", lambda e: schliessen()) + root.after(50, lambda: taskleiste_anzeigen(root)) + + # ---- öffentliche Helfer + def set_titel(self, text: str): + self.root.title(text) + if self.titelleiste: + self.titelleiste.set_titel(text) + + def griff(self, master) -> tk.Label | None: + """Anfasser für die Statusleiste (nur im klassischen Modus).""" + if not self.klassisch: + return None + self._griff_bild = griff_bild(master) + g = tk.Label(master, image=self._griff_bild, bg=w.FACE, cursor="sizing" if not IS_WIN else "size_nw_se") + g.bind("", lambda e: self._resize_start(e, "se")) + g.bind("", self._resize) + return g + + def geometrie_setzen(self, geo: str | None, maximiert: bool = False): + self.root.update_idletasks() + if not geo or "+" not in geo: + breite, hoehe = 1200, 720 + if geo and "x" in geo: + try: + breite, hoehe = (int(v) for v in geo.split("+")[0].split("x")) + except ValueError: + pass + x0, y0, bw, bh = arbeitsbereich(self.root) + breite, hoehe = min(breite, bw), min(hoehe, bh) + geo = f"{breite}x{hoehe}+{x0 + (bw - breite) // 2}+{y0 + (bh - hoehe) // 2}" + self.root.geometry(geo) + if maximiert and self.klassisch: + self.root.after(100, self.maximieren) + + def normale_geometrie(self) -> str: + return self._normal_geo if self.maximiert and self._normal_geo else self.root.geometry() + + # ---- Fensterfunktionen + def minimieren(self): + # Windows minimiert keine overrideredirect-Fenster → kurz normalen Rahmen aktivieren + geo = self.root.geometry() + self.root.overrideredirect(False) + self.root.iconify() + + def wieder_da(e): + if e.widget is self.root and self.root.state() == "normal": + self.root.unbind("") + self.root.overrideredirect(True) + self.root.geometry(geo) + taskleiste_anzeigen(self.root) + self.root.bind("", wieder_da) + + def maximieren(self): + if self.maximiert: + return + self._normal_geo = self.root.geometry() + x, y, bw, bh = arbeitsbereich(self.root) + self.root.geometry(f"{bw}x{bh}+{x}+{y}") + self.maximiert = True + self.titelleiste.max_symbol(True) + + def wiederherstellen(self): + if not self.maximiert: + return + self.maximiert = False + if self._normal_geo: + self.root.geometry(self._normal_geo) + self.titelleiste.max_symbol(False) + + def umschalten(self): + self.wiederherstellen() if self.maximiert else self.maximieren() + + # ---- Größe ändern + def _welcher_rand(self, x, y) -> str: + bw, bh, r = self.aussen.winfo_width(), self.aussen.winfo_height(), self.RAND + rand = ("n" if y < r else "s" if y >= bh - r else "") + ("w" if x < r else "e" if x >= bw - r else "") + return rand + + CURSOR_WIN = {"n": "size_ns", "s": "size_ns", "e": "size_we", "w": "size_we", + "nw": "size_nw_se", "se": "size_nw_se", "ne": "size_ne_sw", "sw": "size_ne_sw"} + CURSOR_X11 = {"n": "top_side", "s": "bottom_side", "e": "right_side", "w": "left_side", + "nw": "top_left_corner", "se": "bottom_right_corner", "ne": "top_right_corner", + "sw": "bottom_left_corner"} + + def _rand_cursor(self, e): + if self.maximiert: + return + rand = self._welcher_rand(e.x, e.y) + try: + self.aussen.configure(cursor=(self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "")) + except tk.TclError: + pass + + def _resize_start(self, e, rand: str | None = None): + self._rand = rand or self._welcher_rand(e.x, e.y) + self._start = (e.x_root, e.y_root, self.root.winfo_x(), self.root.winfo_y(), + self.root.winfo_width(), self.root.winfo_height()) + + def _resize(self, e): + if not self._rand or self.maximiert: + return + sx, sy, x, y, bw, bh = self._start + dx, dy = e.x_root - sx, e.y_root - sy + if "e" in self._rand: + bw = max(self.minbreite, bw + dx) + if "s" in self._rand: + bh = max(self.minhoehe, bh + dy) + if "w" in self._rand: + neu = max(self.minbreite, bw - dx) + x, bw = x + (bw - neu), neu + if "n" in self._rand: + neu = max(self.minhoehe, bh - dy) + y, bh = y + (bh - neu), neu + self.root.geometry(f"{bw}x{bh}+{x}+{y}") diff --git a/provtool/gui/win95.py b/provtool/gui/win95.py index 73de00f..9f8ae00 100644 --- a/provtool/gui/win95.py +++ b/provtool/gui/win95.py @@ -218,6 +218,23 @@ ICONS = { ...kkwwwwwwkk... .....kkkkkkk.... ................""", + "birne": """ + .....kkkkk...... + ....kyyyyyk..... + ...kyywwyyyk.... + ..kyywwyyyyyk... + ..kywwyyyyyyk... + ..kyyyyyyyyyk... + ..kyyyyyyyyyk... + ...kyyyyyyyk.... + ....kyyyyyk..... + .....kyyyk...... + .....kdddk...... + .....kgggk...... + .....kdddk...... + .....kgggk...... + ......kkk....... + ................""", "app": """ ................ .....kkkkkkk.... @@ -480,6 +497,10 @@ def auto_breite(tree: ttk.Treeview, flex: dict[str, float], reserve: int = 4): # --------------------------------------------------------------------------- Dialoge +# Klassischer Win95-Rahmen auch für Dialoge (wird von der App gesetzt, abschaltbar in den Einstellungen) +KLASSISCH = False + + class Dialog(tk.Toplevel): """Modaler Dialog im Win95-Stil.""" @@ -496,6 +517,13 @@ class Dialog(tk.Toplevel): self.iconphoto(False, master.winfo_toplevel()._app_icon) # type: ignore[attr-defined] except Exception: pass + self.titelleiste = None + if KLASSISCH: + from provtool.gui.rahmen import Titelleiste + self.overrideredirect(True) + self.configure(bd=2, relief="raised", padx=2, pady=2) + self.titelleiste = Titelleiste(self, self, titel, None, ("close",), schliessen=self.abbrechen) + self.titelleiste.pack(side="top", fill="x") def zeigen(self, fokus=None): self.update_idletasks() @@ -503,15 +531,33 @@ class Dialog(tk.Toplevel): w, h = self.winfo_reqwidth(), self.winfo_reqheight() x = m.winfo_rootx() + max(0, (m.winfo_width() - w) // 2) y = m.winfo_rooty() + max(0, (m.winfo_height() - h) // 3) - self.geometry(f"+{x}+{y}") + self.geometry(f"+{max(0, x)}+{max(0, y)}") self.deiconify() self.lift() + vorher = self.grab_current() # z. B. der Assistent, wenn hier eine Rückfrage aufgeht try: self.grab_set() except tk.TclError: pass + bind_id = None + if KLASSISCH: + # Ein rahmenloser Dialog darf nicht hinter dem Hauptfenster verschwinden + bind_id = m.bind("", lambda e: self.winfo_exists() and self.lift(), add="+") + self.focus_force() (fokus or self).focus_set() self.wait_window() + if bind_id: + try: + m.unbind("", bind_id) + except tk.TclError: + pass + # Tk gibt beim Schließen die Sperre frei, stellt die vorige aber nicht wieder her + try: + if vorher is not None and vorher.winfo_exists(): + vorher.grab_set() + vorher.focus_force() + except tk.TclError: + pass return self.result def abbrechen(self):