Win95-Titelleiste und -Menüleiste, Einrichtungs-Assistent, Rundgang, Tipp des Tages
Windows-Build / windows-exe (push) Successful in 1m43s

- selbst gezeichneter Fensterrahmen (verschieben, maximieren, minimieren, Größe ändern), abschaltbar
- Dialoge mit Win95-Titelleiste; vorherige Modalsperre wird nach Rückfragen wiederhergestellt
- Assistent: Speicherort, Unterhändler (Nummern aus Abrechnung vorschlagen), erste Abrechnung
- Rundgang mit Hinweis-Sprechblasen, Tipp des Tages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-11-02 10:14:19 +01:00
1 parent 6f860746ca
commit 6b520bce35
7 files changed
+1125 -40

No files matched your search

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