Eigene Kennungen ausblenden, Win95-Menüs, verschiebbares Hauptfenster bei offenem Dialog u. a.

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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-11-02 12:58:18 +01:00
1 parent 2b2d5e8580
commit dcb1d0378c
15 files changed
+1216 -186

No files matched your search

+7 -2
View File
@@ -32,14 +32,19 @@ normale Windows-Titelleiste verwendet.
eines Monats plus die Vodafone-Datei. Das Format wird an der Kopfzeile erkannt. eines Monats plus die Vodafone-Datei. Das Format wird an der Kopfzeile erkannt.
Doppelt importierte Dateien werden erkannt. Unbekannte Kennungen erscheinen unter Doppelt importierte Dateien werden erkannt. Unbekannte Kennungen erscheinen unter
„(Nicht zugeordnet)“ und lassen sich direkt einem Händler zuordnen. „(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 `11111100`,
Vodafone `80000001`) 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. Provisionsart gruppiert.
- Gruppe markieren → **Prozent…** (`F4`): Auszahlung für die ganze Provisionsart; auf Wunsch - 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). als feste Regel für diesen Händler gespeichert (gilt dann für künftige Importe).
- Doppelklick / `F2` auf eine Position: Betrag frei überschreiben - Doppelklick / `F2` auf eine Position: Betrag frei überschreiben
(`Enter` speichern, `↓`/`Tab` nächste Zeile, `Esc` abbrechen). Blau = von Hand geändert. (`Enter` speichern, `↓`/`Tab` nächste Zeile, `Esc` abbrechen). Blau = von Hand geändert.
- Rechtsklick: u. a. „Auszahlung 0 €“. - 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 | | A | B | C | D | E | F | G | H | I | J | K | L | M | N |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|---|---|---|
+1 -1
View File
@@ -1,5 +1,5 @@
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach.""" """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_NAME = "Provisions-Manager 95"
APP_ORG = "Handy-Insel Miesbach" APP_ORG = "Handy-Insel Miesbach"
+27 -2
View File
@@ -45,6 +45,14 @@ CREATE TABLE IF NOT EXISTS regel (
UNIQUE (haendler_id, provisionsart) 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 ( CREATE TABLE IF NOT EXISTS abrechnung (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
titel TEXT NOT NULL, 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 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 # Sonder-Kennung: Vodafone-Positionen, deren Kundenname komplett in GROSSBUCHSTABEN steht
# (so erfasst ein Unterhändler seine Kunden). Hat Vorrang vor der VO-Nr. # (so erfasst ein Unterhändler seine Kunden). Hat Vorrang vor der VO-Nr.
@@ -206,15 +215,31 @@ class Datenbank:
kmap = self.kennung_map() kmap = self.kennung_map()
caps = kmap.get((CAPS_ANBIETER, CAPS_WERT)) caps = kmap.get((CAPS_ANBIETER, CAPS_WERT))
aktive = {h.id for h in self.haendler_liste(nur_aktive=True)} 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: def finde(anbieter: str, kennung: str, name: str | None = None, fest: int | None = None) -> int:
if fest is not None: if fest is not None:
return fest if (fest == UNZUGEORDNET or fest in aktive) else UNZUGEORDNET return fest if (fest == UNZUGEORDNET or fest in aktive) else UNZUGEORDNET
if caps and anbieter == "vodafone" and ist_grossgeschrieben(name): if caps and anbieter == "vodafone" and ist_grossgeschrieben(name):
return caps 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 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 # ------------------------------------------------------------------ Regeln
def regeln(self, hid: int) -> dict[str, float]: def regeln(self, hid: int) -> dict[str, float]:
return {r[0]: r[1] for r in self.con.execute( return {r[0]: r[1] for r in self.con.execute(
@@ -230,7 +255,7 @@ class Datenbank:
(hid, provisionsart, prozent)) (hid, provisionsart, prozent))
def prozent_fuer(self, hid: int, provisionsart: str) -> float: def prozent_fuer(self, hid: int, provisionsart: str) -> float:
if hid == UNZUGEORDNET: if hid <= 0: # nicht zugeordnet / eigene Filiale
return 100.0 return 100.0
r = self.con.execute("SELECT prozent FROM regel WHERE haendler_id=? AND provisionsart=?", r = self.con.execute("SELECT prozent FROM regel WHERE haendler_id=? AND provisionsart=?",
(hid, provisionsart)).fetchone() (hid, provisionsart)).fetchone()
+1 -1
View File
@@ -2,7 +2,7 @@
Spalten: A Zeitraum | B Provisionsart | C Unterhändler-/VO-Nr | D Datum | E Rufnummer | F IMEI/SN | 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 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 from __future__ import annotations
+122 -51
View File
@@ -14,9 +14,11 @@ from tkinter import filedialog
from provtool import APP_NAME, config from provtool import APP_NAME, config
from provtool import export as exp 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 assistent as ass
from provtool.gui import dialogs as dlg 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 rahmen as rh
from provtool.gui import win95 as w from provtool.gui import win95 as w
from provtool.importer import ANBIETER, ImportFehler, lies_datei 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: 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()) m = re.fullmatch(r"(\d{4})\.(\d{2})\.(\d{2})\s*-\s*(\d{4})\.(\d{2})\.(\d{2})", (z or "").strip())
if m: if m:
return f"{m[3]}.{m[2]}.–{m[6]}.{m[5]}." return f"{m[3]}.{m[2]}.–{m[6]}.{m[5]}."
@@ -89,6 +91,8 @@ class App:
self.status.pack(side="bottom", fill="x") self.status.pack(side="bottom", fill="x")
self._tasten() self._tasten()
self.rahmen.geometrie_setzen(self.settings.get("fenster"), self.settings.get("maximiert", False)) 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() abr = self.db.abrechnungen()
if abr: if abr:
@@ -102,43 +106,46 @@ class App:
# ================================================================= Aufbau # ================================================================= Aufbau
def _menue(self): 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") self.menueleiste.pack(side="top", fill="x")
d = self.menueleiste.menue("Datei") d = self.menueleiste.menue("Datei")
d.add_command(label="Neue Abrechnung…", accelerator="Strg+N", command=self.neu) 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="Abrechnung öffnen...", accelerator="Strg+O", command=self.abrechnung_waehlen)
d.add_separator() d.add_separator()
d.add_command(label="Dateien importieren…", accelerator="Strg+I", command=self.importieren) 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="Importierte Dateien...", command=self.dateien)
d.add_command(label="Exportieren…", accelerator="Strg+E", command=self.exportieren) d.add_command(label="Exportieren...", accelerator="Strg+E", command=self.exportieren)
d.add_separator() d.add_separator()
d.add_command(label="Datensicherung erstellen…", command=self.sicherung) d.add_command(label="Datensicherung erstellen...", command=self.sicherung)
d.add_command(label="Datensicherung einspielen…", command=self.wiederherstellen) d.add_command(label="Datensicherung einspielen...", command=self.wiederherstellen)
d.add_command(label="Einstellungen…", command=self.einstellungen) d.add_command(label="Einstellungen...", command=self.einstellungen)
d.add_separator() d.add_separator()
d.add_command(label="Beenden", accelerator="Alt+F4", command=self.beenden) d.add_command(label="Beenden", accelerator="Alt+F4", command=self.beenden)
b = self.menueleiste.menue("Bearbeiten") b = self.menueleiste.menue("Bearbeiten")
b.add_command(label="Betrag ändern…", accelerator="F2", command=self.betrag_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="Auszahlung in Prozent...", accelerator="F4", command=self.prozent)
b.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen) b.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen)
b.add_separator() b.add_separator()
b.add_command(label="Alle Beträge neu berechnen", command=self.neu_berechnen) b.add_command(label="Alle Beträge neu berechnen", command=self.neu_berechnen)
h = self.menueleiste.menue("Händler") h = self.menueleiste.menue("Händler")
h.add_command(label="Händler verwalten…", accelerator="Strg+H", command=self.haendler) 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="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 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_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 = self.menueleiste.menue("?", underline=None)
hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe) 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="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_separator()
hi.add_command(label="Einrichtungs-Assistent…", command=self.assistent) hi.add_command(label="Einrichtungs-Assistent...", command=self.assistent)
hi.add_separator() 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): def _toolbar(self):
tb = tk.Frame(self.inhalt, bg=w.FACE, bd=1, relief="raised") 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") tk.Label(sf, text="Suchen:").pack(side="left")
e = tk.Entry(sf, textvariable=self.v_suche, width=22) e = tk.Entry(sf, textvariable=self.v_suche, width=22)
e.pack(side="left", padx=4) 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._suchfeld = e
self._suchrahmen = sf self._suchrahmen = sf
def _body(self): def _body(self):
desk = tk.Frame(self.inhalt, bg=w.TEAL) desk = tk.Frame(self.inhalt, bg=w.TEAL)
desk.pack(side="top", fill="both", expand=True) 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) pw.pack(fill="both", expand=True, padx=6, pady=6)
# --- Händler-Fenster # --- Händler-Fenster
@@ -218,9 +228,9 @@ class App:
leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE) leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE)
leiste.pack(fill="x", pady=(4, 0)) 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") 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") w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left")
self.v_alle_auf = tk.BooleanVar(value=True) self.v_alle_auf = tk.BooleanVar(value=True)
w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right") w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right")
@@ -231,13 +241,18 @@ class App:
def _tasten(self): def _tasten(self):
r = self.root r = self.root
r.bind_all("<Control-n>", lambda e: self.neu())
r.bind_all("<Control-o>", lambda e: self.abrechnung_waehlen()) def frei(befehl):
r.bind_all("<Control-i>", lambda e: self.importieren()) # Tastenkürzel des Hauptfensters nicht ausführen, solange ein Dialog offen ist
r.bind_all("<Control-e>", lambda e: self.exportieren()) # (sonst öffnete z. B. Strg+N im Dialog einen zweiten Dialog darüber)
r.bind_all("<Control-h>", lambda e: self.haendler()) return lambda e: None if modal.offen() else befehl()
r.bind_all("<Control-f>", lambda e: self._suchfeld.focus_set()) r.bind_all("<Control-n>", frei(self.neu))
r.bind_all("<F1>", lambda e: self.hilfe()) r.bind_all("<Control-o>", frei(self.abrechnung_waehlen))
r.bind_all("<Control-i>", frei(self.importieren))
r.bind_all("<Control-e>", frei(self.exportieren))
r.bind_all("<Control-h>", frei(self.haendler))
r.bind_all("<Control-f>", frei(self._suchfeld.focus_set))
r.bind_all("<F1>", frei(self.hilfe))
self.ptree.bind("<F2>", lambda e: self.betrag_bearbeiten()) self.ptree.bind("<F2>", lambda e: self.betrag_bearbeiten())
self.ptree.bind("<Return>", lambda e: self.betrag_bearbeiten()) self.ptree.bind("<Return>", lambda e: self.betrag_bearbeiten())
self.ptree.bind("<F4>", lambda e: self.prozent()) self.ptree.bind("<F4>", lambda e: self.prozent())
@@ -258,13 +273,23 @@ class App:
self.aid = None self.aid = None
self.positionen = [] self.positionen = []
self.rahmen.set_titel(APP_NAME) 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._haendlerliste(behalte_auswahl)
self._tabelle() 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]: def _gefiltert(self) -> list[dict]:
if self.ansicht == ALLE: 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] return [p for p in self.positionen if p["haendler_id"] == self.ansicht]
def _haendlerliste(self, behalte_auswahl=True): def _haendlerliste(self, behalte_auswahl=True):
@@ -276,7 +301,7 @@ class App:
s[0] += 1 s[0] += 1
s[1] += p["betrag"] s[1] += p["betrag"]
s[2] += p["betrag_orig"] 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",)) t.insert("", "end", iid=str(ALLE), text="(Alle Positionen)", values=(ges[0], fmt(ges[1])), tags=("fett",))
for h in self.db.haendler_liste(): for h in self.db.haendler_liste():
s = stat.get(h.id, [0, 0, 0]) s = stat.get(h.id, [0, 0, 0])
@@ -286,6 +311,10 @@ class App:
if s: if s:
t.insert("", "end", iid=str(UNZUGEORDNET), text="(Nicht zugeordnet)", values=(s[0], fmt(s[1])), t.insert("", "end", iid=str(UNZUGEORDNET), text="(Nicht zugeordnet)", values=(s[0], fmt(s[1])),
tags=("grau",)) 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) ziel = str(self.ansicht) if behalte_auswahl and t.exists(str(self.ansicht)) else str(ALLE)
self.ansicht = int(ziel) self.ansicht = int(ziel)
t.selection_set(ziel) t.selection_set(ziel)
@@ -302,9 +331,11 @@ class App:
self.haendler(self.ansicht) self.haendler(self.ansicht)
elif self.ansicht == UNZUGEORDNET: elif self.ansicht == UNZUGEORDNET:
self.kennung_zuordnen_auswahl() self.kennung_zuordnen_auswahl()
elif self.ansicht == EIGEN:
self.eigene_verwalten()
def _gruppe_von(self, p: dict) -> str: 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)'}" return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · Kennung {p['kennung'] or '(leer)'}"
if self.ansicht == ALLE: if self.ansicht == ALLE:
name = self.haendler_namen.get(p["haendler_id"], "(Nicht zugeordnet)") name = self.haendler_namen.get(p["haendler_id"], "(Nicht zugeordnet)")
@@ -351,7 +382,8 @@ class App:
fmt(p["betrag_orig"]), fmt(p["betrag_orig"]),
"manuell" if p["manuell"] else fmt_percent(p["prozent"]), fmt(p["betrag"])), "manuell" if p["manuell"] else fmt_percent(p["prozent"]), fmt(p["betrag"])),
tags=tuple(tags)) 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.ansicht, self.haendler_namen.get(self.ansicht, ""))
self.f_p.set_titel(f"Positionen – {name}") self.f_p.set_titel(f"Positionen – {name}")
so = sum(p["betrag_orig"] for p in pos) so = sum(p["betrag_orig"] for p in pos)
@@ -464,8 +496,10 @@ class App:
u[0] += 1 u[0] += 1
u[1] += p["betrag_orig"] 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() res = dlg.ImportBericht(self.root, bericht, [(a, k, n, s) for (a, k), (n, s) in sorted(unbekannt.items())]).zeigen()
if res: if res and res[0] == "zuordnen":
self.kennung_zuordnen(*res) self.kennung_zuordnen(res[1], res[2])
elif res and res[0] == "eigen":
self.eigene_ausblenden(res[1])
def dateien(self): def dateien(self):
if not self.aid: if not self.aid:
@@ -599,7 +633,7 @@ class App:
pid = int(iid[1:]) pid = int(iid[1:])
p = next(x for x in self.positionen if x["id"] == pid) p = next(x for x in self.positionen if x["id"] == pid)
if not bbox: 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) self._betrag_dialog(p)
return return
x, y, bw, bh = bbox x, y, bw, bh = bbox
@@ -675,20 +709,56 @@ class App:
return return
if iid not in self.ptree.selection(): if iid not in self.ptree.selection():
self.ptree.selection_set(iid) self.ptree.selection_set(iid)
m = tk.Menu(self.root, tearoff=0) m = mn.Popup(self.root)
if iid.startswith("p"): if iid.startswith("p"):
m.add_command(label="Betrag ändern…", command=lambda: self.betrag_bearbeiten(iid)) 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="Auszahlung in Prozent...", command=self.prozent)
m.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen) m.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen)
m.add_separator() m.add_separator()
m.add_command(label="Auszahlung 0 € (nicht auszahlen)", command=lambda: self._null()) m.add_command(label="Auszahlung 0 € (nicht auszahlen)", command=lambda: self._null())
m.add_separator() 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)) m.add_command(label="Automatisch zuordnen", command=lambda: self.haendler_zuweisen(automatisch=True))
if self.ansicht == UNZUGEORDNET or self.ansicht == ALLE: 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) 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): def haendler_zuweisen(self, automatisch: bool = False):
sel, pids, _ = self._auswahl_info() sel, pids, _ = self._auswahl_info()
if not pids: if not pids:
@@ -746,7 +816,7 @@ class App:
"Ist sie vielleicht noch in Excel geöffnet?") "Ist sie vielleicht noch in Excel geöffnet?")
return return
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}\n\n" 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"]: if res["oeffnen"]:
_oeffne_ordner(res["ordner"]) _oeffne_ordner(res["ordner"])
@@ -815,15 +885,15 @@ class App:
w.info(self.root, "Kurzanleitung", w.info(self.root, "Kurzanleitung",
"1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in % und die Kennungen –\n" "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" " 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" "2. Neue Abrechnung (Strg+N), z. B. „Oktober 2026“.\n\n"
"3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n" "3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n"
" auch alle Wochenlisten eines Monats.\n\n" " auch alle Wochenlisten eines Monats.\n\n"
"4. Links den Händler wählen. Positionen sind nach Provisionsart gruppiert.\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" " optional als Standard für diesen Händler merken.\n"
" • Doppelklick / F2 auf eine Position: Betrag frei ändern\n" " - Doppelklick / F2 auf eine Position: Betrag frei ändern\n"
" (Enter = speichern, ↓/Tab = nächste Zeile, Esc = abbrechen).\n\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" "5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat.\n\n"
"Alles wird automatisch in der Datenbank gespeichert.") "Alles wird automatisch in der Datenbank gespeichert.")
@@ -868,9 +938,9 @@ class App:
"Das wird nicht exportiert."), "Das wird nicht exportiert."),
(self.f_p, "Positionen prüfen", (self.f_p, "Positionen prüfen",
"Rechts stehen die Positionen des gewählten Händlers, gruppiert nach Provisionsart.\n\n" "Rechts stehen die Positionen des gewählten Händlers, gruppiert nach Provisionsart.\n\n"
"• Doppelklick auf eine Position: Betrag frei ändern\n" "- Doppelklick auf eine Position: Betrag frei ändern\n"
"• Rechtsklick: Auszahlung 0 €, Händler zuweisen, …\n" "- Rechtsklick: Auszahlung 0 €, Händler zuweisen, ...\n"
"• Blau = von Hand geändert, Rot = durch Prozent-Regel geändert"), "- Blau = von Hand geändert, Rot = durch Prozent-Regel geändert"),
(self.b_prozent, "Auszahlung in Prozent", (self.b_prozent, "Auszahlung in Prozent",
"Graue Gruppenzeile (Provisionsart) markieren und hier klicken – z. B. 80 % für den Sonderbonus. " "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."), "Mit „Als Standard merken“ gilt das auch in den nächsten Monaten automatisch."),
@@ -955,6 +1025,7 @@ class App:
return return
self._beendet = True self._beendet = True
try: try:
self.settings["trenner"] = self.pw.sash_coord(0)[0]
self.settings["fenster"] = self.rahmen.normale_geometrie() self.settings["fenster"] = self.rahmen.normale_geometrie()
self.settings["maximiert"] = self.rahmen.maximiert self.settings["maximiert"] = self.rahmen.maximiert
config.save(self.settings) config.save(self.settings)
+22 -20
View File
@@ -18,7 +18,7 @@ DATEITYPEN = [("Abrechnungen (CSV, Excel)", "*.csv *.xlsx *.xlsm *.xls *.txt"),
def _nummern(text: str) -> list[str]: def _nummern(text: str) -> list[str]:
"""'11111102 (6 Pos.), 27518999' → ['11111102', '27518999']""" """'11111102 (6 Pos.), 27518999' -> ['11111102', '27518999']"""
out = [] out = []
for teil in text.split(","): for teil in text.split(","):
m = re.match(r"\s*([A-Za-z0-9]+)", teil) m = re.match(r"\s*([A-Za-z0-9]+)", teil)
@@ -122,7 +122,7 @@ class Einrichtungsassistent(w.Dialog):
if w.frage(self, "Einrichtung abbrechen", if w.frage(self, "Einrichtung abbrechen",
"Möchten Sie die Einrichtung wirklich abbrechen?\n\n" "Möchten Sie die Einrichtung wirklich abbrechen?\n\n"
"Bereits angelegte Händler bleiben gespeichert. Den Assistenten finden Sie jederzeit " "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.result = None
self.destroy() self.destroy()
@@ -133,7 +133,7 @@ class Einrichtungsassistent(w.Dialog):
for punkt in ("Speicherort der Daten festlegen", for punkt in ("Speicherort der Daten festlegen",
"Unterhändler mit ihren Kennungen anlegen", "Unterhändler mit ihren Kennungen anlegen",
"die erste Abrechnung anlegen und Dateien importieren"): "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" self._text("\nDanach zeigt Ihnen ein kurzer Rundgang, wo Sie was finden.\n\n"
"Alle Angaben lassen sich später jederzeit ändern.") "Alle Angaben lassen sich später jederzeit ändern.")
self._text("Klicken Sie auf „Weiter“, um fortzufahren.", fg=w.NAVY) 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 = tk.Entry(zeile, textvariable=self.v_db_pfad)
e.pack(side="left", fill="x", expand=True) e.pack(side="left", fill="x", expand=True)
e.bind("<Key>", lambda ev: self.v_db_eigen.set(True)) e.bind("<Key>", 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 – " self._text("\nTipp: Auf einem Netzlaufwerk können mehrere PCs dieselben Händler nutzen – "
"bitte aber nicht gleichzeitig damit arbeiten.", fg=w.SHADOW) "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 = tk.Frame(self.seite, bg=w.FACE)
k.pack(fill="x", pady=(4, 4)) 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, "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") tk.Label(self.seite, text="Bereits angelegt:", anchor="w").pack(fill="x")
lf = tk.Frame(self.seite, bg=w.FACE) 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)) w.warnung(self, "Nummern vorschlagen", "Nicht lesbar:\n" + "\n".join(fehler))
else: else:
w.info(self, "Nummern vorschlagen", 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 " "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.") "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) self.lb_d.pack(side="left", fill="both", expand=True)
kf = tk.Frame(lf, bg=w.FACE) kf = tk.Frame(lf, bg=w.FACE)
kf.pack(side="left", anchor="n", padx=(6, 0)) 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_dazu.pack(pady=(0, 4))
self._b_weg = w.knopf(kf, "Entfernen", self._datei_weg, breite=11) self._b_weg = w.knopf(kf, "Entfernen", self._datei_weg, breite=11)
self._b_weg.pack() self._b_weg.pack()
@@ -385,12 +385,12 @@ class Einrichtungsassistent(w.Dialog):
self._ueberschrift("Fertig!") self._ueberschrift("Fertig!")
n = len(self.app.db.haendler_liste()) n = len(self.app.db.haendler_liste())
self._text(f"Der {APP_NAME} ist eingerichtet.") 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(): 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)")) + (f" mit {len(self.dateien)} Datei(en)" if self.dateien else " (noch ohne Dateien)"))
self._text("\nSo geht es danach jeden Monat weiter:\n" 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 tk.Checkbutton(self.seite, text="Kurzen Rundgang durch das Programm starten", variable=self.v_rundgang
).pack(anchor="w", pady=(12, 0)) ).pack(anchor="w", pady=(12, 0))
self._text("\nKlicken Sie auf „Fertig stellen“.", fg=w.NAVY) self._text("\nKlicken Sie auf „Fertig stellen“.", fg=w.NAVY)
@@ -539,21 +539,21 @@ class Rundgang:
TIPPS = [ TIPPS = [
"Sie können mehrere Dateien gleichzeitig importieren – zum Beispiel alle freenet-Wochenlisten eines Monats " "Sie können mehrere Dateien gleichzeitig importieren – zum Beispiel alle freenet-Wochenlisten eines Monats "
"und die Vodafone-Datei in einem Rutsch.", "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.", "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.", "gleich in die nächste Zeile.",
"Blau dargestellte Beträge wurden von Hand geändert, rote weichen durch eine Prozent-Regel von der " "Blau dargestellte Beträge wurden von Hand geändert, rote weichen durch eine Prozent-Regel von der "
"Provision ab.", "Provision ab.",
"Eine Position soll gar nicht ausgezahlt werden? Rechtsklick ▸ „Auszahlung 0 €“.", "Eine Position soll gar nicht ausgezahlt werden? Rechtsklick > „Auszahlung 0 €“.",
"Die GROSSBUCHSTABEN-Regel hat einen Kunden falsch erwischt? Rechtsklick ▸ „Händler zuweisen…“ – diese " "Die GROSSBUCHSTABEN-Regel hat einen Kunden falsch erwischt? Rechtsklick > „Händler zuweisen...“ – diese "
"Zuordnung hat immer Vorrang.", "Zuordnung hat immer Vorrang.",
"Unter „(Nicht zugeordnet)“ sehen Sie alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. " "Unter „(Nicht zugeordnet)“ sehen Sie alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. "
"Diese Positionen werden nicht exportiert.", "Diese Positionen werden nicht exportiert.",
"Mit Strg+F springen Sie ins Suchfeld. Gesucht wird in Rufnummer, Name, Tarif, Provisionsart und mehr.", "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.", "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.", "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.", "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 if self._bild is None: # Ersatz, falls das Bild fehlt
self._bild = w.make_icon(self, "birne", 3) 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) 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") 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") anchor="w").pack(fill="x")
tk.Frame(rechts, bg=w.DARK, height=1).pack(fill="x", pady=6) 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 = tk.Label(rechts, bg=w.WHITE, justify="left", anchor="nw", wraplength=380 - 2 * 12)
self.text.pack(fill="both") self.text.pack(fill="both", expand=True)
knoepfe = tk.Frame(haupt, bg=w.FACE) knoepfe = tk.Frame(haupt, bg=w.FACE)
knoepfe.pack(side="left", anchor="n", padx=(10, 0)) knoepfe.pack(side="left", anchor="n", padx=(10, 0))
w.knopf(knoepfe, "Schließen", self.abbrechen, breite=12, default="active").pack(pady=(0, 6)) w.knopf(knoepfe, "Schließen", self.abbrechen, breite=12, default="active").pack(pady=(0, 6))
+77 -11
View File
@@ -9,7 +9,7 @@ from provtool.db import CAPS_ANBIETER, CAPS_WERT, Datenbank, Haendler
from provtool.gui import win95 as w from provtool.gui import win95 as w
from provtool.importer import ANBIETER 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.)", KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)",
CAPS_ANBIETER: "Vodafone (Name in GROSSBUCHSTABEN)"} CAPS_ANBIETER: "Vodafone (Name in GROSSBUCHSTABEN)"}
CAPS_TEXT = "Kunden-Nachname komplett in GROSSBUCHSTABEN" CAPS_TEXT = "Kunden-Nachname komplett in GROSSBUCHSTABEN"
@@ -100,7 +100,7 @@ class HaendlerDialog(w.Dialog):
r3.pack(fill="both", expand=True) r3.pack(fill="both", expand=True)
rf = tk.Frame(g3, bg=w.FACE) rf = tk.Frame(g3, bg=w.FACE)
rf.pack(fill="x", pady=(4, 0)) 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") w.knopf(rf, "Entfernen", self._regel_weg).pack(side="right")
unten = tk.Frame(self, bg=w.FACE) unten = tk.Frame(self, bg=w.FACE)
@@ -292,7 +292,7 @@ class ProzentDialog(w.Dialog):
from provtool.money import apply_percent from provtool.money import apply_percent
try: try:
p = parse_percent(self.v.get()) 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: except ValueError:
self.vorschau.configure(text="(ungültiger Wert)") self.vorschau.configure(text="(ungültiger Wert)")
@@ -327,7 +327,7 @@ class AbrechnungenDialog(w.Dialog):
k = tk.Frame(self, bg=w.FACE) k = tk.Frame(self, bg=w.FACE)
k.pack(fill="x", padx=10, pady=10) k.pack(fill="x", padx=10, pady=10)
w.knopf(k, "Öffnen", self._oeffnen, breite=10, default="active").pack(side="left") 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, "Löschen", self._loeschen, breite=10).pack(side="left")
w.knopf(k, "Abbrechen", self.abbrechen, breite=10).pack(side="right") w.knopf(k, "Abbrechen", self.abbrechen, breite=10).pack(side="right")
self.bind("<Return>", lambda e: self._oeffnen()) self.bind("<Return>", 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):", 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)) anchor="w").pack(fill="x", padx=10, pady=(10, 2))
r2, self.ut = w.scroll_tree(self, columns=("anbieter", "kennung", "anzahl", "summe"), show="headings", 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"), for c, h, b, a in (("anbieter", "Anbieter", 90, "w"), ("kennung", "Kennung", 140, "w"),
("anzahl", "Pos.", 50, "e"), ("summe", "Summe", 90, "e")): ("anzahl", "Pos.", 50, "e"), ("summe", "Summe", 90, "e")):
self.ut.heading(c, text=h) self.ut.heading(c, text=h)
@@ -451,14 +451,80 @@ class ImportBericht(w.Dialog):
k = tk.Frame(self, bg=w.FACE) k = tk.Frame(self, bg=w.FACE)
k.pack(fill="x", padx=10, pady=10) k.pack(fill="x", padx=10, pady=10)
if unbekannt: 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") w.knopf(k, "OK", self.abbrechen, breite=10, default="active").pack(side="right")
self.bind("<Return>", lambda e: self.abbrechen()) self.bind("<Return>", lambda e: self.abbrechen())
def _zuordnen(self): def _zuordnen(self):
s = self.ut.selection() s = self.ut.selection()
if s: 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("<Return>", 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() self.destroy()
@@ -473,7 +539,7 @@ class KennungZuordnen(w.Dialog):
self.v = tk.StringVar(value="__neu__") self.v = tk.StringVar(value="__neu__")
f = tk.Frame(self, bg=w.FACE) f = tk.Frame(self, bg=w.FACE)
f.pack(fill="x", padx=20) 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() haendler = db.haendler_liste()
if haendler: if haendler:
tk.Radiobutton(f, text="Bestehendem Händler:", variable=self.v, value="__alt__").pack(anchor="w") 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 = ttk.Combobox(self, values=list(self._hmap) + [self.KEINER], state="readonly", width=40)
self.cb.pack(padx=12, pady=4) self.cb.pack(padx=12, pady=4)
tk.Label(self, text="Die feste Zuweisung hat Vorrang vor Kennungen und der GROSSBUCHSTABEN-Regel.\n" 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) fg=w.SHADOW).pack(fill="x", padx=12)
self.bind("<Return>", lambda e: self._ok()) self.bind("<Return>", lambda e: self._ok())
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=12) 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") tk.Label(g, text="Zielordner:").grid(row=1, column=0, sticky="w")
self.v_ordner = tk.StringVar(value=settings.get("export_ordner") or "") 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) 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_anb = tk.BooleanVar(value=settings.get("export_pro_anbieter", True))
self.v_kopf = tk.BooleanVar(value=settings.get("export_kopfzeile", False)) self.v_kopf = tk.BooleanVar(value=settings.get("export_kopfzeile", False))
self.v_sum = tk.BooleanVar(value=settings.get("export_summe", True)) 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") row=0, column=0, columnspan=2, sticky="w")
self.v = tk.StringVar(value=db_pfad) self.v = tk.StringVar(value=db_pfad)
tk.Entry(g, textvariable=self.v, width=56).grid(row=1, column=0, sticky="we", pady=4) 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" 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.", "Händler nutzen – aber bitte nicht gleichzeitig damit arbeiten.",
justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w") justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w")
+298
View File
@@ -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("<Enter>", lambda ev, i=idx: self._markieren(i))
wdg.bind("<ButtonRelease-1>", 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("<ButtonPress>", self._klick)
f.bind("<Motion>", self._bewegung)
f.bind("<Escape>", lambda e: self.schliessen())
f.bind("<Up>", lambda e: self._schritt(-1))
f.bind("<Down>", lambda e: self._schritt(1))
f.bind("<Return>", lambda e: self._ausfuehren(self.aktiv))
f.bind("<KP_Enter>", lambda e: self._ausfuehren(self.aktiv))
f.bind("<Left>", lambda e: self.leiste and self.leiste.nachbar(-1))
f.bind("<Right>", 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("<ButtonPress-1>", lambda e, k=knopf: self._klick(k))
knopf.bind("<Enter>", lambda e, k=knopf: self._hover(k, True))
knopf.bind("<Leave>", lambda e, k=knopf: self._hover(k, False))
if underline is not None:
taste = label[underline].lower()
self.root.bind_all(f"<Alt-KeyPress-{taste}>", 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"
+93
View File
@@ -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 = ("<ButtonPress>", "<ButtonRelease>", "<Double-ButtonPress>", "<Triple-ButtonPress>",
"<B1-Motion>", "<KeyPress>", "<KeyRelease>", "<MouseWheel>")
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)
+79 -34
View File
@@ -183,38 +183,33 @@ class Titelleiste(tk.Frame):
def _start(self, e): def _start(self, e):
self._ab = (e.x_root - self.fenster.winfo_rootx(), e.y_root - self.fenster.winfo_rooty()) 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): def _ziehen(self, e):
if not self.ziehen_erlaubt(): if self._nativ or not self.ziehen_erlaubt():
return return
self.fenster.geometry(f"+{e.x_root - self._ab[0]}+{e.y_root - self._ab[1]}") 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): def schritt(i=0):
"""Graue Menüleiste (die native von Windows 10/11 ist weiß und lässt sich nicht umfärben).""" 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: NATIV_VERSCHIEBEN = True # Tests können das abschalten, um den Tk-Weg zu prüfen
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"<Alt-KeyPress-{taste}>", lambda e, m=mb: self.oeffnen(m))
return menu
def oeffnen(self, mb: tk.Menubutton):
menu = mb.nametowidget(mb.cget("menu"))
menu.tk_popup(mb.winfo_rootx(), mb.winfo_rooty() + mb.winfo_height())
return "break"
# --------------------------------------------------------------------------- Hauptfenster # --------------------------------------------------------------------------- Hauptfenster
@@ -222,7 +217,8 @@ class Menueleiste(tk.Frame):
class Hauptrahmen: class Hauptrahmen:
"""Umgibt das Hauptfenster. Im klassischen Modus mit eigener Titelleiste, sonst nur ein Container.""" """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, def __init__(self, root: tk.Tk, titel: str, icon: tk.PhotoImage, schliessen, klassisch: bool = True,
minbreite: int = 900, minhoehe: int = 520): minbreite: int = 900, minhoehe: int = 520):
@@ -237,10 +233,11 @@ class Hauptrahmen:
self.aussen.pack(fill="both", expand=True) self.aussen.pack(fill="both", expand=True)
self.inhalt = self.aussen self.inhalt = self.aussen
self.titelleiste = None self.titelleiste = None
root.sperrbereich = lambda: [self.inhalt] # type: ignore[attr-defined] # für modal.py
return return
root.overrideredirect(True) 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.aussen.pack(fill="both", expand=True)
self.titelleiste = Titelleiste(self.aussen, root, titel, icon, ("min", "max", "close"), self.titelleiste = Titelleiste(self.aussen, root, titel, icon, ("min", "max", "close"),
schliessen=schliessen, minimieren=self.minimieren, schliessen=schliessen, minimieren=self.minimieren,
@@ -249,10 +246,17 @@ class Hauptrahmen:
self.titelleiste.ziehen_erlaubt = lambda: not self.maximiert self.titelleiste.ziehen_erlaubt = lambda: not self.maximiert
self.inhalt = tk.Frame(self.aussen, bg=w.FACE) self.inhalt = tk.Frame(self.aussen, bg=w.FACE)
self.inhalt.pack(fill="both", expand=True) 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._rand: str = ""
self._resize_geplant = None
self.aussen.bind("<Motion>", self._rand_cursor) self.aussen.bind("<Motion>", self._rand_cursor)
self.aussen.bind("<Leave>", 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("<Leave>", self._cursor_zurueck)
self.inhalt.bind("<Enter>", self._cursor_zurueck)
self.titelleiste.bind("<Enter>", self._cursor_zurueck, add="+")
self.aussen.bind("<ButtonPress-1>", self._resize_start) self.aussen.bind("<ButtonPress-1>", self._resize_start)
self.aussen.bind("<B1-Motion>", self._resize) self.aussen.bind("<B1-Motion>", self._resize)
root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget)) root.bind_all("<Alt-F4>", 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 # 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 # (unter Windows liefert Tk Tastendrücke an das Fenster mit Fokus, das kann noch das
# Hauptfenster sein). # Hauptfenster sein).
sperre = self.root.grab_current() from provtool.gui import modal
sperre = self.root.grab_current() or modal.oberstes()
popup = self._offenes_popup() popup = self._offenes_popup()
if sperre is not None and sperre.winfo_toplevel() is not self.root: if sperre is not None and sperre.winfo_toplevel() is not self.root:
fenster = sperre.winfo_toplevel() fenster = sperre.winfo_toplevel()
@@ -275,6 +280,11 @@ class Hauptrahmen:
except tk.TclError: except tk.TclError:
fenster = self.root fenster = self.root
if fenster is 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() self._schliessen()
elif hasattr(fenster, "abbrechen"): elif hasattr(fenster, "abbrechen"):
fenster.abbrechen() fenster.abbrechen()
@@ -338,7 +348,14 @@ class Hauptrahmen:
# ---- Fensterfunktionen # ---- Fensterfunktionen
def minimieren(self): 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() geo = self.root.geometry()
self.root.overrideredirect(False) self.root.overrideredirect(False)
self.root.iconify() self.root.iconify()
@@ -373,9 +390,25 @@ class Hauptrahmen:
# ---- Größe ändern # ---- Größe ändern
def _welcher_rand(self, x, y) -> str: def _welcher_rand(self, x, y) -> str:
bw, bh, r = self.aussen.winfo_width(), self.aussen.winfo_height(), self.RAND bw, bh, r, k = self.aussen.winfo_width(), self.aussen.winfo_height(), self.RAND, self.ECKE
rand = ("n" if y < r else "s" if y >= bh - r else "") + ("w" if x < r else "e" if x >= bw - r else "") ns = "n" if y < r else "s" if y >= bh - r else ""
return rand 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", 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"} "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: if self.maximiert:
return return
rand = self._welcher_rand(e.x, e.y) rand = self._welcher_rand(e.x, e.y)
zeiger = (self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "")
try: 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: except tk.TclError:
pass pass
@@ -412,4 +447,14 @@ class Hauptrahmen:
if "n" in self._rand: if "n" in self._rand:
neu = max(self.minhoehe, bh - dy) neu = max(self.minhoehe, bh - dy)
y, bh = y + (bh - neu), neu 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
+70
View File
@@ -50,6 +50,20 @@ def user32():
u.GetTopWindow.restype = wintypes.HWND u.GetTopWindow.restype = wintypes.HWND
u.BringWindowToTop.argtypes = [wintypes.HWND] u.BringWindowToTop.argtypes = [wintypes.HWND]
u.BringWindowToTop.restype = wintypes.BOOL 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 _u32 = u
return _u32 return _u32
@@ -82,6 +96,62 @@ def nach_vorn(fenster: tk.Misc) -> None:
user32().BringWindowToTop(h) 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: def besitzer_setzen(fenster: tk.Misc, besitzer: tk.Misc) -> None:
h, b = hwnd(fenster), hwnd(besitzer) h, b = hwnd(fenster), hwnd(besitzer)
if h and b: if h and b:
+124 -43
View File
@@ -294,6 +294,9 @@ def apply(root: tk.Tk) -> None:
o("*Background", FACE) o("*Background", FACE)
o("*Foreground", DARK) o("*Foreground", DARK)
o("*activeBackground", FACE) 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("*highlightThickness", 0)
o("*selectBackground", NAVY) o("*selectBackground", NAVY)
o("*selectForeground", WHITE) o("*selectForeground", WHITE)
@@ -322,6 +325,7 @@ def apply(root: tk.Tk) -> None:
from tkinter import font as tkfont from tkinter import font as tkfont
zeile = max(18, tkfont.Font(root=root, font=FONT).metrics("linespace") + 5) zeile = max(18, tkfont.Font(root=root, font=FONT).metrics("linespace") + 5)
wort_loeschen_einrichten(root)
st = ttk.Style(root) st = ttk.Style(root)
st.theme_use("classic" if "classic" in st.theme_names() else "default") st.theme_use("classic" if "classic" in st.theme_names() else "default")
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=LIGHT, 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) 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, "<Control-BackSpace>", lambda e: _wort_weg(e, -1))
root.bind_class(klasse, "<Control-Delete>", lambda e: _wort_weg(e, 1))
root.bind_class("Text", "<Control-BackSpace>", lambda e: _text_wort_weg(e, -1))
root.bind_class("Text", "<Control-Delete>", lambda e: _text_wort_weg(e, 1))
# --------------------------------------------------------------------------- Bausteine # --------------------------------------------------------------------------- Bausteine
class Sunken(tk.Frame): class Sunken(tk.Frame):
@@ -434,8 +510,12 @@ class Tooltip:
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4 y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
self.tip = tw = tk.Toplevel(self.widget) self.tip = tw = tk.Toplevel(self.widget)
tw.wm_overrideredirect(True) 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() 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): def _weg(self, _e=None):
if self._after: if self._after:
@@ -502,19 +582,24 @@ KLASSISCH = False
class Dialog(tk.Toplevel): 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): def __init__(self, master, titel: str, resizable: bool = False):
super().__init__(master, bg=FACE) super().__init__(master, bg=FACE)
self.withdraw() self.withdraw()
self.title(titel) self.title(titel)
if not KLASSISCH: if not KLASSISCH:
# Rahmenlose Dialoge bekommen ihren Besitzer über win32.Modal – mit transient() würde Tk # Rahmenlose Dialoge bekommen ihren Besitzer über win32 – mit transient() würde Tk sie
# sie unter Windows als Kindfenster anlegen, die dann hinter anderen Programmen hängen. # unter Windows als Kindfenster anlegen, die dann hinter anderen Programmen hängen.
self.transient(master.winfo_toplevel()) self.transient(master.winfo_toplevel())
self.resizable(resizable, resizable) self.resizable(resizable, resizable)
self.result = None 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.protocol("WM_DELETE_WINDOW", self.abbrechen)
self.bind("<Escape>", lambda e: self.abbrechen()) self.bind("<Escape>", lambda e: self.abbrechen())
try: try:
@@ -530,64 +615,60 @@ class Dialog(tk.Toplevel):
self.titelleiste.pack(side="top", fill="x") self.titelleiste.pack(side="top", fill="x")
def zeigen(self, fokus=None): def zeigen(self, fokus=None):
from provtool.gui import modal, win32
if getattr(self._root(), "_provtool_schliesst", False): if getattr(self._root(), "_provtool_schliesst", False):
self.destroy() # Programm wird beendet – keine neuen Dialoge mehr self.destroy() # Programm wird beendet – keine neuen Dialoge mehr
return None return None
self.update_idletasks() self.update_idletasks()
m = self.master.winfo_toplevel() m = self.master.winfo_toplevel()
self._aufrufer = m
w, h = self.winfo_reqwidth(), self.winfo_reqheight() w, h = self.winfo_reqwidth(), self.winfo_reqheight()
x = m.winfo_rootx() + max(0, (m.winfo_width() - w) // 2) x = m.winfo_rootx() + max(0, (m.winfo_width() - w) // 2)
y = m.winfo_rooty() + max(0, (m.winfo_height() - h) // 3) 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.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() self.update_idletasks()
self.focus_force() if KLASSISCH and win32.IS_WIN:
self._modal = Modal(self, m) # eigener Taskleisten-Eintrag; wird erst nach kurzem Aus-/Einblenden wirksam
vorher = self.grab_current() # z. B. der Assistent, wenn hier eine Rückfrage aufgeht if win32.taskleisteneintrag(self):
self._sperre_setzen() self.withdraw()
bind_id = None self.deiconify()
if KLASSISCH: self.update_idletasks()
# Ein rahmenloser Dialog darf nicht hinter dem Hauptfenster verschwinden win32.besitzer_setzen(self, m)
bind_id = m.bind("<FocusIn>", lambda e: self.winfo_exists() and self.lift(), add="+") self._sperre = modal.Sperre(m, self)
self.lift()
self.focus_force() self.focus_force()
(fokus or self).focus_set() (fokus or self).focus_set()
self.wait_window() self.wait_window()
if bind_id:
try:
m.unbind("<FocusIn>", 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 return self.result
def _sperre_setzen(self, versuch: int = 0): def nach_vorn(self, blinken: bool = False):
"""Tk-Modalsperre setzen. Ist das Fenster noch nicht ganz sichtbar (langsamer PC, Wine), """Dialog nach vorn holen (z. B. nach Klick auf das gesperrte Hauptfenster)."""
schlägt grab_set fehl – dann kurz danach erneut versuchen statt ohne Sperre weiterzumachen.""" from provtool.gui import win32
try: try:
if self.winfo_exists(): self.lift()
self.grab_set() self.focus_force()
win32.nach_vorn(self)
except tk.TclError: except tk.TclError:
if versuch < 50: return
self.after(20, lambda: self._sperre_setzen(versuch + 1)) if blinken:
self.bell()
win32.blinken(self)
if self.titelleiste is not None:
self.titelleiste.blinken()
def destroy(self): def destroy(self):
# Erst das aufrufende Fenster wieder freigeben, dann schließen – sonst aktiviert Windows sperre = getattr(self, "_sperre", None)
# beim Schließen ein fremdes Programm statt unseres Fensters. if sperre is not None:
modal = getattr(self, "_modal", None) sperre.aufheben()
if modal is not None: self._sperre = None
modal.beenden() aufrufer = getattr(self, "_aufrufer", None)
self._modal = None
super().destroy() 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): def abbrechen(self):
self.result = None self.result = None
+1 -1
View File
@@ -161,7 +161,7 @@ def ausfuehren(ergebnisdatei: str) -> int:
(seite(0), lambda: wiz()._weiter(), "Willkommen"), (seite(0), lambda: wiz()._weiter(), "Willkommen"),
(seite(1), lambda: wiz()._weiter(), "Speicherort"), (seite(1), lambda: wiz()._weiter(), "Speicherort"),
(seite(2), haendler, "Händler anlegen"), (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(3), abrechnung, "Abrechnung mit Datei"),
(seite(4), lambda: wiz()._weiter(), "Fertig stellen"), (seite(4), lambda: wiz()._weiter(), "Fertig stellen"),
(lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"), (lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"),
+278 -20
View File
@@ -61,6 +61,23 @@ class Lauf:
# eine evtl. geöffnete Fehlermeldung schließen, damit der Test nicht hängt # eine evtl. geöffnete Fehlermeldung schließen, damit der Test nicht hängt
self.root.after(100, self._meldungen_zu) self.root.after(100, self._meldungen_zu)
self.root.report_callback_exception = melden 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 # ---------------------------------------------------------------- Zustand
def lebt_sonst(self, text: str) -> str: def lebt_sonst(self, text: str) -> str:
@@ -155,6 +172,10 @@ class Lauf:
time.sleep(0.01) time.sleep(0.01)
self.uebrig = [s[2] for s in offen] 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): def schliessen_ueber_fenster(self):
"""Wie Klick auf × / Taskleiste ▸ Fenster schließen (WM_DELETE_WINDOW).""" """Wie Klick auf × / Taskleiste ▸ Fenster schließen (WM_DELETE_WINDOW)."""
cmd = self.root.tk.call("wm", "protocol", self.root._w, "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"), (lambda: lauf.dialog(Einrichtungsassistent) is None, lambda: None, "Assistent ist zu"),
]) ])
assert lauf.lebt, lauf.lebt_sonst("Programm darf nicht beendet werden") 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 assert lauf.app.settings.get("assistent_erledigt") is True
lauf.pruefen() lauf.pruefen()
lauf.ende() 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.""" Zeichnen sind noch nicht fertig. Alt+F4 muss trotzdem nur den Dialog schließen."""
from provtool.gui import win95 as w from provtool.gui import win95 as w
from provtool.gui.dialogs import InfoDialog 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“ monkeypatch.setattr(w.Dialog, "winfo_ismapped", lambda self: 0) # „noch nicht gezeichnet“
lauf = lauf_mit_daten lauf = lauf_mit_daten
lauf.ablauf([ 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"), (lambda: lauf.dialog(InfoDialog) is None, lambda: None, "Dialog geschlossen"),
], sekunden=8) ], sekunden=8)
assert lauf.lebt, lauf.lebt_sonst("Alt+F4 darf bei offenem Dialog nicht das ganze Programm beenden") 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() 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"), (lambda: rg().blase is None and not rg().rahmen, lambda: None, "Rundgang samt Rahmen weg"),
], sekunden=8) ], sekunden=8)
assert lauf.lebt, lauf.lebt_sonst("Alt+F4 im Rundgang darf nicht das Programm beenden") 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() 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: bool(app.ptree.get_children()), oeffnen, "Betrag ändern bei unsichtbarer Zelle"),
(lambda: eingabe() is not None, tippen, "Betrag im Dialog eingeben"), (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) assert any(p["betrag"] == 777 and p["manuell"] for p in app.positionen)
del app.ptree.bbox del app.ptree.bbox
lauf.pruefen() 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.geometry("400x300+50+50")
fremd.update() fremd.update()
m.update(besitzer=win32.besitzer_von(d), haupt=win32.hwnd(lauf.root), 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“ win32.nach_vorn(fremd) # „woanders hingeklickt“
lauf.root.update() lauf.root.update()
m["woanders"] = win32.stapel(d, lauf.root, fremd) 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: 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"), (lambda: "zurueck" in m and lauf.dialog(InfoDialog) is None, lambda: None, "geschlossen"),
], sekunden=10) ], 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["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["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 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" assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Schließen nicht wieder freigegeben"
lauf.pruefen() lauf.pruefen()
@nur_windows def test_verschachtelte_rueckfrage_sperrt_der_reihe_nach(lauf_mit_daten):
def test_windows_verschachtelte_rueckfrage(lauf_mit_daten): """Händler-Dialog -> Rückfrage: gesperrt sind Hauptfenster-Inhalt und Händler-Dialog (nicht dessen
"""Händler-Dialog → Rückfrage: Rückfrage gehört dem Händler-Dialog; beim Schließen der Reihe nach frei.""" Titelleiste); nach der Rückfrage ist der Händler-Dialog wieder frei, das Hauptfenster noch nicht."""
from provtool.gui import win32 from provtool.gui import modal, win32
from provtool.gui import win95 as w from provtool.gui import win95 as w
from provtool.gui.dialogs import HaendlerDialog from provtool.gui.dialogs import HaendlerDialog
lauf = lauf_mit_daten lauf = lauf_mit_daten
m = {} m = {}
rueckfrage = lambda: next((d for d in lauf.dialoge() if d.title() == "Test-Rückfrage"), None) # noqa: E731 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(): def messen_innen():
h, r = lauf.dialog(HaendlerDialog), rueckfrage() h, r = lauf.dialog(HaendlerDialog), rueckfrage()
m.update(r_besitzer=win32.besitzer_von(r), h=win32.hwnd(h), h_an=win32.ist_bedienbar(h), m.update(h_inhalt=gesperrt(h.liste), h_titel=gesperrt(h.titelleiste) if h.titelleiste else False,
haupt_an=win32.ist_bedienbar(lauf.root)) 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() r.destroy()
def messen_aussen(): def messen_aussen():
h = lauf.dialog(HaendlerDialog) 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() h.abbrechen()
lauf.ablauf([ 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: "h" in m and rueckfrage() is None, messen_aussen, "außen messen"),
(lambda: lauf.dialog(HaendlerDialog) is None, lambda: None, "alles zu"), (lambda: lauf.dialog(HaendlerDialog) is None, lambda: None, "alles zu"),
], sekunden=12) ], sekunden=12)
assert not lauf.uebrig, lauf.uebrig 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" 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)
lauf.pruefen() lauf.pruefen()
@@ -612,8 +638,240 @@ def test_windows_rundgang_sperrt_und_gibt_frei(lauf_mit_daten):
messen_und_weiter, f"Schritt {i + 1}")) messen_und_weiter, f"Schritt {i + 1}"))
schritte.append((IMMER, lambda: rg().beenden(), "Rundgang beenden")) schritte.append((IMMER, lambda: rg().beenden(), "Rundgang beenden"))
lauf.ablauf(schritte, sekunden=12) 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["gesperrt"]), "Hauptfenster während des Rundgangs nicht gesperrt"
assert all(m["besitzer"]), "Sprechblase gehört nicht dem Hauptfenster" assert all(m["besitzer"]), "Sprechblase gehört nicht dem Hauptfenster"
assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Rundgang nicht freigegeben" assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Rundgang nicht freigegeben"
lauf.pruefen() 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, "<Control-BackSpace>"), klasse
assert lauf.root.bind_class(klasse, "<Control-Delete>"), 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("<Escape>")
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("<ButtonPress-1>", x=30, y=5, rootx=rx, rooty=ry)
titel.event_generate("<B1-Motion>", x=70, y=5, rootx=rx + 40, rooty=ry)
titel.event_generate("<ButtonRelease-1>", 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("<ButtonPress-1>", x=5, y=5)
knopf.event_generate("<ButtonRelease-1>", 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("<Enter>")
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()
+16
View File
@@ -265,3 +265,19 @@ def test_migration_alter_datenbank(tmp_path):
spalten = {r[1] for r in d.con.execute("PRAGMA table_info(position)")} spalten = {r[1] for r in d.con.execute("PRAGMA table_info(position)")}
assert "haendler_fest" in spalten assert "haendler_fest" in spalten
d.close() 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