From 807a38622cd178e50d6d93ac3659aeaeb554ac2f Mon Sep 17 00:00:00 2001 From: Handy-Insel Date: Mon, 2 Nov 2026 12:58:18 +0100 Subject: [PATCH] =?UTF-8?q?Eigene=20Kennungen=20ausblenden,=20Win95-Men?= =?UTF-8?q?=C3=BCs,=20verschiebbares=20Hauptfenster=20bei=20offenem=20Dial?= =?UTF-8?q?og=20u.=20a.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neu: - Eigene Kennungen (eigene Filiale) ausblenden: nicht angezeigt, nicht mitgerechnet, nicht exportiert; per Rechtsklick, Händler-Menü oder Import-Bericht; Händler-Zuordnung hat Vorrang - Menüs im Win95-Stil: wechseln beim Überfahren der Menüleiste, Tastatur, Kontextmenü gleich - Dialoge sperren nur den Inhalt: Hauptfenster bleibt verschiebbar/maximierbar; Dialoge haben eigenen Taskleisten-Eintrag; Klick auf gesperrten Inhalt holt den Dialog nach vorn (blinkt) - Trennlinie zwischen Händler- und Positionsliste sichtbar und ziehbar, Position wird gemerkt - Strg+Rücktaste / Strg+Entf löschen ganze Wörter in allen Eingabefeldern Behoben: - Gedrückte Knöpfe zeigten keine Beschriftung (activeForeground gehört in Tk zur Klasse Background) - Tipp des Tages: Text lief aus dem sichtbaren Bereich - Größenänderung: breiterer Griffbereich mit Ecken, Zeiger wird zuverlässig zurückgesetzt, flüssiger; Verschieben über Windows selbst (inkl. Andocken), Minimieren über Windows - Zeichen, die MS Sans Serif nicht hat (…, ▸, →, •), durch Win95-übliche ersetzt - Menü/Tooltip/Dialog: Größe+Position zusammen setzen (Tk ignoriert sonst die Position) - Suche reagiert erst nach kurzer Tipp-Pause (flüssigeres Tippen) Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +- provtool/__init__.py | 2 +- provtool/db.py | 29 +++- provtool/export.py | 2 +- provtool/gui/app.py | 173 +++++++++++++++------- provtool/gui/assistent.py | 42 +++--- provtool/gui/dialogs.py | 88 +++++++++-- provtool/gui/menue.py | 298 +++++++++++++++++++++++++++++++++++++ provtool/gui/modal.py | 93 ++++++++++++ provtool/gui/rahmen.py | 113 +++++++++----- provtool/gui/win32.py | 70 +++++++++ provtool/gui/win95.py | 167 +++++++++++++++------ provtool/selbsttest.py | 2 +- tests/test_gui.py | 300 +++++++++++++++++++++++++++++++++++--- tests/test_kern.py | 16 ++ 15 files changed, 1217 insertions(+), 187 deletions(-) create mode 100644 provtool/gui/menue.py create mode 100644 provtool/gui/modal.py diff --git a/README.md b/README.md index 3286648..dbb3df0 100644 --- a/README.md +++ b/README.md @@ -32,14 +32,19 @@ normale Windows-Titelleiste verwendet. eines Monats plus die Vodafone-Datei. Das Format wird an der Kopfzeile erkannt. Doppelt importierte Dateien werden erkannt. Unbekannte Kennungen erscheinen unter „(Nicht zugeordnet)“ und lassen sich direkt einem Händler zuordnen. -4. **Prüfen und anpassen**: links Händler wählen, rechts sind die Positionen nach +4. **Eigene Filiale ausblenden**: Positionen eurer eigenen Kennungen (z. B. freenet `27518900`, + Vodafone `80210760`) müssen nicht verprovisioniert werden. Rechtsklick auf eine nicht zugeordnete + Gruppe ▸ *Kennung als eigene ausblenden* (oder *Händler ▸ Eigene Kennungen…* bzw. direkt im + Import-Bericht). Sie verschwinden aus Liste, Summen und Export und stehen grau unter + „(Eigene – ausgeblendet)“. Händler-Zuordnungen (auch GROSSBUCHSTABEN) haben Vorrang. +5. **Prüfen und anpassen**: links Händler wählen, rechts sind die Positionen nach Provisionsart gruppiert. - Gruppe markieren → **Prozent…** (`F4`): Auszahlung für die ganze Provisionsart; auf Wunsch als feste Regel für diesen Händler gespeichert (gilt dann für künftige Importe). - Doppelklick / `F2` auf eine Position: Betrag frei überschreiben (`Enter` speichern, `↓`/`Tab` nächste Zeile, `Esc` abbrechen). Blau = von Hand geändert. - Rechtsklick: u. a. „Auszahlung 0 €“. -5. **Export** (`Strg+E`): je Händler (und je Anbieter) eine `.xlsx` im Format der Vorlage: +6. **Export** (`Strg+E`): je Händler (und je Anbieter) eine `.xlsx` im Format der Vorlage: | A | B | C | D | E | F | G | H | I | J | K | L | M | N | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| diff --git a/provtool/__init__.py b/provtool/__init__.py index abffec8..ffcb358 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.1.3" +__version__ = "1.2.0" APP_NAME = "Provisions-Manager 95" APP_ORG = "Handy-Insel Miesbach" diff --git a/provtool/db.py b/provtool/db.py index 7fddb3c..00459c9 100644 --- a/provtool/db.py +++ b/provtool/db.py @@ -45,6 +45,14 @@ CREATE TABLE IF NOT EXISTS regel ( UNIQUE (haendler_id, provisionsart) ); +-- Eigene Kennungen (eigene Filiale): diese Positionen werden ausgeblendet und nicht verprovisioniert. +CREATE TABLE IF NOT EXISTS eigen ( + id INTEGER PRIMARY KEY, + anbieter TEXT NOT NULL, + wert TEXT NOT NULL, + UNIQUE (anbieter, wert) +); + CREATE TABLE IF NOT EXISTS abrechnung ( id INTEGER PRIMARY KEY, titel TEXT NOT NULL, @@ -82,6 +90,7 @@ CREATE INDEX IF NOT EXISTS ix_position_abr ON position(abrechnung_id); """ UNZUGEORDNET = 0 # Pseudo-Händler-ID für Positionen ohne bekannte Kennung +EIGEN = -2 # Pseudo-Händler-ID für Positionen eigener Kennungen (ausgeblendet) # Sonder-Kennung: Vodafone-Positionen, deren Kundenname komplett in GROSSBUCHSTABEN steht # (so erfasst ein Unterhändler seine Kunden). Hat Vorrang vor der VO-Nr. @@ -206,15 +215,31 @@ class Datenbank: kmap = self.kennung_map() caps = kmap.get((CAPS_ANBIETER, CAPS_WERT)) aktive = {h.id for h in self.haendler_liste(nur_aktive=True)} + eigene = set(self.eigene_kennungen()) def finde(anbieter: str, kennung: str, name: str | None = None, fest: int | None = None) -> int: if fest is not None: return fest if (fest == UNZUGEORDNET or fest in aktive) else UNZUGEORDNET if caps and anbieter == "vodafone" and ist_grossgeschrieben(name): return caps - return kmap.get((anbieter, kennung), UNZUGEORDNET) + hid = kmap.get((anbieter, kennung), UNZUGEORDNET) + if hid == UNZUGEORDNET and (anbieter, kennung) in eigene: + return EIGEN + return hid return finde + # ------------------------------------------------------------------ eigene Kennungen + def eigene_kennungen(self) -> list[tuple[str, str]]: + return [(r[0], r[1]) for r in self.con.execute("SELECT anbieter, wert FROM eigen ORDER BY anbieter, wert")] + + def eigene_setzen(self, anbieter: str, wert: str, eigen: bool = True) -> None: + wert = (wert or "").strip() + with self.con: + if eigen: + self.con.execute("INSERT OR IGNORE INTO eigen (anbieter, wert) VALUES (?,?)", (anbieter, wert)) + else: + self.con.execute("DELETE FROM eigen WHERE anbieter=? AND wert=?", (anbieter, wert)) + # ------------------------------------------------------------------ Regeln def regeln(self, hid: int) -> dict[str, float]: return {r[0]: r[1] for r in self.con.execute( @@ -230,7 +255,7 @@ class Datenbank: (hid, provisionsart, prozent)) def prozent_fuer(self, hid: int, provisionsart: str) -> float: - if hid == UNZUGEORDNET: + if hid <= 0: # nicht zugeordnet / eigene Filiale return 100.0 r = self.con.execute("SELECT prozent FROM regel WHERE haendler_id=? AND provisionsart=?", (hid, provisionsart)).fetchone() diff --git a/provtool/export.py b/provtool/export.py index 110e465..4b44211 100644 --- a/provtool/export.py +++ b/provtool/export.py @@ -2,7 +2,7 @@ Spalten: A Zeitraum | B Provisionsart | C Unterhändler-/VO-Nr | D Datum | E Rufnummer | F IMEI/SN | G Gerät | H Vorname | I Name | J Tarif | K Betrag (Auszahlung) | L Netz | M Dienst | N Aktionscode -Sortierung: Zeitraum → Provisionsart → Datum (sonst Reihenfolge der Abrechnung). +Sortierung: Zeitraum -> Provisionsart -> Datum (sonst Reihenfolge der Abrechnung). """ from __future__ import annotations diff --git a/provtool/gui/app.py b/provtool/gui/app.py index 65291b0..6ec8912 100644 --- a/provtool/gui/app.py +++ b/provtool/gui/app.py @@ -14,9 +14,11 @@ from tkinter import filedialog from provtool import APP_NAME, config from provtool import export as exp -from provtool.db import UNZUGEORDNET, Datenbank +from provtool.db import EIGEN, UNZUGEORDNET, Datenbank from provtool.gui import assistent as ass from provtool.gui import dialogs as dlg +from provtool.gui import menue as mn +from provtool.gui import modal from provtool.gui import rahmen as rh from provtool.gui import win95 as w from provtool.importer import ANBIETER, ImportFehler, lies_datei @@ -35,7 +37,7 @@ def _datum_de(iso: str | None) -> str: def _zeitraum_kurz(z: str | None) -> str: - """'2026.10.01-2026.10.07' → '01.10.–07.10.'""" + """'2026.10.01-2026.10.07' -> '01.10.–07.10.'""" m = re.fullmatch(r"(\d{4})\.(\d{2})\.(\d{2})\s*-\s*(\d{4})\.(\d{2})\.(\d{2})", (z or "").strip()) if m: return f"{m[3]}.{m[2]}.–{m[6]}.{m[5]}." @@ -89,6 +91,8 @@ class App: self.status.pack(side="bottom", fill="x") self._tasten() self.rahmen.geometrie_setzen(self.settings.get("fenster"), self.settings.get("maximiert", False)) + if self.settings.get("trenner"): + root.after(200, lambda: self.pw.sash_place(0, int(self.settings["trenner"]), 0)) abr = self.db.abrechnungen() if abr: @@ -102,43 +106,46 @@ class App: # ================================================================= Aufbau def _menue(self): - self.menueleiste = rh.Menueleiste(self.inhalt, self.root) + self.menueleiste = mn.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_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() - 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="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_separator() - d.add_command(label="Datensicherung erstellen…", command=self.sicherung) - d.add_command(label="Datensicherung einspielen…", command=self.wiederherstellen) - d.add_command(label="Einstellungen…", command=self.einstellungen) + d.add_command(label="Datensicherung erstellen...", command=self.sicherung) + d.add_command(label="Datensicherung einspielen...", command=self.wiederherstellen) + d.add_command(label="Einstellungen...", command=self.einstellungen) d.add_separator() d.add_command(label="Beenden", accelerator="Alt+F4", command=self.beenden) 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="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) 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="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)) + h.add_separator() + h.add_command(label="Eigene Kennungen (ausblenden)...", command=self.eigene_verwalten) + h.add_command(label="Kennung der Auswahl als eigene ausblenden", command=self.eigene_ausblenden) 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_command(label="Tipp des Tages...", command=self.tipp_des_tages) hi.add_separator() - hi.add_command(label="Einrichtungs-Assistent…", command=self.assistent) + 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()) + 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") @@ -166,14 +173,17 @@ class App: tk.Label(sf, text="Suchen:").pack(side="left") e = tk.Entry(sf, textvariable=self.v_suche, width=22) e.pack(side="left", padx=4) - self.v_suche.trace_add("write", lambda *_: self._tabelle()) + self._suche_geplant = None + self.v_suche.trace_add("write", lambda *_: self._suche_planen()) self._suchfeld = e self._suchrahmen = sf def _body(self): 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) + # Trennlinie zwischen den Spalten: grau und erhaben wie ein Win95-Splitter, mit der Maus ziehbar + pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.FACE, sashwidth=7, sashrelief="raised", + bd=0, opaqueresize=True, sashcursor="sb_h_double_arrow") pw.pack(fill="both", expand=True, padx=6, pady=6) # --- Händler-Fenster @@ -218,9 +228,9 @@ class App: leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE) leiste.pack(fill="x", pady=(4, 0)) - self.b_prozent = w.knopf(leiste, "Prozent…", self.prozent, self.icons("prozent")) + 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, "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) w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right") @@ -231,13 +241,18 @@ class App: def _tasten(self): r = self.root - r.bind_all("", lambda e: self.neu()) - r.bind_all("", lambda e: self.abrechnung_waehlen()) - r.bind_all("", lambda e: self.importieren()) - r.bind_all("", lambda e: self.exportieren()) - r.bind_all("", lambda e: self.haendler()) - r.bind_all("", lambda e: self._suchfeld.focus_set()) - r.bind_all("", lambda e: self.hilfe()) + + def frei(befehl): + # Tastenkürzel des Hauptfensters nicht ausführen, solange ein Dialog offen ist + # (sonst öffnete z. B. Strg+N im Dialog einen zweiten Dialog darüber) + return lambda e: None if modal.offen() else befehl() + r.bind_all("", frei(self.neu)) + r.bind_all("", frei(self.abrechnung_waehlen)) + r.bind_all("", frei(self.importieren)) + r.bind_all("", frei(self.exportieren)) + r.bind_all("", frei(self.haendler)) + r.bind_all("", frei(self._suchfeld.focus_set)) + r.bind_all("", frei(self.hilfe)) self.ptree.bind("", lambda e: self.betrag_bearbeiten()) self.ptree.bind("", lambda e: self.betrag_bearbeiten()) self.ptree.bind("", lambda e: self.prozent()) @@ -258,13 +273,23 @@ class App: self.aid = None self.positionen = [] self.rahmen.set_titel(APP_NAME) - self.status.set("info", " Keine Abrechnung geöffnet – Datei ▸ Neue Abrechnung oder Import") + self.status.set("info", " Keine Abrechnung geöffnet – Datei > Neue Abrechnung oder Import") self._haendlerliste(behalte_auswahl) self._tabelle() + def _suche_planen(self): + """Suche erst kurz nach dem letzten Tastendruck ausführen (flüssiges Tippen).""" + if self._suche_geplant is not None: + self.root.after_cancel(self._suche_geplant) + self._suche_geplant = self.root.after(150, self._suche_ausfuehren) + + def _suche_ausfuehren(self): + self._suche_geplant = None + self._tabelle() + def _gefiltert(self) -> list[dict]: if self.ansicht == ALLE: - return self.positionen + return [p for p in self.positionen if p["haendler_id"] != EIGEN] return [p for p in self.positionen if p["haendler_id"] == self.ansicht] def _haendlerliste(self, behalte_auswahl=True): @@ -276,7 +301,7 @@ class App: s[0] += 1 s[1] += p["betrag"] s[2] += p["betrag_orig"] - ges = [sum(s[0] for s in stat.values()), sum(s[1] for s in stat.values())] + ges = [sum(s[0] for k, s in stat.items() if k != EIGEN), sum(s[1] for k, s in stat.items() if k != EIGEN)] t.insert("", "end", iid=str(ALLE), text="(Alle Positionen)", values=(ges[0], fmt(ges[1])), tags=("fett",)) for h in self.db.haendler_liste(): s = stat.get(h.id, [0, 0, 0]) @@ -286,6 +311,10 @@ class App: if s: t.insert("", "end", iid=str(UNZUGEORDNET), text="(Nicht zugeordnet)", values=(s[0], fmt(s[1])), tags=("grau",)) + s = stat.get(EIGEN) + if s: + t.insert("", "end", iid=str(EIGEN), text="(Eigene – ausgeblendet)", values=(s[0], fmt(s[2])), + tags=("grau",)) ziel = str(self.ansicht) if behalte_auswahl and t.exists(str(self.ansicht)) else str(ALLE) self.ansicht = int(ziel) t.selection_set(ziel) @@ -302,9 +331,11 @@ class App: self.haendler(self.ansicht) elif self.ansicht == UNZUGEORDNET: self.kennung_zuordnen_auswahl() + elif self.ansicht == EIGEN: + self.eigene_verwalten() def _gruppe_von(self, p: dict) -> str: - if self.ansicht == UNZUGEORDNET: + if self.ansicht in (UNZUGEORDNET, EIGEN): return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · Kennung {p['kennung'] or '(leer)'}" if self.ansicht == ALLE: name = self.haendler_namen.get(p["haendler_id"], "(Nicht zugeordnet)") @@ -351,7 +382,8 @@ class App: fmt(p["betrag_orig"]), "manuell" if p["manuell"] else fmt_percent(p["prozent"]), fmt(p["betrag"])), tags=tuple(tags)) - name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet"}.get( + name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet", + EIGEN: "Eigene Kennungen (ausgeblendet, werden nicht exportiert)"}.get( self.ansicht, self.haendler_namen.get(self.ansicht, "")) self.f_p.set_titel(f"Positionen – {name}") so = sum(p["betrag_orig"] for p in pos) @@ -464,8 +496,10 @@ class App: u[0] += 1 u[1] += p["betrag_orig"] res = dlg.ImportBericht(self.root, bericht, [(a, k, n, s) for (a, k), (n, s) in sorted(unbekannt.items())]).zeigen() - if res: - self.kennung_zuordnen(*res) + if res and res[0] == "zuordnen": + self.kennung_zuordnen(res[1], res[2]) + elif res and res[0] == "eigen": + self.eigene_ausblenden(res[1]) def dateien(self): if not self.aid: @@ -599,7 +633,7 @@ class App: pid = int(iid[1:]) p = next(x for x in self.positionen if x["id"] == pid) if not bbox: - # Zelle gerade nicht sichtbar (z. B. Spalte zu schmal) → kleiner Dialog statt Inline-Feld + # Zelle gerade nicht sichtbar (z. B. Spalte zu schmal) -> kleiner Dialog statt Inline-Feld self._betrag_dialog(p) return x, y, bw, bh = bbox @@ -675,20 +709,56 @@ class App: return if iid not in self.ptree.selection(): self.ptree.selection_set(iid) - m = tk.Menu(self.root, tearoff=0) + m = mn.Popup(self.root) if iid.startswith("p"): - m.add_command(label="Betrag ändern…", command=lambda: self.betrag_bearbeiten(iid)) - m.add_command(label="Auszahlung in Prozent…", command=self.prozent) + m.add_command(label="Betrag ändern...", command=lambda: self.betrag_bearbeiten(iid)) + m.add_command(label="Auszahlung in Prozent...", command=self.prozent) m.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen) m.add_separator() m.add_command(label="Auszahlung 0 € (nicht auszahlen)", command=lambda: self._null()) m.add_separator() - m.add_command(label="Händler zuweisen…", command=self.haendler_zuweisen) + m.add_command(label="Händler zuweisen...", command=self.haendler_zuweisen) m.add_command(label="Automatisch zuordnen", command=lambda: self.haendler_zuweisen(automatisch=True)) if self.ansicht == UNZUGEORDNET or self.ansicht == ALLE: - m.add_command(label="Kennung einem Händler zuordnen…", command=self.kennung_zuordnen_auswahl) + m.add_command(label="Kennung einem Händler zuordnen...", command=self.kennung_zuordnen_auswahl) + _, _, ps = self._auswahl_info() + if any(p["haendler_id"] == UNZUGEORDNET for p in ps): + m.add_separator() + m.add_command(label="Kennung als eigene ausblenden", command=self.eigene_ausblenden) + if any(p["haendler_id"] == EIGEN for p in ps): + m.add_separator() + m.add_command(label="Kennung wieder einblenden", command=self.eigene_einblenden) m.tk_popup(e.x_root, e.y_root) + # ----- Eigene Kennungen (eigene Filiale) ausblenden + def _kennungen_der_auswahl(self, nur: int) -> list[tuple[str, str]]: + _, _, ps = self._auswahl_info() + return sorted({(p["anbieter"], p["kennung"]) for p in ps if p["haendler_id"] == nur}) + + def eigene_ausblenden(self, kennungen: list[tuple[str, str]] | None = None): + kennungen = kennungen if kennungen is not None else self._kennungen_der_auswahl(UNZUGEORDNET) + if not kennungen: + w.info(self.root, "Eigene Kennung", "Bitte zuerst nicht zugeordnete Positionen auswählen.") + return + liste = "\n".join(f" {ANBIETER.get(a, a)}: {k or '(leer)'}" for a, k in kennungen) + if not w.frage(self.root, "Eigene Kennung ausblenden", + f"Diese Kennung(en) gehören zur eigenen Filiale und werden künftig ausgeblendet " + f"(nicht angezeigt, nicht mitgerechnet, nicht exportiert):\n\n{liste}\n\n" + "Rückgängig jederzeit unter Händler > Eigene Kennungen."): + return + for a, k in kennungen: + self.db.eigene_setzen(a, k, True) + self._aktualisieren() + + def eigene_einblenden(self): + for a, k in self._kennungen_der_auswahl(EIGEN): + self.db.eigene_setzen(a, k, False) + self._aktualisieren() + + def eigene_verwalten(self): + if dlg.EigeneKennungenDialog(self.root, self.db).zeigen(): + self._aktualisieren() + def haendler_zuweisen(self, automatisch: bool = False): sel, pids, _ = self._auswahl_info() if not pids: @@ -746,7 +816,7 @@ class App: "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 "")) + + "\n".join(p.name for p in erzeugt[:15]) + ("\n..." if len(erzeugt) > 15 else "")) if res["oeffnen"]: _oeffne_ordner(res["ordner"]) @@ -815,15 +885,15 @@ class App: w.info(self.root, "Kurzanleitung", "1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in % und die Kennungen –\n" " bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr. oder die Regel\n" - " „Name in GROSSBUCHSTABEN“. Einzelne Positionen: Rechtsklick ▸ Händler zuweisen.\n\n" + " „Name in GROSSBUCHSTABEN“. Einzelne Positionen: Rechtsklick > Händler zuweisen.\n\n" "2. Neue Abrechnung (Strg+N), z. B. „Oktober 2026“.\n\n" "3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n" " auch alle Wochenlisten eines Monats.\n\n" "4. Links den Händler wählen. Positionen sind nach Provisionsart gruppiert.\n" - " • Gruppe wählen + „Prozent…“ (F4): Auszahlung für die ganze Provisionsart,\n" + " - Gruppe wählen + „Prozent...“ (F4): Auszahlung für die ganze Provisionsart,\n" " optional als Standard für diesen Händler merken.\n" - " • Doppelklick / F2 auf eine Position: Betrag frei ändern\n" - " (Enter = speichern, ↓/Tab = nächste Zeile, Esc = abbrechen).\n\n" + " - Doppelklick / F2 auf eine Position: Betrag frei ändern\n" + " (Enter = speichern, Pfeil runter/Tab = nächste Zeile, Esc = abbrechen).\n\n" "5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat.\n\n" "Alles wird automatisch in der Datenbank gespeichert.") @@ -868,9 +938,9 @@ class App: "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"), + "- 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."), @@ -955,6 +1025,7 @@ class App: return self._beendet = True try: + self.settings["trenner"] = self.pw.sash_coord(0)[0] self.settings["fenster"] = self.rahmen.normale_geometrie() self.settings["maximiert"] = self.rahmen.maximiert config.save(self.settings) diff --git a/provtool/gui/assistent.py b/provtool/gui/assistent.py index 8eba487..89f04dd 100644 --- a/provtool/gui/assistent.py +++ b/provtool/gui/assistent.py @@ -18,7 +18,7 @@ DATEITYPEN = [("Abrechnungen (CSV, Excel)", "*.csv *.xlsx *.xlsm *.xls *.txt"), def _nummern(text: str) -> list[str]: - """'27518902 (6 Pos.), 27518999' → ['27518902', '27518999']""" + """'27518902 (6 Pos.), 27518999' -> ['27518902', '27518999']""" out = [] for teil in text.split(","): m = re.match(r"\s*([A-Za-z0-9]+)", teil) @@ -122,7 +122,7 @@ class Einrichtungsassistent(w.Dialog): 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): + "unter ? > Einrichtungs-Assistent.", default_ja=False): self.result = None self.destroy() @@ -133,7 +133,7 @@ class Einrichtungsassistent(w.Dialog): 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(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) @@ -158,7 +158,7 @@ class Einrichtungsassistent(w.Dialog): 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)) + 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) @@ -222,7 +222,7 @@ class Einrichtungsassistent(w.Dialog): 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) + 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) @@ -313,7 +313,7 @@ class Einrichtungsassistent(w.Dialog): 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 – " + "Die gefundenen Nummern stehen jetzt in den Auswahllisten (Pfeil-Knopf rechts) 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.") @@ -349,7 +349,7 @@ class Einrichtungsassistent(w.Dialog): 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 = 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() @@ -385,12 +385,12 @@ class Einrichtungsassistent(w.Dialog): 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") + self._text(f" - {n} Händler angelegt") if self.v_abrechnung.get(): - self._text(f" • Abrechnung „{self.v_titel.get().strip()}“" + 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") + " 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) @@ -539,21 +539,21 @@ class Rundgang: 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 % " + "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 " + "Doppelklick auf eine Position – und Sie können den Auszahlungsbetrag direkt überschreiben. Mit Pfeil runter 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 " + "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 " + "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…", + "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.", ] @@ -583,13 +583,15 @@ class TippDesTages(w.Dialog): if self._bild is None: # Ersatz, falls das Bild fehlt self._bild = w.make_icon(self, "birne", 3) tk.Label(box, image=self._bild, bg=w.WHITE).pack(side="left", anchor="n", padx=(12, 4), pady=10) - rechts = tk.Frame(box, bg=w.WHITE, padx=12, pady=10) + # fester Textbereich: der Tipp bricht darin um und kann nicht mehr abgeschnitten werden + rechts = tk.Frame(box, bg=w.WHITE, padx=12, pady=10, width=380, height=self._bild.height() + 20) 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"), + rechts.pack_propagate(False) + 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=8) - self.text.pack(fill="both") + self.text = tk.Label(rechts, bg=w.WHITE, justify="left", anchor="nw", wraplength=380 - 2 * 12) + self.text.pack(fill="both", expand=True) 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)) diff --git a/provtool/gui/dialogs.py b/provtool/gui/dialogs.py index ef1b031..6e35311 100644 --- a/provtool/gui/dialogs.py +++ b/provtool/gui/dialogs.py @@ -9,7 +9,7 @@ from provtool.db import CAPS_ANBIETER, CAPS_WERT, Datenbank, Haendler from provtool.gui import win95 as w from provtool.importer import ANBIETER -# Auswahl im Händler-Dialog: Schlüssel → Anzeige +# Auswahl im Händler-Dialog: Schlüssel -> Anzeige KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)", CAPS_ANBIETER: "Vodafone (Name in GROSSBUCHSTABEN)"} CAPS_TEXT = "Kunden-Nachname komplett in GROSSBUCHSTABEN" @@ -100,7 +100,7 @@ class HaendlerDialog(w.Dialog): r3.pack(fill="both", expand=True) rf = tk.Frame(g3, bg=w.FACE) rf.pack(fill="x", pady=(4, 0)) - tk.Label(rf, text="Regeln entstehen im Hauptfenster über „Prozent…“.", fg=w.SHADOW).pack(side="left") + tk.Label(rf, text="Regeln entstehen im Hauptfenster über „Prozent...“.", fg=w.SHADOW).pack(side="left") w.knopf(rf, "Entfernen", self._regel_weg).pack(side="right") unten = tk.Frame(self, bg=w.FACE) @@ -292,7 +292,7 @@ class ProzentDialog(w.Dialog): from provtool.money import apply_percent try: p = parse_percent(self.v.get()) - self.vorschau.configure(text=f"→ Auszahlung ca. {fmt(apply_percent(self.summe_orig, p))}") + self.vorschau.configure(text=f"-> Auszahlung ca. {fmt(apply_percent(self.summe_orig, p))}") except ValueError: self.vorschau.configure(text="(ungültiger Wert)") @@ -327,7 +327,7 @@ class AbrechnungenDialog(w.Dialog): k = tk.Frame(self, bg=w.FACE) k.pack(fill="x", padx=10, pady=10) w.knopf(k, "Öffnen", self._oeffnen, breite=10, default="active").pack(side="left") - w.knopf(k, "Umbenennen…", self._umbenennen, breite=12).pack(side="left", padx=6) + w.knopf(k, "Umbenennen...", self._umbenennen, breite=12).pack(side="left", padx=6) w.knopf(k, "Löschen", self._loeschen, breite=10).pack(side="left") w.knopf(k, "Abbrechen", self.abbrechen, breite=10).pack(side="right") self.bind("", lambda e: self._oeffnen()) @@ -440,7 +440,7 @@ class ImportBericht(w.Dialog): tk.Label(self, text="Nicht zugeordnete Kennungen (kein Händler hinterlegt – z. B. eigene Filiale):", anchor="w").pack(fill="x", padx=10, pady=(10, 2)) r2, self.ut = w.scroll_tree(self, columns=("anbieter", "kennung", "anzahl", "summe"), show="headings", - height=min(6, len(unbekannt)), selectmode="browse") + height=min(6, len(unbekannt)), selectmode="extended") for c, h, b, a in (("anbieter", "Anbieter", 90, "w"), ("kennung", "Kennung", 140, "w"), ("anzahl", "Pos.", 50, "e"), ("summe", "Summe", 90, "e")): self.ut.heading(c, text=h) @@ -451,16 +451,82 @@ class ImportBericht(w.Dialog): k = tk.Frame(self, bg=w.FACE) k.pack(fill="x", padx=10, pady=10) if unbekannt: - w.knopf(k, "Kennung einem Händler zuordnen…", self._zuordnen).pack(side="left") + w.knopf(k, "Kennung einem Händler zuordnen...", self._zuordnen).pack(side="left") + w.knopf(k, "Als eigene ausblenden...", self._eigen).pack(side="left", padx=6) w.knopf(k, "OK", self.abbrechen, breite=10, default="active").pack(side="right") self.bind("", lambda e: self.abbrechen()) def _zuordnen(self): s = self.ut.selection() if s: - self.result = tuple(s[0].split("\t", 1)) + a, k = s[0].split("\t", 1) + self.result = ("zuordnen", a, k) self.destroy() + def _eigen(self): + s = self.ut.selection() + if s: + self.result = ("eigen", [tuple(x.split("\t", 1)) for x in s]) + self.destroy() + + +class EigeneKennungenDialog(w.Dialog): + """Kennungen der eigenen Filiale: Positionen damit werden ausgeblendet und nicht exportiert.""" + + def __init__(self, master, db: Datenbank): + super().__init__(master, "Eigene Kennungen") + self.db, self.geaendert = db, False + tk.Label(self, text="Positionen mit diesen Kennungen gehören zur eigenen Filiale. Sie werden\n" + "ausgeblendet, nicht mitgerechnet und nicht exportiert.\n" + "(Ist eine Kennung einem Händler zugeordnet, hat der Händler Vorrang.)", + justify="left", anchor="w").pack(fill="x", padx=10, pady=(10, 4)) + r, self.tree = w.scroll_tree(self, columns=("anbieter", "wert"), show="headings", height=7, + selectmode="extended") + self.tree.heading("anbieter", text="Anbieter") + self.tree.heading("wert", text="Unterhändler-Nr. / VO-Nr.") + self.tree.column("anbieter", width=100) + self.tree.column("wert", width=200) + r.pack(fill="both", expand=True, padx=10) + z = tk.Frame(self, bg=w.FACE) + z.pack(fill="x", padx=10, pady=6) + self.v_anb = tk.StringVar(value="freenet") + ttk.Combobox(z, textvariable=self.v_anb, values=list(ANBIETER.values()), width=10, + state="readonly").pack(side="left") + self.v_wert = tk.StringVar() + e = tk.Entry(z, textvariable=self.v_wert, width=18) + e.pack(side="left", padx=4) + e.bind("", lambda ev: self._dazu()) + w.knopf(z, "Hinzufügen", self._dazu).pack(side="left") + w.knopf(z, "Entfernen", self._weg).pack(side="left", padx=4) + self.knopfleiste([("Schließen", self.abbrechen)]).pack(pady=(0, 10)) + self._laden() + + def _laden(self): + self.tree.delete(*self.tree.get_children()) + for a, k in self.db.eigene_kennungen(): + self.tree.insert("", "end", iid=f"{a}\t{k}", values=(ANBIETER.get(a, a), k)) + + def _dazu(self): + wert = self.v_wert.get().strip() + if not wert: + return + anb = next((k for k, v in ANBIETER.items() if v == self.v_anb.get()), self.v_anb.get()) + self.db.eigene_setzen(anb, wert, True) + self.v_wert.set("") + self.geaendert = True + self._laden() + + def _weg(self): + for iid in self.tree.selection(): + a, k = iid.split("\t", 1) + self.db.eigene_setzen(a, k, False) + self.geaendert = True + self._laden() + + def abbrechen(self): + self.result = self.geaendert + self.destroy() + class KennungZuordnen(w.Dialog): """Fragt, ob eine Kennung einem bestehenden oder neuen Händler zugeordnet werden soll.""" @@ -473,7 +539,7 @@ class KennungZuordnen(w.Dialog): self.v = tk.StringVar(value="__neu__") f = tk.Frame(self, bg=w.FACE) f.pack(fill="x", padx=20) - tk.Radiobutton(f, text="Neuem Händler …", variable=self.v, value="__neu__").pack(anchor="w") + tk.Radiobutton(f, text="Neuem Händler ...", variable=self.v, value="__neu__").pack(anchor="w") haendler = db.haendler_liste() if haendler: tk.Radiobutton(f, text="Bestehendem Händler:", variable=self.v, value="__alt__").pack(anchor="w") @@ -515,7 +581,7 @@ class HaendlerWaehlen(w.Dialog): self.cb = ttk.Combobox(self, values=list(self._hmap) + [self.KEINER], state="readonly", width=40) self.cb.pack(padx=12, pady=4) tk.Label(self, text="Die feste Zuweisung hat Vorrang vor Kennungen und der GROSSBUCHSTABEN-Regel.\n" - "Rückgängig: Rechtsklick ▸ „Automatisch zuordnen“.", justify="left", + "Rückgängig: Rechtsklick > „Automatisch zuordnen“.", justify="left", fg=w.SHADOW).pack(fill="x", padx=12) self.bind("", lambda e: self._ok()) self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=12) @@ -571,7 +637,7 @@ class ExportDialog(w.Dialog): tk.Label(g, text="Zielordner:").grid(row=1, column=0, sticky="w") self.v_ordner = tk.StringVar(value=settings.get("export_ordner") or "") tk.Entry(g, textvariable=self.v_ordner, width=40).grid(row=1, column=1, sticky="we", pady=2) - w.knopf(g, "Durchsuchen…", self._ordner).grid(row=1, column=2, padx=(4, 0)) + w.knopf(g, "Durchsuchen...", self._ordner).grid(row=1, column=2, padx=(4, 0)) self.v_anb = tk.BooleanVar(value=settings.get("export_pro_anbieter", True)) self.v_kopf = tk.BooleanVar(value=settings.get("export_kopfzeile", False)) self.v_sum = tk.BooleanVar(value=settings.get("export_summe", True)) @@ -637,7 +703,7 @@ class EinstellungenDialog(w.Dialog): row=0, column=0, columnspan=2, sticky="w") self.v = tk.StringVar(value=db_pfad) tk.Entry(g, textvariable=self.v, width=56).grid(row=1, column=0, sticky="we", pady=4) - w.knopf(g, "Ändern…", self._waehlen).grid(row=1, column=1, padx=(4, 0)) + w.knopf(g, "Ändern...", self._waehlen).grid(row=1, column=1, padx=(4, 0)) 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") diff --git a/provtool/gui/menue.py b/provtool/gui/menue.py new file mode 100644 index 0000000..6ac5dcb --- /dev/null +++ b/provtool/gui/menue.py @@ -0,0 +1,298 @@ +"""Menüs im Windows-95-Stil (Menüleiste, Aufklapp- und Kontextmenüs). + +Die nativen Windows-Menüs sind weiß und wechseln nicht mit, wenn man bei offenem Menü mit der +Maus zum nächsten Menüleisten-Eintrag fährt. Diese Menüs sind selbst gezeichnet: +- grau, Markierung in Navy, Tastenkürzel rechtsbündig, Trennlinien eingelassen +- bei offenem Menü wechselt das Menü beim Überfahren der Menüleiste +- Tastatur: Pfeil hoch/runter wählen, Enter ausführen, Esc schließen, Pfeil links/rechts benachbartes Menü +- Klick daneben oder Wechsel in ein anderes Programm schließt das Menü +Die Schnittstelle (add_command, add_separator, tk_popup) entspricht tk.Menu. +""" +from __future__ import annotations + +import tkinter as tk + +from provtool.gui import win95 as w + + +class Popup: + """Ein Aufklapp- bzw. Kontextmenü.""" + + def __init__(self, master: tk.Misc): + self.master = master + self.eintraege: list[dict] = [] + self.fenster: tk.Toplevel | None = None + self.zeilen: list[tuple[dict, tk.Frame, list[tk.Label]]] = [] + self.aktiv = -1 + self.leiste: Menueleiste | None = None + self._pruefer = None + + # ------------------------------------------------------------- tk.Menu-kompatibel + def add_command(self, label: str, command=None, accelerator: str = "", state: str = "normal"): + self.eintraege.append(dict(art="befehl", label=label, command=command, accelerator=accelerator, + state=state)) + + def add_separator(self): + self.eintraege.append(dict(art="trenner")) + + def tk_popup(self, x: int, y: int): + self.oeffnen(x, y) + + # ------------------------------------------------------------- Anzeige + @property + def offen(self) -> bool: + try: + return self.fenster is not None and bool(self.fenster.winfo_exists()) + except tk.TclError: + return False + + def oeffnen(self, x: int, y: int, leiste: Menueleiste | None = None): + self.schliessen() + self.leiste = leiste + root = self.master._root() + f = self.fenster = tk.Toplevel(root, bg=w.FACE, bd=2, relief="raised") + f.withdraw() + f.overrideredirect(True) + self.zeilen = [] + for e in self.eintraege: + if e["art"] == "trenner": + t = tk.Frame(f, bg=w.FACE, 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.pack(fill="x") + grau = e["state"] == "disabled" + text = tk.Label(zeile, text=e["label"], bg=w.FACE, anchor="w", padx=18, pady=2, + 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, + fg=w.SHADOW if grau else w.DARK) + acc.pack(side="right") + labels.append(acc) + idx = len(self.zeilen) + for wdg in [zeile] + labels: + wdg.bind("", lambda ev, i=idx: self._markieren(i)) + wdg.bind("", lambda ev, i=idx: self._ausfuehren(i)) + self.zeilen.append((e, zeile, labels)) + + f.update_idletasks() + bw, bh = f.winfo_reqwidth(), f.winfo_reqheight() + sw, sh = f.winfo_screenwidth(), f.winfo_screenheight() + x = max(0, min(x, sw - bw)) + y = max(0, min(y, sh - bh)) + # Größe UND Position zusammen setzen: eine reine Positionsangabe ignoriert Tk unter Windows bei + # rahmenlosen Fenstern – das Menü landete sonst oben links in der Bildschirmecke. + f.geometry(f"{bw}x{bh}+{x}+{y}") + f.deiconify() + f.lift() + f.update_idletasks() + try: + from provtool.gui import win32 + win32.besitzer_setzen(f, root) # bleibt über dem Hauptfenster + except Exception: + pass + + # Alle Maus-Ereignisse der Anwendung an das Menü (lokale Tk-Sperre): Klick daneben schließt, + # Bewegung über die Menüleiste wechselt das Menü. + try: + f.grab_set() + except tk.TclError: + pass + f.focus_force() + f.bind("", self._klick) + f.bind("", self._bewegung) + f.bind("", lambda e: self.schliessen()) + f.bind("", lambda e: self._schritt(-1)) + f.bind("", lambda e: self._schritt(1)) + f.bind("", lambda e: self._ausfuehren(self.aktiv)) + f.bind("", lambda e: self._ausfuehren(self.aktiv)) + f.bind("", lambda e: self.leiste and self.leiste.nachbar(-1)) + f.bind("", lambda e: self.leiste and self.leiste.nachbar(1)) + self.aktiv = -1 + self._pruefer = f.after(250, self._fokus_pruefen) + + def schliessen(self): + if self._pruefer is not None and self.fenster is not None: + try: + self.fenster.after_cancel(self._pruefer) + except tk.TclError: + pass + self._pruefer = None + if self.offen: + try: + self.fenster.grab_release() + except tk.TclError: + pass + self.fenster.destroy() + self.fenster = None + if self.leiste is not None: + self.leiste._geschlossen(self) + + # ------------------------------------------------------------- Bedienung + def _innerhalb(self, x_root: int, y_root: int) -> bool: + """Liegt der Punkt über diesem Menü? Fragt das Fenstersystem, welches Fenster dort liegt – + die Geometrie rahmenloser Fenster meldet Tk unter Windows nicht zuverlässig.""" + f = self.fenster + try: + unter = f.winfo_containing(x_root, y_root) + except (tk.TclError, KeyError): + unter = None + if unter is not None: + return unter.winfo_toplevel() is f + return (f.winfo_rootx() <= x_root < f.winfo_rootx() + f.winfo_width() + and f.winfo_rooty() <= y_root < f.winfo_rooty() + f.winfo_height()) + + def _klick(self, e): + if self._innerhalb(e.x_root, e.y_root): + return + if self.leiste is not None and self.leiste.knopf_bei(e.x_root, e.y_root) is not None: + knopf = self.leiste.knopf_bei(e.x_root, e.y_root) + if knopf is self.leiste.offener_knopf: + self.schliessen() # Klick auf den eigenen Menütitel klappt zu + else: + self.leiste.oeffnen(knopf) + return "break" + self.schliessen() + return "break" + + def _bewegung(self, e): + if self.leiste is None or self._innerhalb(e.x_root, e.y_root): + return + knopf = self.leiste.knopf_bei(e.x_root, e.y_root) + if knopf is not None and knopf is not self.leiste.offener_knopf: + self.leiste.oeffnen(knopf) + + def _markieren(self, i: int): + 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) + zeile.configure(bg=bg) + for lbl in labels: + lbl.configure(bg=bg, fg=fg) + + def _schritt(self, richtung: int): + if not self.zeilen: + return + i = self.aktiv + for _ in range(len(self.zeilen)): + i = (i + richtung) % len(self.zeilen) + if self.zeilen[i][0].get("state") != "disabled": + break + self._markieren(i) + + def _ausfuehren(self, i: int): + if not (0 <= i < len(self.zeilen)): + return + e = self.zeilen[i][0] + if e.get("state") == "disabled": + return + self.schliessen() + if e.get("command"): + # nach dem Schließen ausführen (der Befehl öffnet evtl. einen Dialog) + self.master.after_idle(e["command"]) + + def _fokus_pruefen(self): + """In ein anderes Programm gewechselt -> Menü schließen.""" + self._pruefer = None + if not self.offen: + return + try: + weg = self.fenster.focus_get() is None + except (tk.TclError, KeyError): + weg = False + if weg: + self.schliessen() + else: + self._pruefer = self.fenster.after(250, self._fokus_pruefen) + + +class Menueleiste(tk.Frame): + """Graue Menüleiste mit Popup-Menüs (die native von Windows 10/11 ist weiß).""" + + def __init__(self, master, root: tk.Misc): + super().__init__(master, bg=w.FACE) + self.root = root + self.knoepfe: dict[str, tk.Label] = {} + self.menues: dict[tk.Label, Popup] = {} + self.offener_knopf: tk.Label | None = None + + def menue(self, label: str, underline: int | None = 0) -> Popup: + knopf = tk.Label(self, text=label, bg=w.FACE, padx=7, pady=2, bd=1, relief="flat", + underline=-1 if underline is None else underline) + knopf.pack(side="left") + popup = Popup(self) + self.knoepfe[label] = knopf + self.menues[knopf] = popup + knopf.bind("", lambda e, k=knopf: self._klick(k)) + knopf.bind("", lambda e, k=knopf: self._hover(k, True)) + knopf.bind("", lambda e, k=knopf: self._hover(k, False)) + if underline is not None: + taste = label[underline].lower() + self.root.bind_all(f"", lambda e, k=knopf: self._alt(k)) + return popup + + # ---- Zustand + def knopf_bei(self, x_root: int, y_root: int) -> tk.Label | None: + for k in self.knoepfe.values(): + if (k.winfo_rootx() <= x_root < k.winfo_rootx() + k.winfo_width() + and k.winfo_rooty() <= y_root < k.winfo_rooty() + k.winfo_height()): + return k + return None + + 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") + else: + k.configure(bg=w.FACE, fg=w.DARK, relief="flat") + + # ---- Bedienung + def oeffnen(self, knopf: tk.Label): + from provtool.gui import modal + if modal.offen(): + return + alt = self.offener_knopf + if alt is not None and alt is not knopf: + self.menues[alt].leiste = None # ohne Rückmeldung schließen, wir öffnen gleich das nächste + self.menues[alt].schliessen() + self.offener_knopf = knopf + self._darstellen() + self.menues[knopf].oeffnen(knopf.winfo_rootx(), knopf.winfo_rooty() + knopf.winfo_height(), leiste=self) + + def schliessen(self): + if self.offener_knopf is not None: + self.menues[self.offener_knopf].schliessen() + + def _geschlossen(self, popup: Popup): + if self.offener_knopf is not None and self.menues.get(self.offener_knopf) is popup: + self.offener_knopf = None + self._darstellen() + + def nachbar(self, richtung: int): + liste = list(self.knoepfe.values()) + if self.offener_knopf in liste: + i = (liste.index(self.offener_knopf) + richtung) % len(liste) + self.oeffnen(liste[i]) + + def _klick(self, knopf): + if self.offener_knopf is knopf: + self.schliessen() + else: + self.oeffnen(knopf) + return "break" + + def _hover(self, knopf, drin: bool): + if self.offener_knopf is None: + knopf.configure(relief="raised" if drin else "flat") + + def _alt(self, knopf): + self.oeffnen(knopf) + if self.offener_knopf is knopf: + self.menues[knopf]._schritt(1) + return "break" diff --git a/provtool/gui/modal.py b/provtool/gui/modal.py new file mode 100644 index 0000000..74a5815 --- /dev/null +++ b/provtool/gui/modal.py @@ -0,0 +1,93 @@ +"""Modale Sperre ohne Tk-„grab“: Nur der INHALT des aufrufenden Fensters wird gesperrt. + +Titelleiste und Rand bleiben bedienbar – das Hauptfenster lässt sich also verschieben, maximieren +und in der Größe ändern, während ein Dialog offen ist. Klickt man auf den gesperrten Inhalt, kommt +der Dialog nach vorn und blinkt kurz (wie bei Windows). + +Technik: Jedes Widget im gesperrten Bereich bekommt vorn das Bindungs-Tag ``Pm95Gesperrt``. Dessen +Bindungen laufen vor allen anderen und beenden Klicks/Tastendrücke mit „break“. +""" +from __future__ import annotations + +import tkinter as tk + +TAG = "Pm95Gesperrt" +_stapel: list[tk.Toplevel] = [] # offene modale Fenster, innerstes zuletzt + +GESPERRTE_EREIGNISSE = ("", "", "", "", + "", "", "", "") + + +def oberstes() -> tk.Toplevel | None: + """Das zuletzt geöffnete, noch offene modale Fenster (oder None).""" + while _stapel: + d = _stapel[-1] + try: + if d.winfo_exists(): + return d + except tk.TclError: + pass + _stapel.pop() + return None + + +def offen() -> bool: + return oberstes() is not None + + +def _gesperrt_geklickt(event): + d = oberstes() + if d is not None and hasattr(d, "nach_vorn"): + d.nach_vorn(blinken=str(event.type) in ("ButtonPress", "4")) + return "break" + + +def _einrichten(root: tk.Misc): + # Merker am Tk-Objekt selbst (nicht am Namen: das Hauptfenster heißt in jeder Tk-Instanz „.“) + if getattr(root, "_pm95_sperre_eingerichtet", False): + return + for seq in GESPERRTE_EREIGNISSE: + root.bind_class(TAG, seq, _gesperrt_geklickt) + root._pm95_sperre_eingerichtet = True + + +def sperrbereiche(fenster: tk.Misc) -> list[tk.Misc]: + """Welche Widgets eines Fensters gesperrt werden: alles außer Titelleiste (und Rand).""" + eigene = getattr(fenster, "sperrbereich", None) + if callable(eigene): + return list(eigene()) + titel = getattr(fenster, "titelleiste", None) + return [k for k in fenster.winfo_children() if k is not titel and not isinstance(k, tk.Toplevel)] + + +class Sperre: + """Sperrt den Inhalt von `fenster`, solange `dialog` offen ist.""" + + def __init__(self, fenster: tk.Misc, dialog: tk.Toplevel): + _einrichten(fenster._root()) + self.widgets: list[tk.Misc] = [] + for bereich in sperrbereiche(fenster): + self._sperren(bereich) + _stapel.append(dialog) + self.dialog = dialog + + def _sperren(self, w: tk.Misc): + if isinstance(w, tk.Toplevel): + return # eigene Fenster (z. B. andere Dialoge) nicht anfassen + tags = w.bindtags() + if TAG not in tags: + w.bindtags((TAG,) + tuple(tags)) + self.widgets.append(w) + for kind in w.winfo_children(): + self._sperren(kind) + + def aufheben(self): + for w in self.widgets: + try: + if w.winfo_exists(): + w.bindtags(tuple(t for t in w.bindtags() if t != TAG)) + except tk.TclError: + pass + self.widgets = [] + if self.dialog in _stapel: + _stapel.remove(self.dialog) diff --git a/provtool/gui/rahmen.py b/provtool/gui/rahmen.py index 183d16f..b2d7c26 100644 --- a/provtool/gui/rahmen.py +++ b/provtool/gui/rahmen.py @@ -183,38 +183,33 @@ class Titelleiste(tk.Frame): def _start(self, e): self._ab = (e.x_root - self.fenster.winfo_rootx(), e.y_root - self.fenster.winfo_rooty()) + self._nativ = False + if NATIV_VERSCHIEBEN and IS_WIN and self.ziehen_erlaubt(): + # Windows übernimmt das Ziehen: flüssig und mit Andocken am Bildschirmrand + from provtool.gui import win32 + self._nativ = win32.nativ_verschieben(self.fenster) + if self._nativ: + return "break" def _ziehen(self, e): - if not self.ziehen_erlaubt(): + if self._nativ or not self.ziehen_erlaubt(): return self.fenster.geometry(f"+{e.x_root - self._ab[0]}+{e.y_root - self._ab[1]}") + def blinken(self, mal: int = 3): + """Titelleiste kurz grau/blau blinken lassen (wie Windows bei gesperrtem Fenster).""" + farben = [w.SHADOW, w.NAVY] * mal -class Menueleiste(tk.Frame): - """Graue Menüleiste (die native von Windows 10/11 ist weiß und lässt sich nicht umfärben).""" + def schritt(i=0): + if i >= len(farben) or not self.winfo_exists(): + return + for widget in (self, self.titel_lbl): + widget.configure(bg=farben[i]) + self.after(70, lambda: schritt(i + 1)) + schritt() - 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" +NATIV_VERSCHIEBEN = True # Tests können das abschalten, um den Tk-Weg zu prüfen # --------------------------------------------------------------------------- Hauptfenster @@ -222,7 +217,8 @@ class Menueleiste(tk.Frame): 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 + RAND = 6 # Pixel am Rand, an denen man die Größe ziehen kann (= sichtbarer Rahmen) + ECKE = 18 # so weit vom Eck entfernt gilt der Rand noch als Ecke (diagonal ziehen) def __init__(self, root: tk.Tk, titel: str, icon: tk.PhotoImage, schliessen, klassisch: bool = True, minbreite: int = 900, minhoehe: int = 520): @@ -237,10 +233,11 @@ class Hauptrahmen: self.aussen.pack(fill="both", expand=True) self.inhalt = self.aussen self.titelleiste = None + root.sperrbereich = lambda: [self.inhalt] # type: ignore[attr-defined] # für modal.py return root.overrideredirect(True) - self.aussen = tk.Frame(root, bg=w.FACE, bd=2, relief="raised", padx=2, pady=2) + self.aussen = tk.Frame(root, bg=w.FACE, bd=2, relief="raised", padx=self.RAND - 2, pady=self.RAND - 2) self.aussen.pack(fill="both", expand=True) self.titelleiste = Titelleiste(self.aussen, root, titel, icon, ("min", "max", "close"), schliessen=schliessen, minimieren=self.minimieren, @@ -249,10 +246,17 @@ class Hauptrahmen: self.titelleiste.ziehen_erlaubt = lambda: not self.maximiert self.inhalt = tk.Frame(self.aussen, bg=w.FACE) self.inhalt.pack(fill="both", expand=True) + # Gesperrt wird bei offenem Dialog nur der Inhalt – Titelleiste und Rand bleiben bedienbar + root.sperrbereich = lambda: [self.inhalt] # type: ignore[attr-defined] self._rand: str = "" + self._resize_geplant = None self.aussen.bind("", self._rand_cursor) - self.aussen.bind("", lambda e: self.aussen.configure(cursor="")) + # Mauszeiger zurücksetzen, sobald der Rand verlassen wird – Kindfenster erben sonst den + # Größenänderungs-Zeiger, wenn das letzte Motion-Ereignis am Rand verpasst wurde. + self.aussen.bind("", self._cursor_zurueck) + self.inhalt.bind("", self._cursor_zurueck) + self.titelleiste.bind("", self._cursor_zurueck, add="+") self.aussen.bind("", self._resize_start) self.aussen.bind("", self._resize) root.bind_all("", lambda e: self.alt_f4(e.widget)) @@ -264,7 +268,8 @@ class Hauptrahmen: # Ist ein modaler Dialog offen, gilt Alt+F4 immer ihm – egal wo der Tastaturfokus liegt # (unter Windows liefert Tk Tastendrücke an das Fenster mit Fokus, das kann noch das # Hauptfenster sein). - sperre = self.root.grab_current() + from provtool.gui import modal + sperre = self.root.grab_current() or modal.oberstes() popup = self._offenes_popup() if sperre is not None and sperre.winfo_toplevel() is not self.root: fenster = sperre.winfo_toplevel() @@ -275,6 +280,11 @@ class Hauptrahmen: except tk.TclError: fenster = self.root if fenster is self.root: + from provtool.gui import modal + offen = modal.oberstes() + if offen is not None: # Alt+F4 bei offenem Dialog schließt den Dialog + offen.abbrechen() + return "break" self._schliessen() elif hasattr(fenster, "abbrechen"): fenster.abbrechen() @@ -338,7 +348,14 @@ class Hauptrahmen: # ---- Fensterfunktionen def minimieren(self): - # Windows minimiert keine overrideredirect-Fenster → kurz normalen Rahmen aktivieren + if IS_WIN: + # Direkt über Windows: offene Dialoge (gehören dem Hauptfenster) werden mit aus- und + # wieder eingeblendet. NICHT overrideredirect umschalten – Tk baut dabei das Fenster neu + # und Windows würde die zugehörigen Dialoge zerstören. + from provtool.gui import win32 + if win32.minimieren(self.root): + return + # Tk minimiert keine overrideredirect-Fenster -> kurz normalen Rahmen aktivieren geo = self.root.geometry() self.root.overrideredirect(False) self.root.iconify() @@ -373,9 +390,25 @@ class Hauptrahmen: # ---- 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 + bw, bh, r, k = self.aussen.winfo_width(), self.aussen.winfo_height(), self.RAND, self.ECKE + ns = "n" if y < r else "s" if y >= bh - r else "" + we = "w" if x < r else "e" if x >= bw - r else "" + # an einer Kante nahe am Eck -> Ecke (diagonal), wie bei Windows + if ns and not we: + we = "w" if x < k else "e" if x >= bw - k else "" + elif we and not ns: + ns = "n" if y < k else "s" if y >= bh - k else "" + return ns + we + + def _cursor_zurueck(self, _e=None): + if self._rand_zeiger: + self._rand_zeiger = False + try: + self.aussen.configure(cursor="") + except tk.TclError: + pass + + _rand_zeiger = False 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"} @@ -387,8 +420,10 @@ class Hauptrahmen: if self.maximiert: return rand = self._welcher_rand(e.x, e.y) + zeiger = (self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "") try: - self.aussen.configure(cursor=(self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "")) + self.aussen.configure(cursor=zeiger) + self._rand_zeiger = bool(zeiger) except tk.TclError: pass @@ -412,4 +447,14 @@ class Hauptrahmen: 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}") + # Mausbewegungen bündeln: nur die jeweils letzte Größe anwenden (flüssiger unter Windows) + self._resize_ziel = f"{bw}x{bh}+{x}+{y}" + if self._resize_geplant is None: + self._resize_geplant = self.root.after(15, self._resize_anwenden) + + def _resize_anwenden(self): + self._resize_geplant = None + try: + self.root.geometry(self._resize_ziel) + except tk.TclError: + pass diff --git a/provtool/gui/win32.py b/provtool/gui/win32.py index 3eca622..3119a65 100644 --- a/provtool/gui/win32.py +++ b/provtool/gui/win32.py @@ -50,6 +50,20 @@ def user32(): u.GetTopWindow.restype = wintypes.HWND u.BringWindowToTop.argtypes = [wintypes.HWND] u.BringWindowToTop.restype = wintypes.BOOL + u.GetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int] + u.GetWindowLongW.restype = ctypes.c_long + u.SetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_long] + u.SetWindowLongW.restype = ctypes.c_long + u.ReleaseCapture.argtypes = [] + u.ReleaseCapture.restype = wintypes.BOOL + u.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] + u.SendMessageW.restype = wintypes.LPARAM + u.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int] + u.ShowWindow.restype = wintypes.BOOL + u.IsIconic.argtypes = [wintypes.HWND] + u.IsIconic.restype = wintypes.BOOL + u.FlashWindow.argtypes = [wintypes.HWND, wintypes.BOOL] + u.FlashWindow.restype = wintypes.BOOL _u32 = u return _u32 @@ -82,6 +96,62 @@ def nach_vorn(fenster: tk.Misc) -> None: user32().BringWindowToTop(h) +GWL_EXSTYLE, WS_EX_APPWINDOW, WS_EX_TOOLWINDOW = -20, 0x00040000, 0x00000080 + + +def hat_taskleisteneintrag(fenster: tk.Misc) -> bool: + h = hwnd(fenster) + if not h: + return False + stil = user32().GetWindowLongW(h, GWL_EXSTYLE) + return bool(stil & WS_EX_APPWINDOW) and not (stil & WS_EX_TOOLWINDOW) + + +def taskleisteneintrag(fenster: tk.Misc) -> bool: + """Eigener Eintrag in Taskleiste/Alt+Tab (rahmenlose Tk-Fenster haben sonst keinen). + Wirkt erst nach erneutem Einblenden – gibt True zurück, wenn das nötig ist.""" + h = hwnd(fenster) + if not h: + return False + stil = user32().GetWindowLongW(h, GWL_EXSTYLE) + neu = (stil & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW + if neu != stil: + user32().SetWindowLongW(h, GWL_EXSTYLE, neu) + return True + return False + + +def nativ_verschieben(fenster: tk.Misc) -> bool: + """Windows übernimmt das Ziehen (flüssig, mit Andocken am Bildschirmrand). True bei Erfolg.""" + h = hwnd(fenster) + if not h: + return False + user32().ReleaseCapture() + user32().SendMessageW(h, 0x00A1, 2, 0) # WM_NCLBUTTONDOWN, HTCAPTION + return True + + +def minimieren(fenster: tk.Misc) -> bool: + """Direkt über Windows minimieren – Tk verweigert das bei rahmenlosen Fenstern. Eigene Popups + (Dialoge) werden dabei von Windows mit ausgeblendet und beim Wiederherstellen mit eingeblendet.""" + h = hwnd(fenster) + if not h: + return False + user32().ShowWindow(h, 6) # SW_MINIMIZE + return True + + +def ist_minimiert(fenster: tk.Misc) -> bool: + h = hwnd(fenster) + return bool(user32().IsIconic(h)) if h else False + + +def blinken(fenster: tk.Misc) -> None: + h = hwnd(fenster) + if h: + user32().FlashWindow(h, True) + + def besitzer_setzen(fenster: tk.Misc, besitzer: tk.Misc) -> None: h, b = hwnd(fenster), hwnd(besitzer) if h and b: diff --git a/provtool/gui/win95.py b/provtool/gui/win95.py index 6f7f408..d639421 100644 --- a/provtool/gui/win95.py +++ b/provtool/gui/win95.py @@ -294,6 +294,9 @@ def apply(root: tk.Tk) -> None: o("*Background", FACE) o("*Foreground", DARK) o("*activeBackground", FACE) + # Tk-Eigenheit: „activeForeground“ gehört zur Optionsklasse „Background“ – ohne diese Zeile + # wäre die Schrift gedrückter Knöpfe grau auf grau (unsichtbar). + o("*activeForeground", DARK) o("*highlightThickness", 0) o("*selectBackground", NAVY) o("*selectForeground", WHITE) @@ -322,6 +325,7 @@ def apply(root: tk.Tk) -> None: from tkinter import font as tkfont zeile = max(18, tkfont.Font(root=root, font=FONT).metrics("linespace") + 5) + wort_loeschen_einrichten(root) st = ttk.Style(root) st.theme_use("classic" if "classic" in st.theme_names() else "default") st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=LIGHT, @@ -341,6 +345,78 @@ def apply(root: tk.Tk) -> None: st.configure("Sash", sashthickness=6, background=FACE) +# --------------------------------------------------------------------------- Wort löschen + +def _wortgrenze(text: str, pos: int, richtung: int) -> int: + """Position der nächsten Wortgrenze von pos aus (richtung -1 = links, +1 = rechts), + wie Strg+Rücktaste / Strg+Entf unter Windows: erst Leerzeichen, dann das Wort.""" + if richtung < 0: + i = pos + while i > 0 and text[i - 1].isspace(): + i -= 1 + if i > 0 and text[i - 1].isalnum(): + while i > 0 and text[i - 1].isalnum(): + i -= 1 + elif i > 0: + i -= 1 # Satzzeichen einzeln + return i + i = pos + if i < len(text) and text[i].isalnum(): + while i < len(text) and text[i].isalnum(): + i += 1 + elif i < len(text): + i += 1 + while i < len(text) and text[i].isspace(): + i += 1 + return i + + +def _wort_weg(event, richtung: int): + e = event.widget + try: + if str(e.cget("state")) in ("disabled", "readonly"): + return "break" + except tk.TclError: + pass + try: + if e.selection_present(): + e.delete("sel.first", "sel.last") + return "break" + except (tk.TclError, AttributeError): + pass + text, pos = e.get(), e.index("insert") + grenze = _wortgrenze(text, pos, richtung) + if richtung < 0 and grenze < pos: + e.delete(grenze, pos) + elif richtung > 0 and grenze > pos: + e.delete(pos, grenze) + return "break" + + +def _text_wort_weg(event, richtung: int): + t = event.widget + if str(t.cget("state")) == "disabled": + return "break" + if t.tag_ranges("sel"): + t.delete("sel.first", "sel.last") + return "break" + if richtung < 0: + t.delete("insert -1c wordstart" if t.get("insert -1c").isspace() else "insert wordstart", "insert") + else: + t.delete("insert", "insert wordend") + return "break" + + +def wort_loeschen_einrichten(root: tk.Misc): + """Strg+Rücktaste / Strg+Entf löschen das ganze Wort – in allen Eingabefeldern (Tk kann das + unter Windows von Haus aus nicht, dort wird nur ein Zeichen gelöscht).""" + for klasse in ("Entry", "TEntry", "TCombobox", "Spinbox", "TSpinbox"): + root.bind_class(klasse, "", lambda e: _wort_weg(e, -1)) + root.bind_class(klasse, "", lambda e: _wort_weg(e, 1)) + root.bind_class("Text", "", lambda e: _text_wort_weg(e, -1)) + root.bind_class("Text", "", lambda e: _text_wort_weg(e, 1)) + + # --------------------------------------------------------------------------- Bausteine class Sunken(tk.Frame): @@ -434,8 +510,12 @@ class Tooltip: y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4 self.tip = tw = tk.Toplevel(self.widget) tw.wm_overrideredirect(True) - tw.wm_geometry(f"+{x}+{y}") + tw.withdraw() tk.Label(tw, text=self.text, bg=INFO_BG, bd=1, relief="solid", padx=4, pady=1).pack() + tw.update_idletasks() + # Größe + Position zusammen (reine Position ignoriert Tk unter Windows bei rahmenlosen Fenstern) + tw.wm_geometry(f"{tw.winfo_reqwidth()}x{tw.winfo_reqheight()}+{x}+{y}") + tw.deiconify() def _weg(self, _e=None): if self._after: @@ -502,19 +582,24 @@ KLASSISCH = False class Dialog(tk.Toplevel): - """Modaler Dialog im Win95-Stil.""" + """Modaler Dialog im Win95-Stil. + + Modal heißt hier: Der INHALT des aufrufenden Fensters ist gesperrt (siehe modal.py), dessen + Titelleiste und Rand bleiben bedienbar. Unter Windows gehört der Dialog dem aufrufenden Fenster + (bleibt darüber, kommt mit Alt+Tab nach vorn) und hat einen eigenen Taskleisten-Eintrag. + """ def __init__(self, master, titel: str, resizable: bool = False): super().__init__(master, bg=FACE) self.withdraw() self.title(titel) if not KLASSISCH: - # Rahmenlose Dialoge bekommen ihren Besitzer über win32.Modal – mit transient() würde Tk - # sie unter Windows als Kindfenster anlegen, die dann hinter anderen Programmen hängen. + # Rahmenlose Dialoge bekommen ihren Besitzer über win32 – mit transient() würde Tk sie + # unter Windows als Kindfenster anlegen, die dann hinter anderen Programmen hängen. self.transient(master.winfo_toplevel()) self.resizable(resizable, resizable) self.result = None - self._modal = None # Windows: Besitzer + Sperre des aufrufenden Fensters (siehe win32.Modal) + self._sperre = None self.protocol("WM_DELETE_WINDOW", self.abbrechen) self.bind("", lambda e: self.abbrechen()) try: @@ -530,64 +615,60 @@ class Dialog(tk.Toplevel): self.titelleiste.pack(side="top", fill="x") def zeigen(self, fokus=None): + from provtool.gui import modal, win32 if getattr(self._root(), "_provtool_schliesst", False): self.destroy() # Programm wird beendet – keine neuen Dialoge mehr return None self.update_idletasks() m = self.master.winfo_toplevel() + self._aufrufer = m 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"+{max(0, x)}+{max(0, y)}") + self.geometry(f"{w}x{h}+{max(0, x)}+{max(0, y)}") # Größe + Position zusammen (siehe Tooltip) self.deiconify() - self.lift() - # Windows: Dialog gehört dem aufrufenden Fenster, das solange gesperrt ist → Klick aufs - # Hauptfenster, Taskleiste und Alt+Tab holen automatisch den Dialog nach vorn. - from provtool.gui.win32 import Modal self.update_idletasks() + if KLASSISCH and win32.IS_WIN: + # eigener Taskleisten-Eintrag; wird erst nach kurzem Aus-/Einblenden wirksam + if win32.taskleisteneintrag(self): + self.withdraw() + self.deiconify() + self.update_idletasks() + win32.besitzer_setzen(self, m) + self._sperre = modal.Sperre(m, self) + self.lift() self.focus_force() - self._modal = Modal(self, m) - vorher = self.grab_current() # z. B. der Assistent, wenn hier eine Rückfrage aufgeht - self._sperre_setzen() - 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 _sperre_setzen(self, versuch: int = 0): - """Tk-Modalsperre setzen. Ist das Fenster noch nicht ganz sichtbar (langsamer PC, Wine), - schlägt grab_set fehl – dann kurz danach erneut versuchen statt ohne Sperre weiterzumachen.""" + def nach_vorn(self, blinken: bool = False): + """Dialog nach vorn holen (z. B. nach Klick auf das gesperrte Hauptfenster).""" + from provtool.gui import win32 try: - if self.winfo_exists(): - self.grab_set() + self.lift() + self.focus_force() + win32.nach_vorn(self) except tk.TclError: - if versuch < 50: - self.after(20, lambda: self._sperre_setzen(versuch + 1)) + return + if blinken: + self.bell() + win32.blinken(self) + if self.titelleiste is not None: + self.titelleiste.blinken() def destroy(self): - # Erst das aufrufende Fenster wieder freigeben, dann schließen – sonst aktiviert Windows - # beim Schließen ein fremdes Programm statt unseres Fensters. - modal = getattr(self, "_modal", None) - if modal is not None: - modal.beenden() - self._modal = None + sperre = getattr(self, "_sperre", None) + if sperre is not None: + sperre.aufheben() + self._sperre = None + aufrufer = getattr(self, "_aufrufer", None) super().destroy() + try: # Fokus zurück an das aufrufende Fenster + if aufrufer is not None and aufrufer.winfo_exists(): + aufrufer.focus_force() + except tk.TclError: + pass def abbrechen(self): self.result = None diff --git a/provtool/selbsttest.py b/provtool/selbsttest.py index c062d1b..6fd6e57 100644 --- a/provtool/selbsttest.py +++ b/provtool/selbsttest.py @@ -161,7 +161,7 @@ def ausfuehren(ergebnisdatei: str) -> int: (seite(0), lambda: wiz()._weiter(), "Willkommen"), (seite(1), lambda: wiz()._weiter(), "Speicherort"), (seite(2), haendler, "Händler anlegen"), - (lambda: len(s.app.db.haendler_liste()) == 1, lambda: wiz()._weiter(), "Händler → weiter"), + (lambda: len(s.app.db.haendler_liste()) == 1, lambda: wiz()._weiter(), "Händler -> weiter"), (seite(3), abrechnung, "Abrechnung mit Datei"), (seite(4), lambda: wiz()._weiter(), "Fertig stellen"), (lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"), diff --git a/tests/test_gui.py b/tests/test_gui.py index c2cd046..3932dbf 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -61,6 +61,23 @@ class Lauf: # eine evtl. geöffnete Fehlermeldung schließen, damit der Test nicht hängt self.root.after(100, self._meldungen_zu) self.root.report_callback_exception = melden + self._warten_bis_sichtbar() + + def _warten_bis_sichtbar(self, sekunden: float = 8.0): + """Wie ein Mensch erst bedienen, wenn das Hauptfenster zu sehen ist: Unter Windows wird es beim + Start für den Taskleisten-Eintrag kurz aus- und wieder eingeblendet (unter Wine langsamer).""" + ende = time.time() + sekunden + stabil = 0 + while time.time() < ende: + try: + self.root.update() + sichtbar = self.root.winfo_viewable() and self.app.inhalt.winfo_height() > 50 + except tk.TclError: + return + stabil = stabil + 1 if sichtbar else 0 + if stabil >= 10: # ~0,1 s durchgehend sichtbar + return + time.sleep(0.01) # ---------------------------------------------------------------- Zustand def lebt_sonst(self, text: str) -> str: @@ -155,6 +172,10 @@ class Lauf: time.sleep(0.01) self.uebrig = [s[2] for s in offen] + def diagnose(self) -> str: + """Für Fehlermeldungen: welche Schritte nicht liefen + alle gesammelten Fehler.""" + return f"nicht ausgeführt: {self.uebrig}\n" + "\n".join(self.fehler) + def schliessen_ueber_fenster(self): """Wie Klick auf × / Taskleiste ▸ Fenster schließen (WM_DELETE_WINDOW).""" cmd = self.root.tk.call("wm", "protocol", self.root._w, "WM_DELETE_WINDOW") @@ -233,7 +254,7 @@ def test_alt_f4_im_assistent_fragt_und_schliesst_nur_assistent(home): (lambda: lauf.dialog(Einrichtungsassistent) is None, lambda: None, "Assistent ist zu"), ]) assert lauf.lebt, lauf.lebt_sonst("Programm darf nicht beendet werden") - assert not lauf.uebrig, lauf.uebrig + assert not lauf.uebrig, lauf.diagnose() assert lauf.app.settings.get("assistent_erledigt") is True lauf.pruefen() lauf.ende() @@ -342,7 +363,6 @@ def test_alt_f4_bevor_die_sperre_steht(lauf_mit_daten, monkeypatch): Zeichnen sind noch nicht fertig. Alt+F4 muss trotzdem nur den Dialog schließen.""" from provtool.gui import win95 as w from provtool.gui.dialogs import InfoDialog - monkeypatch.setattr(w.Dialog, "_sperre_setzen", lambda self, versuch=0: None) # keine Sperre monkeypatch.setattr(w.Dialog, "winfo_ismapped", lambda self: 0) # „noch nicht gezeichnet“ lauf = lauf_mit_daten lauf.ablauf([ @@ -353,7 +373,7 @@ def test_alt_f4_bevor_die_sperre_steht(lauf_mit_daten, monkeypatch): (lambda: lauf.dialog(InfoDialog) is None, lambda: None, "Dialog geschlossen"), ], sekunden=8) assert lauf.lebt, lauf.lebt_sonst("Alt+F4 darf bei offenem Dialog nicht das ganze Programm beenden") - assert not lauf.uebrig, lauf.uebrig + assert not lauf.uebrig, lauf.diagnose() lauf.pruefen() @@ -367,7 +387,7 @@ def test_alt_f4_im_rundgang_beendet_nur_den_rundgang(lauf_mit_daten): (lambda: rg().blase is None and not rg().rahmen, lambda: None, "Rundgang samt Rahmen weg"), ], sekunden=8) assert lauf.lebt, lauf.lebt_sonst("Alt+F4 im Rundgang darf nicht das Programm beenden") - assert not lauf.uebrig, lauf.uebrig + assert not lauf.uebrig, lauf.diagnose() lauf.pruefen() @@ -396,7 +416,7 @@ def test_betrag_dialog_wenn_zelle_unsichtbar(lauf_mit_daten): (lambda: bool(app.ptree.get_children()), oeffnen, "Betrag ändern bei unsichtbarer Zelle"), (lambda: eingabe() is not None, tippen, "Betrag im Dialog eingeben"), ]) - assert not lauf.uebrig, lauf.uebrig + assert not lauf.uebrig, lauf.diagnose() assert any(p["betrag"] == 777 and p["manuell"] for p in app.positionen) del app.ptree.bbox lauf.pruefen() @@ -533,7 +553,7 @@ def test_windows_popup_kommt_nach_alt_tab_wieder_nach_vorn(lauf_mit_daten): fremd.geometry("400x300+50+50") fremd.update() m.update(besitzer=win32.besitzer_von(d), haupt=win32.hwnd(lauf.root), - haupt_bedienbar=win32.ist_bedienbar(lauf.root)) + haupt_bedienbar=win32.ist_bedienbar(lauf.root), taskleiste=win32.hat_taskleisteneintrag(d)) win32.nach_vorn(fremd) # „woanders hingeklickt“ lauf.root.update() m["woanders"] = win32.stapel(d, lauf.root, fremd) @@ -548,34 +568,38 @@ def test_windows_popup_kommt_nach_alt_tab_wieder_nach_vorn(lauf_mit_daten): (lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.root.after(150, messen), "messen"), (lambda: "zurueck" in m and lauf.dialog(InfoDialog) is None, lambda: None, "geschlossen"), ], sekunden=10) - assert not lauf.uebrig, lauf.uebrig + assert not lauf.uebrig, lauf.diagnose() assert m["besitzer"] == m["haupt"], "Popup gehört nicht dem Hauptfenster" - assert m["haupt_bedienbar"] is False, "Hauptfenster muss gesperrt sein, solange das Popup offen ist" + assert m["haupt_bedienbar"] is True, "Hauptfenster soll verschiebbar bleiben (nur der Inhalt ist gesperrt)" + assert m["taskleiste"] is True, "Popup braucht einen eigenen Eintrag in der Taskleiste" assert m["woanders"][0] == 2, f"Testaufbau: das fremde Fenster sollte oben liegen ({m['woanders']})" assert m["zurueck"][:2] == [0, 1], f"Popup liegt nach dem Zurückholen nicht über dem Hauptfenster: {m['zurueck']}" assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Schließen nicht wieder freigegeben" lauf.pruefen() -@nur_windows -def test_windows_verschachtelte_rueckfrage(lauf_mit_daten): - """Händler-Dialog → Rückfrage: Rückfrage gehört dem Händler-Dialog; beim Schließen der Reihe nach frei.""" - from provtool.gui import win32 +def test_verschachtelte_rueckfrage_sperrt_der_reihe_nach(lauf_mit_daten): + """Händler-Dialog -> Rückfrage: gesperrt sind Hauptfenster-Inhalt und Händler-Dialog (nicht dessen + Titelleiste); nach der Rückfrage ist der Händler-Dialog wieder frei, das Hauptfenster noch nicht.""" + from provtool.gui import modal, win32 from provtool.gui import win95 as w from provtool.gui.dialogs import HaendlerDialog lauf = lauf_mit_daten m = {} rueckfrage = lambda: next((d for d in lauf.dialoge() if d.title() == "Test-Rückfrage"), None) # noqa: E731 + gesperrt = lambda wdg: modal.TAG in wdg.bindtags() # noqa: E731 def messen_innen(): h, r = lauf.dialog(HaendlerDialog), rueckfrage() - m.update(r_besitzer=win32.besitzer_von(r), h=win32.hwnd(h), h_an=win32.ist_bedienbar(h), - haupt_an=win32.ist_bedienbar(lauf.root)) + m.update(h_inhalt=gesperrt(h.liste), h_titel=gesperrt(h.titelleiste) if h.titelleiste else False, + haupt_inhalt=gesperrt(lauf.app.ptree), haupt_titel=gesperrt(lauf.app.rahmen.titelleiste) + if lauf.app.rahmen.titelleiste else False, + r_besitzer=win32.besitzer_von(r), h=win32.hwnd(h)) r.destroy() def messen_aussen(): h = lauf.dialog(HaendlerDialog) - m.update(h_an_danach=win32.ist_bedienbar(h), haupt_an_danach=win32.ist_bedienbar(lauf.root)) + m.update(h_inhalt_danach=gesperrt(h.liste), haupt_inhalt_danach=gesperrt(lauf.app.ptree)) h.abbrechen() lauf.ablauf([ @@ -586,11 +610,13 @@ def test_windows_verschachtelte_rueckfrage(lauf_mit_daten): (lambda: "h" in m and rueckfrage() is None, messen_aussen, "außen messen"), (lambda: lauf.dialog(HaendlerDialog) is None, lambda: None, "alles zu"), ], sekunden=12) - assert not lauf.uebrig, lauf.uebrig - assert m["r_besitzer"] == m["h"], "Rückfrage gehört nicht dem Händler-Dialog" - assert m["h_an"] is False and m["haupt_an"] is False - assert m["h_an_danach"] is True and m["haupt_an_danach"] is False - assert win32.ist_bedienbar(lauf.root) + assert not lauf.uebrig, lauf.diagnose() + assert m["h_inhalt"] and m["haupt_inhalt"], "Inhalte müssen gesperrt sein, solange die Rückfrage offen ist" + assert not m["h_titel"] and not m["haupt_titel"], "Titelleisten (verschieben) dürfen nicht gesperrt sein" + assert not m["h_inhalt_danach"] and m["haupt_inhalt_danach"] + assert not gesperrt(lauf.app.ptree), "Hauptfenster nach dem Schließen nicht wieder freigegeben" + if sys.platform.startswith("win"): + assert m["r_besitzer"] == m["h"], "Rückfrage gehört nicht dem Händler-Dialog" lauf.pruefen() @@ -612,8 +638,240 @@ def test_windows_rundgang_sperrt_und_gibt_frei(lauf_mit_daten): messen_und_weiter, f"Schritt {i + 1}")) schritte.append((IMMER, lambda: rg().beenden(), "Rundgang beenden")) lauf.ablauf(schritte, sekunden=12) - assert not lauf.uebrig, lauf.uebrig + assert not lauf.uebrig, lauf.diagnose() assert all(m["gesperrt"]), "Hauptfenster während des Rundgangs nicht gesperrt" assert all(m["besitzer"]), "Sprechblase gehört nicht dem Hauptfenster" assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Rundgang nicht freigegeben" lauf.pruefen() + + +# =========================================================================== Rückmeldungen vom 2.11. (Teil 2) + +def test_gedrueckte_knoepfe_behalten_ihre_schrift(lauf_mit_daten): + """Gemeldet: Im gedrückten Zustand war die Beschriftung weg (activeforeground = Grau).""" + from provtool.gui import win95 as w + lauf = lauf_mit_daten + for b in (w.knopf(lauf.root, "Test"), tk.Button(lauf.root, text="roh")): + assert b.cget("activeforeground") != b.cget("activebackground"), "Schrift gedrückter Knöpfe unsichtbar" + b.destroy() + lauf.pruefen() + + +def test_tipp_text_bleibt_im_sichtbaren_bereich(lauf_mit_daten): + from provtool.gui.assistent import TIPPS, TippDesTages + lauf = lauf_mit_daten + m = {} + + def messen(): + d = lauf.dialog(TippDesTages) + rahmen = d.text.master + for t in TIPPS: # jeden Tipp einmal anzeigen + d.text.configure(text=t) + d.update_idletasks() + m.setdefault("breiten", []).append((d.text.winfo_reqwidth(), rahmen.winfo_width())) + m.setdefault("hoehen", []).append((d.text.winfo_reqheight(), rahmen.winfo_height())) + d.abbrechen() + + lauf.ablauf([ + (IMMER, lauf.app.tipp_des_tages, "Tipp öffnen"), + (lambda: lauf.dialog(TippDesTages) is not None, lambda: lauf.root.after(100, messen), "messen"), + (lambda: "breiten" in m and lauf.dialog(TippDesTages) is None, lambda: None, "zu"), + ]) + assert not lauf.uebrig, lauf.diagnose() + for text_b, rahmen_b in m["breiten"]: + assert text_b <= rahmen_b, f"Tipp-Text ({text_b}px) breiter als sein Bereich ({rahmen_b}px)" + for text_h, rahmen_h in m["hoehen"]: + assert text_h <= rahmen_h, f"Tipp-Text ({text_h}px) höher als sein Bereich ({rahmen_h}px)" + lauf.pruefen() + + +def test_strg_ruecktaste_loescht_ganzes_wort(lauf_mit_daten): + from provtool.gui import win95 as w + lauf = lauf_mit_daten + e = tk.Entry(lauf.root) + e.insert(0, "Mobilfunk Schmid GmbH") + ev = type("E", (), {"widget": e})() + e.icursor("end") + w._wort_weg(ev, -1) + assert e.get() == "Mobilfunk Schmid " + w._wort_weg(ev, -1) + assert e.get() == "Mobilfunk " + e.icursor(0) + w._wort_weg(ev, 1) + assert e.get() == "" + # Bindung ist für alle Eingabefelder aktiv + for klasse in ("Entry", "TEntry", "TCombobox"): + assert lauf.root.bind_class(klasse, ""), klasse + assert lauf.root.bind_class(klasse, ""), klasse + e.destroy() + lauf.pruefen() + + +def test_menue_wechselt_beim_ueberfahren(lauf_mit_daten): + """Gemeldet: Bei offenem Menü wechselte es nicht mit, wenn man zum nächsten Eintrag fuhr.""" + lauf = lauf_mit_daten + leiste = lauf.app.menueleiste + datei, haendler = leiste.knoepfe["Datei"], leiste.knoepfe["Händler"] + m = {} + + def ueberfahren(): + popup = leiste.menues[datei] + ev = type("E", (), {"x_root": haendler.winfo_rootx() + 3, "y_root": haendler.winfo_rooty() + 3})() + popup._bewegung(ev) + + def pruefen_und_esc(): + m["offen"] = leiste.offener_knopf + m["popup_offen"] = leiste.menues[haendler].offen + leiste.menues[haendler].fenster.event_generate("") + leiste.menues[haendler].schliessen() + + lauf.ablauf([ + (IMMER, lambda: leiste._klick(datei), "Datei öffnen"), + (lambda: leiste.menues[datei].offen, ueberfahren, "zu „Händler“ fahren"), + (lambda: leiste.menues[haendler].offen, pruefen_und_esc, "prüfen + schließen"), + (lambda: leiste.offener_knopf is None, lambda: None, "alles zu"), + ]) + assert not lauf.uebrig, lauf.diagnose() + assert m["offen"] is haendler and m["popup_offen"] + assert not leiste.menues[datei].offen + lauf.pruefen() + + +def test_menue_geht_unter_seinem_menuepunkt_auf(lauf_mit_daten): + """Unter Windows landete das Menü zunächst oben links in der Bildschirmecke.""" + lauf = lauf_mit_daten + leiste = lauf.app.menueleiste + m = {} + for name in ("Datei", "Händler"): + knopf = leiste.knoepfe[name] + + def messen(knopf=knopf, name=name): + f = leiste.menues[knopf].fenster + m[name] = (f.winfo_rootx() - knopf.winfo_rootx(), + f.winfo_rooty() - (knopf.winfo_rooty() + knopf.winfo_height())) + leiste.schliessen() + lauf.ablauf([(IMMER, lambda knopf=knopf: leiste._klick(knopf), f"{name} öffnen"), + (lambda knopf=knopf: leiste.menues[knopf].offen, lambda: lauf.root.after(100, messen), "messen"), + (lambda name=name: name in m, lambda: None, "fertig")]) + assert not lauf.uebrig, lauf.diagnose() + for name, (dx, dy) in m.items(): + assert abs(dx) <= 3 and abs(dy) <= 3, f"Menü „{name}“ nicht unter seinem Menüpunkt (Abstand {dx}, {dy})" + lauf.pruefen() + + +def test_menue_befehl_wird_ausgefuehrt(lauf_mit_daten): + from provtool.gui.assistent import TippDesTages + lauf = lauf_mit_daten + leiste = lauf.app.menueleiste + hilfe = leiste.knoepfe["?"] + + def tipp_waehlen(): + popup = leiste.menues[hilfe] + i = next(i for i, (e, _, _) in enumerate(popup.zeilen) if e["label"].startswith("Tipp des Tages")) + popup._ausfuehren(i) + + lauf.ablauf([ + (IMMER, lambda: leiste._klick(hilfe), "? öffnen"), + (lambda: leiste.menues[hilfe].offen, tipp_waehlen, "Tipp des Tages wählen"), + (lambda: lauf.dialog(TippDesTages) is not None, lambda: lauf.dialog(TippDesTages).abbrechen(), "zu"), + ]) + assert not lauf.uebrig, lauf.diagnose() + assert not leiste.menues[hilfe].offen + lauf.pruefen() + + +def test_hauptfenster_bei_offenem_dialog_verschiebbar_inhalt_gesperrt(lauf_mit_daten, monkeypatch): + """Gemeldet: Bei offenem Dialog ließ sich das Hauptfenster nicht verschieben.""" + from provtool.gui import modal, rahmen + from provtool.gui.dialogs import InfoDialog + monkeypatch.setattr(rahmen, "NATIV_VERSCHIEBEN", False) # Tk-Weg prüfen (nativ macht Windows selbst) + lauf = lauf_mit_daten + app = lauf.app + if not app.rahmen.klassisch: + pytest.skip("nur im Win95-Rahmen") + m = {"neu_aufgerufen": False} + monkeypatch.setattr(type(app), "neu", lambda self: m.__setitem__("neu_aufgerufen", True)) + titel = app.rahmen.titelleiste.titel_lbl + + def pruefen(): + x0 = lauf.root.winfo_x() + rx, ry = titel.winfo_rootx() + 30, titel.winfo_rooty() + 5 + titel.event_generate("", x=30, y=5, rootx=rx, rooty=ry) + titel.event_generate("", x=70, y=5, rootx=rx + 40, rooty=ry) + titel.event_generate("", x=70, y=5, rootx=rx + 40, rooty=ry) + lauf.root.update() + m["verschoben"] = lauf.root.winfo_x() - x0 + knopf = app.tb["Neu"] # Klick auf den gesperrten Inhalt + knopf.event_generate("", x=5, y=5) + knopf.event_generate("", x=5, y=5) + m["gesperrt"] = modal.TAG in app.ptree.bindtags() + lauf.dialog(InfoDialog).abbrechen() + + lauf.ablauf([ + (IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Dialog öffnen"), + (lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.root.after(100, pruefen), "prüfen"), + (lambda: "verschoben" in m and lauf.dialog(InfoDialog) is None, lambda: None, "zu"), + ]) + assert not lauf.uebrig, f"{lauf.uebrig} {m}\n" + "\n".join(lauf.fehler) + assert m["verschoben"] == 40, f"Hauptfenster ließ sich nicht verschieben ({m['verschoben']} px)" + assert m["gesperrt"] and not m["neu_aufgerufen"], "Inhalt des Hauptfensters war nicht gesperrt" + assert modal.TAG not in app.ptree.bindtags(), "Sperre nach dem Schließen nicht aufgehoben" + lauf.pruefen() + + +def test_rand_zeiger_wird_zurueckgesetzt(lauf_mit_daten): + """Gemeldet: Der Größenänderungs-Zeiger blieb manchmal stehen.""" + lauf = lauf_mit_daten + r = lauf.app.rahmen + if not r.klassisch: + pytest.skip("nur im Win95-Rahmen") + ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2})() + r._rand_cursor(ev) + assert r.aussen.cget("cursor"), "am Rand muss der Größenänderungs-Zeiger erscheinen" + r.inhalt.event_generate("") + lauf.root.update() + assert r.aussen.cget("cursor") == "", "Zeiger wurde beim Betreten des Inhalts nicht zurückgesetzt" + # Ecken: in der Nähe des Ecks diagonal + assert r._welcher_rand(2, 10) == "nw" and r._welcher_rand(r.aussen.winfo_width() - 3, 10) == "ne" + lauf.pruefen() + + +def test_eigene_kennungen_ausblenden_in_der_oberflaeche(lauf_mit_daten): + from provtool.db import EIGEN + lauf = lauf_mit_daten + app = lauf.app + alle_vorher = int(app.htree.item("-1", "values")[0]) + app.db.eigene_setzen("freenet", "999") + app._aktualisieren() + assert app.htree.exists(str(EIGEN)), "Eintrag „(Eigene – ausgeblendet)“ fehlt" + assert int(app.htree.item("-1", "values")[0]) == alle_vorher - 1, "eigene Positionen zählen noch mit" + assert all(p["haendler_id"] != EIGEN for p in app._gefiltert()) + lauf.pruefen() + + +@nur_windows +def test_windows_minimieren_mit_offenem_dialog(lauf_mit_daten): + """Minimieren bei offenem Dialog darf den Dialog nicht zerstören; nach dem Wiederherstellen ist er da.""" + from provtool.gui import win32 + from provtool.gui.dialogs import InfoDialog + lauf = lauf_mit_daten + m = {} + + def minimieren(): + lauf.app.rahmen.minimieren() + lauf.root.update() + m["minimiert"] = win32.ist_minimiert(lauf.root) + win32.user32().ShowWindow(win32.hwnd(lauf.root), 9) # SW_RESTORE (wie Klick in der Taskleiste) + lauf.root.update() + m["dialog_da"] = lauf.dialog(InfoDialog) is not None + lauf.dialog(InfoDialog).abbrechen() + + lauf.ablauf([ + (IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Dialog öffnen"), + (lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.root.after(150, minimieren), "minimieren"), + (lambda: "dialog_da" in m, lambda: None, "fertig"), + ], sekunden=12) + assert not lauf.uebrig, lauf.diagnose() + assert m["minimiert"], "Hauptfenster wurde nicht minimiert" + assert m["dialog_da"], "Dialog nach dem Minimieren verschwunden" + lauf.pruefen() diff --git a/tests/test_kern.py b/tests/test_kern.py index c226167..d5c1af4 100644 --- a/tests/test_kern.py +++ b/tests/test_kern.py @@ -265,3 +265,19 @@ def test_migration_alter_datenbank(tmp_path): spalten = {r[1] for r in d.con.execute("PRAGMA table_info(position)")} assert "haendler_fest" in spalten d.close() + + +def test_eigene_kennungen_ausblenden(db, freenet_csv): + from provtool.db import EIGEN + hid = db.haendler_speichern(Haendler(0, "Händler A"), [("freenet", "111")]) + aid = db.abrechnung_neu("X") + db.importieren(aid, lies_datei(freenet_csv)) + assert {p["haendler_id"] for p in db.positionen(aid)} == {hid, UNZUGEORDNET} + db.eigene_setzen("freenet", "999") + ids = {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)} + assert ids["999"] == EIGEN and ids["111"] == hid + # Händler-Kennung hat Vorrang vor „eigen“ + db.eigene_setzen("freenet", "111") + assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["111"] == hid + db.eigene_setzen("freenet", "999", False) + assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["999"] == UNZUGEORDNET