8 Commits
Author SHA1 Message Date
adminandClaude Opus 5.5 c0b740db06 Tests: Warten auf das sichtbare Hauptfenster als Schritt im Ablauf statt eigener update()-Schleife
Auf dem langsamen Runner öffnete sich währenddessen der modale Einrichtungs-Assistent und hielt die
Schleife fest (Hänger). Als Tk-Callback-Schritt läuft das Warten auch bei offenem Dialog weiter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:01:57 +01:00
adminandClaude Opus 5.5 03c5eaf5da Build: harte Zeitgrenzen für Tests (90 s je Test, 15 min gesamt, Job 30 min), Tests ausführlich protokollieren
Ein Test hing auf dem Runner fast eine Stunde in einem Systemaufruf, wo der Tk-Watchdog der GUI-Tests
nicht greift. pytest-timeout bricht ab und gibt den Aufrufstapel aus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:52:24 +01:00
adminandClaude Opus 5.5 dcb1d0378c 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>
2026-11-02 12:58:18 +01:00
adminandClaude Opus 5.5 2b2d5e8580 Tests: Alt+F4 unabhängig von der Tastaturzustellung des Build-Rechners prüfen
Auf dem Runner gingen erzeugte Tastendrücke verloren (Tk stellt sie unter Windows nur dem Fenster
mit Tastaturfokus zu); der Watchdog brach dann ab und es sah aus, als hätte sich das Programm
beendet. Handler wird jetzt direkt aufgerufen, die Bindung separat geprüft; Watchdog-Abbrüche
werden als Zeitüberschreitung gemeldet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:43:29 +01:00
adminandClaude Opus 5.5 86192c853d Test: Diagnose für Alt+F4 im Dialog (wer löst Alt+F4/Beenden aus)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:39:11 +01:00
adminandClaude Opus 5.5 bd45ffea17 Alt+F4 bei gerade geöffnetem Dialog schließt nicht mehr das Programm
Auf dem langsameren Build-Runner schlug der Alt+F4-Test fehl: Kam der Tastendruck an, bevor der
Dialog gezeichnet und gesperrt war, übersah die Ausweichsuche das Popup (winfo_ismapped = 0) und
Alt+F4 galt dem Hauptfenster. Die Suche nutzt jetzt den Fensterstatus. Neuer Test erzwingt genau
diesen Zustand plattformunabhängig.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:33:54 +01:00
adminandClaude Opus 5.5 26ac90bb0f Popups kommen nach Klick woanders / Alt+Tab wieder nach vorn; Büroklammer im Tipp des Tages
Fehler: War ein Popup offen und man klickte in ein anderes Programm, ließ sich das Popup über
Hauptfenster, Taskleiste oder Alt+Tab nicht mehr nach vorn holen.
Ursache: Rahmenlose Tk-Fenster mit transient() legt Tk unter Windows als Kindfenster des Desktops
(WS_CHILD) an – ohne Besitzer, daher kam beim Zurückholen nur das (gesperrte) Hauptfenster nach vorn.

- Popups (Dialoge, Rückfragen, Rundgang) ohne transient(), stattdessen Windows-Besitzer +
  Sperre des aufrufenden Fensters wie bei echten modalen Windows-Dialogen (gui/win32.py)
- Rundgang: neue Sprechblase erscheint vor dem Entfernen der alten (sonst springt Windows weg)
- Alt+F4 und Modalsperre robust gegen Timing (Sperre wird bei Bedarf nachgeholt)
- Tests (nur unter Windows/Wine): Besitzer, Sperre, Stapelreihenfolge nach „woanders klicken“
  und Zurückholen, verschachtelte Rückfragen, Rundgang
- Tipp des Tages mit freigestelltem Büroklammer-Bild (build/make_tipp_bild.py), in der .exe
  enthalten und vom Selbsttest geprüft

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:26:17 +01:00
adminandClaude Opus 5.5 aae28fa7f2 Absturz beim Schließen mit offenem Dialog behoben; GUI-Tests und Selbsttest der .exe im Build
Fehler: Programm schließen, während der Einrichtungs-Assistent offen war → Zugriff auf
geschlossene Datenbank, danach Absturz des Fehler-Handlers.

- beenden(): schließt zuerst offene Dialoge/Rückfragen/Rundgang, lässt wartende Abläufe zu Ende
  laufen und schließt erst dann Datenbank und Fenster; mehrfacher Aufruf unschädlich
- Fehler-Handler protokolliert immer, zeigt aber keine Meldung mehr ohne Fenster
- Alt+F4 schließt nur das aktive bzw. modale Fenster (nicht mehr das ganze Programm)
- Betrag ändern: Ersatz-Dialog, wenn die Zelle nicht sichtbar ist (vorher passierte still nichts)
- tests/test_gui.py: Oberflächen-Tests inkl. Schließen bei jedem Dialog, Watchdog gegen Hänger
- Build: ein Xvfb für den ganzen Build (Wine bindet sich an das erste DISPLAY), GUI-Tests unter
  Windows-Python, danach Selbsttest der fertigen .exe (--selbsttest)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:55:00 +01:00
23 changed files with 2439 additions and 167 deletions

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ jobs:
windows-exe:
# Label ggf. an euren Runner anpassen (Runner braucht Docker; Wine steckt im Container-Image).
runs-on: ubuntu-latest
timeout-minutes: 30 # normal ca. 5 Minuten – ein Hänger darf den Runner nicht stundenlang belegen
container:
image: tobix/pywine:3.12
env:
+3
View File
@@ -13,3 +13,6 @@ dist/
build/tmp/
build/version_info.txt
*.db
selbsttest.txt
/*.jpg
/*.png
+16 -3
View File
@@ -32,14 +32,19 @@ normale Windows-Titelleiste verwendet.
eines Monats plus die Vodafone-Datei. Das Format wird an der Kopfzeile erkannt.
Doppelt importierte Dateien werden erkannt. Unbekannte Kennungen erscheinen unter
„(Nicht zugeordnet)“ und lassen sich direkt einem Händler zuordnen.
4. **Prüfen und anpassen**: links Händler wählen, rechts sind die Positionen nach
4. **Eigene Filiale ausblenden**: Positionen eurer eigenen Kennungen (z. B. freenet `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.
- Gruppe markieren → **Prozent…** (`F4`): Auszahlung für die ganze Provisionsart; auf Wunsch
als feste Regel für diesen Händler gespeichert (gilt dann für künftige Importe).
- Doppelklick / `F2` auf eine Position: Betrag frei überschreiben
(`Enter` speichern, `↓`/`Tab` nächste Zeile, `Esc` abbrechen). Blau = von Hand geändert.
- Rechtsklick: u. a. „Auszahlung 0 €“.
5. **Export** (`Strg+E`): je Händler (und je Anbieter) eine `.xlsx` im Format der Vorlage:
6. **Export** (`Strg+E`): je Händler (und je Anbieter) eine `.xlsx` im Format der Vorlage:
| A | B | C | D | E | F | G | H | I | J | K | L | M | N |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -97,9 +102,17 @@ Aufbau:
Gitea Actions (`.gitea/workflows/windows-build.yml`) baut die `.exe` im Container
`tobix/pywine:3.12` (Windows-Python unter Wine) mit PyInstaller:
- jeder Push: Tests unter Windows-Python + `Provisionsmanager95.exe` als Artefakt
- jeder Push: alle Tests unter Windows-Python (Wine) **inklusive Oberflächen-Tests** auf einem
virtuellen Bildschirm, danach `Provisionsmanager95.exe` bauen und mit
`Provisionsmanager95.exe --selbsttest` die **fertige .exe** prüfen; erst dann gibt es das Artefakt
- Tag `vX.Y.Z` (passend zu `provtool/__init__.py`): zusätzlich ein Gitea-Release mit `.exe` und `.zip`
Die Oberflächen-Tests (`tests/test_gui.py`) bedienen die echte Oberfläche: Dialoge öffnen,
Programm schließen, während Assistent/Rückfragen/Rundgang offen sind, Alt+F4, Beträge ändern usw.
Jede Ausnahme in der Oberfläche und jeder Eintrag in `fehler.log` lässt sie fehlschlagen; ein
Watchdog bricht hängende Tests ab, statt den Build zu blockieren. Ohne Bildschirm werden sie lokal
übersprungen, im Build (`PROVTOOL_GUI_TESTS=1`) ist das ein Fehler.
Lokal geht dasselbe mit Docker:
```bash
+29 -2
View File
@@ -9,11 +9,25 @@ export LANG=C.UTF-8 LC_ALL=C.UTF-8 # sonst kann Wine keine Umlaute in Dateinam
export WINEPREFIX="${WINEPREFIX:-/opt/wineprefix}"
PY="wine python"
# Ein virtueller Bildschirm für den GANZEN Build – vor dem ersten Wine-Aufruf starten!
# Wine bindet seinen Hintergrundprozess (wineserver/Desktop) an das DISPLAY des ersten Aufrufs.
# Läuft z. B. „pip install“ ohne Bildschirm und die GUI-Tests später mit, scheitert Tk unter
# Wine mit „Failed to create the menu window“.
wineserver -k 2>/dev/null || true
ANZEIGE=":$((90 + RANDOM % 9))"
Xvfb "$ANZEIGE" -screen 0 1280x800x24 -nolisten tcp >/dev/null 2>&1 &
XVFB_PID=$!
trap 'wineserver -k 2>/dev/null || true; kill $XVFB_PID 2>/dev/null || true' EXIT
export DISPLAY="$ANZEIGE"
sleep 1
$PY --version
$PY -m pip install --disable-pip-version-check -q -r requirements-dev.txt
echo "== Tests (Windows-Python) =="
$PY -m pytest -q
echo "== Tests inkl. Oberfläche (Windows-Python unter Wine) =="
# PROVTOOL_GUI_TESTS=1: fehlt der Bildschirm, ist das ein Fehler (nicht „übersprungen“)
# -v: im Log ist live sichtbar, welcher Test läuft; timeout: Obergrenze für alle Tests zusammen
PROVTOOL_GUI_TESTS=1 timeout 900 $PY -m pytest -v -p no:cacheprovider
echo "== Version =="
VERSION=$($PY build/version_info.py | tr -d '\r')
@@ -23,6 +37,19 @@ echo "== PyInstaller =="
$PY -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec
test -f dist/Provisionsmanager95.exe
echo "== Selbsttest der fertigen .exe =="
rm -f selbsttest.txt
set +e
timeout 300 wine dist/Provisionsmanager95.exe --selbsttest selbsttest.txt
CODE=$?
set -e
cat selbsttest.txt 2>/dev/null || echo "(keine Ergebnisdatei – die .exe ist vermutlich gar nicht gestartet)"
if [ "$CODE" -ne 0 ] || ! head -1 selbsttest.txt 2>/dev/null | tr -d '\r' | grep -qx OK; then
echo "Selbsttest der .exe FEHLGESCHLAGEN (Exit-Code $CODE)"
exit 1
fi
echo "Selbsttest bestanden"
cp README.md dist/LIESMICH.md 2>/dev/null || true
( cd dist && rm -f Provisionsmanager95-*.zip && \
python3 -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \
+61
View File
@@ -0,0 +1,61 @@
"""Erzeugt provtool/gui/bilder/tipp.png (freigestellt, verkleinert, ohne Metadaten) aus dem Foto
für den „Tipp des Tages“. Nur zum Entwickeln nötig (Pillow): python build/make_tipp_bild.py <foto>"""
import sys
from collections import deque
from pathlib import Path
from PIL import Image
ZIEL_HOEHE = 170
ROOT = Path(__file__).resolve().parents[1]
def freistellen(im: Image.Image, schwelle: int = 232, min_flaeche: int = 6000) -> Image.Image:
"""Weißen Hintergrund transparent machen: alle hellen Flächen, die den Rand berühren ODER groß
sind (z. B. das Innere der Büroklammer-Schlaufen). Kleine helle Flächen (Augenweiß) bleiben."""
im = im.convert("RGBA")
w, h = im.size
px = im.load()
hell = lambda p: p[0] >= schwelle and p[1] >= schwelle and p[2] >= schwelle # noqa: E731
gesehen = bytearray(w * h)
for sy in range(h):
for sx in range(w):
if gesehen[sy * w + sx] or not hell(px[sx, sy]):
continue
flaeche, rand, q = [], False, deque([(sx, sy)])
gesehen[sy * w + sx] = 1
while q:
x, y = q.popleft()
flaeche.append((x, y))
if x in (0, w - 1) or y in (0, h - 1):
rand = True
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if 0 <= nx < w and 0 <= ny < h and not gesehen[ny * w + nx] and hell(px[nx, ny]):
gesehen[ny * w + nx] = 1
q.append((nx, ny))
if rand or len(flaeche) >= min_flaeche:
for x, y in flaeche:
px[x, y] = (255, 255, 255, 0)
return im.crop(im.getbbox())
def main(foto: str, ziel: Path, modus: str = "lanczos"):
im = freistellen(Image.open(foto))
faktor = ZIEL_HOEHE / im.height
groesse = (max(1, round(im.width * faktor)), ZIEL_HOEHE)
if modus == "raster": # erst auf das Pixel-Raster (ca. 8 px) mitteln, dann hochskalieren
klein = im.resize((im.width // 8, im.height // 8), Image.BOX)
im = klein.resize(groesse, Image.NEAREST)
else:
im = im.resize(groesse, Image.LANCZOS)
# Halbtransparente Randpixel hart machen (Tk/Win95-Optik, keine weißen Säume)
alpha = im.getchannel("A").point(lambda a: 255 if a >= 128 else 0)
im.putalpha(alpha)
ziel.parent.mkdir(parents=True, exist_ok=True)
im.save(ziel, optimize=True) # PNG ohne EXIF-Metadaten
print(ziel, im.size)
if __name__ == "__main__":
main(sys.argv[1], Path(sys.argv[2]) if len(sys.argv) > 2 else ROOT / "provtool" / "gui" / "bilder" / "tipp.png",
sys.argv[3] if len(sys.argv) > 3 else "lanczos")
+1
View File
@@ -8,6 +8,7 @@ a = Analysis(
[os.path.join(ROOT, "build", "start.py")],
pathex=[ROOT],
hiddenimports=["openpyxl", "xlrd"],
datas=[(os.path.join(ROOT, "provtool", "gui", "bilder"), os.path.join("provtool", "gui", "bilder"))],
excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"],
noarchive=False,
)
+1 -1
View File
@@ -1,5 +1,5 @@
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
__version__ = "1.1.0"
__version__ = "1.2.0"
APP_NAME = "Provisions-Manager 95"
APP_ORG = "Handy-Insel Miesbach"
+27 -2
View File
@@ -45,6 +45,14 @@ CREATE TABLE IF NOT EXISTS regel (
UNIQUE (haendler_id, provisionsart)
);
-- Eigene Kennungen (eigene Filiale): diese Positionen werden ausgeblendet und nicht verprovisioniert.
CREATE TABLE IF NOT EXISTS eigen (
id INTEGER PRIMARY KEY,
anbieter TEXT NOT NULL,
wert TEXT NOT NULL,
UNIQUE (anbieter, wert)
);
CREATE TABLE IF NOT EXISTS abrechnung (
id INTEGER PRIMARY KEY,
titel TEXT NOT NULL,
@@ -82,6 +90,7 @@ CREATE INDEX IF NOT EXISTS ix_position_abr ON position(abrechnung_id);
"""
UNZUGEORDNET = 0 # Pseudo-Händler-ID für Positionen ohne bekannte Kennung
EIGEN = -2 # Pseudo-Händler-ID für Positionen eigener Kennungen (ausgeblendet)
# Sonder-Kennung: Vodafone-Positionen, deren Kundenname komplett in GROSSBUCHSTABEN steht
# (so erfasst ein Unterhändler seine Kunden). Hat Vorrang vor der VO-Nr.
@@ -206,15 +215,31 @@ class Datenbank:
kmap = self.kennung_map()
caps = kmap.get((CAPS_ANBIETER, CAPS_WERT))
aktive = {h.id for h in self.haendler_liste(nur_aktive=True)}
eigene = set(self.eigene_kennungen())
def finde(anbieter: str, kennung: str, name: str | None = None, fest: int | None = None) -> int:
if fest is not None:
return fest if (fest == UNZUGEORDNET or fest in aktive) else UNZUGEORDNET
if caps and anbieter == "vodafone" and ist_grossgeschrieben(name):
return caps
return kmap.get((anbieter, kennung), UNZUGEORDNET)
hid = kmap.get((anbieter, kennung), UNZUGEORDNET)
if hid == UNZUGEORDNET and (anbieter, kennung) in eigene:
return EIGEN
return hid
return finde
# ------------------------------------------------------------------ eigene Kennungen
def eigene_kennungen(self) -> list[tuple[str, str]]:
return [(r[0], r[1]) for r in self.con.execute("SELECT anbieter, wert FROM eigen ORDER BY anbieter, wert")]
def eigene_setzen(self, anbieter: str, wert: str, eigen: bool = True) -> None:
wert = (wert or "").strip()
with self.con:
if eigen:
self.con.execute("INSERT OR IGNORE INTO eigen (anbieter, wert) VALUES (?,?)", (anbieter, wert))
else:
self.con.execute("DELETE FROM eigen WHERE anbieter=? AND wert=?", (anbieter, wert))
# ------------------------------------------------------------------ Regeln
def regeln(self, hid: int) -> dict[str, float]:
return {r[0]: r[1] for r in self.con.execute(
@@ -230,7 +255,7 @@ class Datenbank:
(hid, provisionsart, prozent))
def prozent_fuer(self, hid: int, provisionsart: str) -> float:
if hid == UNZUGEORDNET:
if hid <= 0: # nicht zugeordnet / eigene Filiale
return 100.0
r = self.con.execute("SELECT prozent FROM regel WHERE haendler_id=? AND provisionsart=?",
(hid, provisionsart)).fetchone()
+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 |
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
+212 -53
View File
@@ -14,9 +14,11 @@ from tkinter import filedialog
from provtool import APP_NAME, config
from provtool import export as exp
from provtool.db import UNZUGEORDNET, Datenbank
from provtool.db import EIGEN, UNZUGEORDNET, Datenbank
from provtool.gui import assistent as ass
from provtool.gui import dialogs as dlg
from provtool.gui import menue as mn
from provtool.gui import modal
from provtool.gui import rahmen as rh
from provtool.gui import win95 as w
from provtool.importer import ANBIETER, ImportFehler, lies_datei
@@ -35,7 +37,7 @@ def _datum_de(iso: str | None) -> str:
def _zeitraum_kurz(z: str | None) -> str:
"""'2026.10.01-2026.10.07' → '01.10.–07.10.'"""
"""'2026.10.01-2026.10.07' -> '01.10.–07.10.'"""
m = re.fullmatch(r"(\d{4})\.(\d{2})\.(\d{2})\s*-\s*(\d{4})\.(\d{2})\.(\d{2})", (z or "").strip())
if m:
return f"{m[3]}.{m[2]}.–{m[6]}.{m[5]}."
@@ -64,6 +66,8 @@ class App:
self.haendler_namen: dict[int, str] = {}
self.ansicht: int = ALLE
self._edit: tk.Entry | None = None
self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen)
self._beendet = False # Datenbank und Fenster sind zu
w.apply(root)
self.icons = w.Icons(root)
@@ -87,6 +91,8 @@ class App:
self.status.pack(side="bottom", fill="x")
self._tasten()
self.rahmen.geometrie_setzen(self.settings.get("fenster"), self.settings.get("maximiert", False))
if self.settings.get("trenner"):
root.after(200, lambda: self.pw.sash_place(0, int(self.settings["trenner"]), 0))
abr = self.db.abrechnungen()
if abr:
@@ -100,43 +106,46 @@ class App:
# ================================================================= Aufbau
def _menue(self):
self.menueleiste = rh.Menueleiste(self.inhalt, self.root)
self.menueleiste = mn.Menueleiste(self.inhalt, self.root)
self.menueleiste.pack(side="top", fill="x")
d = self.menueleiste.menue("Datei")
d.add_command(label="Neue Abrechnung…", accelerator="Strg+N", command=self.neu)
d.add_command(label="Abrechnung öffnen…", accelerator="Strg+O", command=self.abrechnung_waehlen)
d.add_command(label="Neue Abrechnung...", accelerator="Strg+N", command=self.neu)
d.add_command(label="Abrechnung öffnen...", accelerator="Strg+O", command=self.abrechnung_waehlen)
d.add_separator()
d.add_command(label="Dateien importieren…", accelerator="Strg+I", command=self.importieren)
d.add_command(label="Importierte Dateien…", command=self.dateien)
d.add_command(label="Exportieren…", accelerator="Strg+E", command=self.exportieren)
d.add_command(label="Dateien importieren...", accelerator="Strg+I", command=self.importieren)
d.add_command(label="Importierte Dateien...", command=self.dateien)
d.add_command(label="Exportieren...", accelerator="Strg+E", command=self.exportieren)
d.add_separator()
d.add_command(label="Datensicherung erstellen…", command=self.sicherung)
d.add_command(label="Datensicherung einspielen…", command=self.wiederherstellen)
d.add_command(label="Einstellungen…", command=self.einstellungen)
d.add_command(label="Datensicherung erstellen...", command=self.sicherung)
d.add_command(label="Datensicherung einspielen...", command=self.wiederherstellen)
d.add_command(label="Einstellungen...", command=self.einstellungen)
d.add_separator()
d.add_command(label="Beenden", accelerator="Alt+F4", command=self.beenden)
b = self.menueleiste.menue("Bearbeiten")
b.add_command(label="Betrag ändern…", accelerator="F2", command=self.betrag_bearbeiten)
b.add_command(label="Auszahlung in Prozent…", accelerator="F4", command=self.prozent)
b.add_command(label="Betrag ändern...", accelerator="F2", command=self.betrag_bearbeiten)
b.add_command(label="Auszahlung in Prozent...", accelerator="F4", command=self.prozent)
b.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen)
b.add_separator()
b.add_command(label="Alle Beträge neu berechnen", command=self.neu_berechnen)
h = self.menueleiste.menue("Händler")
h.add_command(label="Händler verwalten…", accelerator="Strg+H", command=self.haendler)
h.add_command(label="Kennung des Eintrags zuordnen…", command=self.kennung_zuordnen_auswahl)
h.add_command(label="Auswahl fest einem Händler zuweisen…", command=self.haendler_zuweisen)
h.add_command(label="Händler verwalten...", accelerator="Strg+H", command=self.haendler)
h.add_command(label="Kennung des Eintrags zuordnen...", command=self.kennung_zuordnen_auswahl)
h.add_command(label="Auswahl fest einem Händler zuweisen...", command=self.haendler_zuweisen)
h.add_command(label="Auswahl automatisch zuordnen", command=lambda: self.haendler_zuweisen(automatisch=True))
h.add_separator()
h.add_command(label="Eigene Kennungen (ausblenden)...", command=self.eigene_verwalten)
h.add_command(label="Kennung der Auswahl als eigene ausblenden", command=self.eigene_ausblenden)
hi = self.menueleiste.menue("?", underline=None)
hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe)
hi.add_command(label="Rundgang durch das Programm", command=self.rundgang)
hi.add_command(label="Tipp des Tages…", command=self.tipp_des_tages)
hi.add_command(label="Tipp des Tages...", command=self.tipp_des_tages)
hi.add_separator()
hi.add_command(label="Einrichtungs-Assistent…", command=self.assistent)
hi.add_command(label="Einrichtungs-Assistent...", command=self.assistent)
hi.add_separator()
hi.add_command(label=f"Info über {APP_NAME}…", command=lambda: dlg.InfoDialog(self.root).zeigen())
hi.add_command(label=f"Info über {APP_NAME}...", command=lambda: dlg.InfoDialog(self.root).zeigen())
def _toolbar(self):
tb = tk.Frame(self.inhalt, bg=w.FACE, bd=1, relief="raised")
@@ -164,14 +173,17 @@ class App:
tk.Label(sf, text="Suchen:").pack(side="left")
e = tk.Entry(sf, textvariable=self.v_suche, width=22)
e.pack(side="left", padx=4)
self.v_suche.trace_add("write", lambda *_: self._tabelle())
self._suche_geplant = None
self.v_suche.trace_add("write", lambda *_: self._suche_planen())
self._suchfeld = e
self._suchrahmen = sf
def _body(self):
desk = tk.Frame(self.inhalt, bg=w.TEAL)
desk.pack(side="top", fill="both", expand=True)
pw = tk.PanedWindow(desk, orient="horizontal", bg=w.TEAL, sashwidth=6, bd=0)
# Trennlinie zwischen den Spalten: grau und erhaben wie ein Win95-Splitter, mit der Maus ziehbar
pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.FACE, sashwidth=7, sashrelief="raised",
bd=0, opaqueresize=True, sashcursor="sb_h_double_arrow")
pw.pack(fill="both", expand=True, padx=6, pady=6)
# --- Händler-Fenster
@@ -216,9 +228,9 @@ class App:
leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE)
leiste.pack(fill="x", pady=(4, 0))
self.b_prozent = w.knopf(leiste, "Prozent…", self.prozent, self.icons("prozent"))
self.b_prozent = w.knopf(leiste, "Prozent...", self.prozent, self.icons("prozent"))
self.b_prozent.pack(side="left")
w.knopf(leiste, "Betrag ändern…", self.betrag_bearbeiten, self.icons("rechner")).pack(side="left", padx=4)
w.knopf(leiste, "Betrag ändern...", self.betrag_bearbeiten, self.icons("rechner")).pack(side="left", padx=4)
w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left")
self.v_alle_auf = tk.BooleanVar(value=True)
w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right")
@@ -229,13 +241,18 @@ class App:
def _tasten(self):
r = self.root
r.bind_all("<Control-n>", lambda e: self.neu())
r.bind_all("<Control-o>", lambda e: self.abrechnung_waehlen())
r.bind_all("<Control-i>", lambda e: self.importieren())
r.bind_all("<Control-e>", lambda e: self.exportieren())
r.bind_all("<Control-h>", lambda e: self.haendler())
r.bind_all("<Control-f>", lambda e: self._suchfeld.focus_set())
r.bind_all("<F1>", lambda e: self.hilfe())
def frei(befehl):
# Tastenkürzel des Hauptfensters nicht ausführen, solange ein Dialog offen ist
# (sonst öffnete z. B. Strg+N im Dialog einen zweiten Dialog darüber)
return lambda e: None if modal.offen() else befehl()
r.bind_all("<Control-n>", frei(self.neu))
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("<Return>", lambda e: self.betrag_bearbeiten())
self.ptree.bind("<F4>", lambda e: self.prozent())
@@ -256,13 +273,23 @@ class App:
self.aid = None
self.positionen = []
self.rahmen.set_titel(APP_NAME)
self.status.set("info", " Keine Abrechnung geöffnet – Datei ▸ Neue Abrechnung oder Import")
self.status.set("info", " Keine Abrechnung geöffnet – Datei > Neue Abrechnung oder Import")
self._haendlerliste(behalte_auswahl)
self._tabelle()
def _suche_planen(self):
"""Suche erst kurz nach dem letzten Tastendruck ausführen (flüssiges Tippen)."""
if self._suche_geplant is not None:
self.root.after_cancel(self._suche_geplant)
self._suche_geplant = self.root.after(150, self._suche_ausfuehren)
def _suche_ausfuehren(self):
self._suche_geplant = None
self._tabelle()
def _gefiltert(self) -> list[dict]:
if self.ansicht == ALLE:
return self.positionen
return [p for p in self.positionen if p["haendler_id"] != EIGEN]
return [p for p in self.positionen if p["haendler_id"] == self.ansicht]
def _haendlerliste(self, behalte_auswahl=True):
@@ -274,7 +301,7 @@ class App:
s[0] += 1
s[1] += p["betrag"]
s[2] += p["betrag_orig"]
ges = [sum(s[0] for s in stat.values()), sum(s[1] for s in stat.values())]
ges = [sum(s[0] for k, s in stat.items() if k != EIGEN), sum(s[1] for k, s in stat.items() if k != EIGEN)]
t.insert("", "end", iid=str(ALLE), text="(Alle Positionen)", values=(ges[0], fmt(ges[1])), tags=("fett",))
for h in self.db.haendler_liste():
s = stat.get(h.id, [0, 0, 0])
@@ -284,6 +311,10 @@ class App:
if s:
t.insert("", "end", iid=str(UNZUGEORDNET), text="(Nicht zugeordnet)", values=(s[0], fmt(s[1])),
tags=("grau",))
s = stat.get(EIGEN)
if s:
t.insert("", "end", iid=str(EIGEN), text="(Eigene – ausgeblendet)", values=(s[0], fmt(s[2])),
tags=("grau",))
ziel = str(self.ansicht) if behalte_auswahl and t.exists(str(self.ansicht)) else str(ALLE)
self.ansicht = int(ziel)
t.selection_set(ziel)
@@ -300,9 +331,11 @@ class App:
self.haendler(self.ansicht)
elif self.ansicht == UNZUGEORDNET:
self.kennung_zuordnen_auswahl()
elif self.ansicht == EIGEN:
self.eigene_verwalten()
def _gruppe_von(self, p: dict) -> str:
if self.ansicht == UNZUGEORDNET:
if self.ansicht in (UNZUGEORDNET, EIGEN):
return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · Kennung {p['kennung'] or '(leer)'}"
if self.ansicht == ALLE:
name = self.haendler_namen.get(p["haendler_id"], "(Nicht zugeordnet)")
@@ -349,7 +382,8 @@ class App:
fmt(p["betrag_orig"]),
"manuell" if p["manuell"] else fmt_percent(p["prozent"]), fmt(p["betrag"])),
tags=tuple(tags))
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet"}.get(
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet",
EIGEN: "Eigene Kennungen (ausgeblendet, werden nicht exportiert)"}.get(
self.ansicht, self.haendler_namen.get(self.ansicht, ""))
self.f_p.set_titel(f"Positionen – {name}")
so = sum(p["betrag_orig"] for p in pos)
@@ -462,8 +496,10 @@ class App:
u[0] += 1
u[1] += p["betrag_orig"]
res = dlg.ImportBericht(self.root, bericht, [(a, k, n, s) for (a, k), (n, s) in sorted(unbekannt.items())]).zeigen()
if res:
self.kennung_zuordnen(*res)
if res and res[0] == "zuordnen":
self.kennung_zuordnen(res[1], res[2])
elif res and res[0] == "eigen":
self.eigene_ausblenden(res[1])
def dateien(self):
if not self.aid:
@@ -594,10 +630,12 @@ class App:
self.ptree.see(iid)
self.ptree.update_idletasks()
bbox = self.ptree.bbox(iid, "ausz")
if not bbox:
return
pid = int(iid[1:])
p = next(x for x in self.positionen if x["id"] == pid)
if not bbox:
# Zelle gerade nicht sichtbar (z. B. Spalte zu schmal) -> kleiner Dialog statt Inline-Feld
self._betrag_dialog(p)
return
x, y, bw, bh = bbox
var = tk.StringVar(value=fmt(p["betrag"], symbol=False))
e = tk.Entry(self.ptree, textvariable=var, justify="right", bd=1, relief="solid", font=w.FONT)
@@ -646,26 +684,81 @@ class App:
e.bind("<Escape>", abbruch)
e.bind("<FocusOut>", speichern)
def _betrag_dialog(self, p: dict):
kunde = " ".join(x for x in (p["vorname"], p["name"]) if x) or "(ohne Name)"
while True:
text = w.eingabe(self.root, "Betrag ändern",
f"Auszahlung für {kunde}\n{p['provisionsart']} – Provision {fmt(p['betrag_orig'])}:",
fmt(p["betrag"], symbol=False))
if text is None:
return
try:
cent = parse_cent(text)
except ValueError:
w.warnung(self.root, "Betrag ändern", "Bitte einen Betrag eingeben, z. B. 25 oder 12,50.")
continue
break
if cent != p["betrag"]:
self.db.betrag_setzen(p["id"], cent)
self.db.touch(self.aid)
self._aktualisieren()
def _kontextmenue(self, e):
iid = self.ptree.identify_row(e.y)
if not iid:
return
if iid not in self.ptree.selection():
self.ptree.selection_set(iid)
m = tk.Menu(self.root, tearoff=0)
m = mn.Popup(self.root)
if iid.startswith("p"):
m.add_command(label="Betrag ändern…", command=lambda: self.betrag_bearbeiten(iid))
m.add_command(label="Auszahlung in Prozent…", command=self.prozent)
m.add_command(label="Betrag ändern...", command=lambda: self.betrag_bearbeiten(iid))
m.add_command(label="Auszahlung in Prozent...", command=self.prozent)
m.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen)
m.add_separator()
m.add_command(label="Auszahlung 0 € (nicht auszahlen)", command=lambda: self._null())
m.add_separator()
m.add_command(label="Händler zuweisen…", command=self.haendler_zuweisen)
m.add_command(label="Händler zuweisen...", command=self.haendler_zuweisen)
m.add_command(label="Automatisch zuordnen", command=lambda: self.haendler_zuweisen(automatisch=True))
if self.ansicht == UNZUGEORDNET or self.ansicht == ALLE:
m.add_command(label="Kennung einem Händler zuordnen…", command=self.kennung_zuordnen_auswahl)
m.add_command(label="Kennung einem Händler zuordnen...", command=self.kennung_zuordnen_auswahl)
_, _, ps = self._auswahl_info()
if any(p["haendler_id"] == UNZUGEORDNET for p in ps):
m.add_separator()
m.add_command(label="Kennung als eigene ausblenden", command=self.eigene_ausblenden)
if any(p["haendler_id"] == EIGEN for p in ps):
m.add_separator()
m.add_command(label="Kennung wieder einblenden", command=self.eigene_einblenden)
m.tk_popup(e.x_root, e.y_root)
# ----- Eigene Kennungen (eigene Filiale) ausblenden
def _kennungen_der_auswahl(self, nur: int) -> list[tuple[str, str]]:
_, _, ps = self._auswahl_info()
return sorted({(p["anbieter"], p["kennung"]) for p in ps if p["haendler_id"] == nur})
def eigene_ausblenden(self, kennungen: list[tuple[str, str]] | None = None):
kennungen = kennungen if kennungen is not None else self._kennungen_der_auswahl(UNZUGEORDNET)
if not kennungen:
w.info(self.root, "Eigene Kennung", "Bitte zuerst nicht zugeordnete Positionen auswählen.")
return
liste = "\n".join(f" {ANBIETER.get(a, a)}: {k or '(leer)'}" for a, k in kennungen)
if not w.frage(self.root, "Eigene Kennung ausblenden",
f"Diese Kennung(en) gehören zur eigenen Filiale und werden künftig ausgeblendet "
f"(nicht angezeigt, nicht mitgerechnet, nicht exportiert):\n\n{liste}\n\n"
"Rückgängig jederzeit unter Händler > Eigene Kennungen."):
return
for a, k in kennungen:
self.db.eigene_setzen(a, k, True)
self._aktualisieren()
def eigene_einblenden(self):
for a, k in self._kennungen_der_auswahl(EIGEN):
self.db.eigene_setzen(a, k, False)
self._aktualisieren()
def eigene_verwalten(self):
if dlg.EigeneKennungenDialog(self.root, self.db).zeigen():
self._aktualisieren()
def haendler_zuweisen(self, automatisch: bool = False):
sel, pids, _ = self._auswahl_info()
if not pids:
@@ -723,7 +816,7 @@ class App:
"Ist sie vielleicht noch in Excel geöffnet?")
return
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}\n\n"
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n…" if len(erzeugt) > 15 else ""))
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n..." if len(erzeugt) > 15 else ""))
if res["oeffnen"]:
_oeffne_ordner(res["ordner"])
@@ -792,20 +885,22 @@ class App:
w.info(self.root, "Kurzanleitung",
"1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in % und die Kennungen –\n"
" bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr. oder die Regel\n"
" „Name in GROSSBUCHSTABEN“. Einzelne Positionen: Rechtsklick ▸ Händler zuweisen.\n\n"
" „Name in GROSSBUCHSTABEN“. Einzelne Positionen: Rechtsklick > Händler zuweisen.\n\n"
"2. Neue Abrechnung (Strg+N), z. B. „Oktober 2026“.\n\n"
"3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n"
" auch alle Wochenlisten eines Monats.\n\n"
"4. Links den Händler wählen. Positionen sind nach Provisionsart gruppiert.\n"
" • Gruppe wählen + „Prozent…“ (F4): Auszahlung für die ganze Provisionsart,\n"
" - Gruppe wählen + „Prozent...“ (F4): Auszahlung für die ganze Provisionsart,\n"
" optional als Standard für diesen Händler merken.\n"
" • Doppelklick / F2 auf eine Position: Betrag frei ändern\n"
" (Enter = speichern, ↓/Tab = nächste Zeile, Esc = abbrechen).\n\n"
" - Doppelklick / F2 auf eine Position: Betrag frei ändern\n"
" (Enter = speichern, Pfeil runter/Tab = nächste Zeile, Esc = abbrechen).\n\n"
"5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat.\n\n"
"Alles wird automatisch in der Datenbank gespeichert.")
def assistent(self):
res = ass.Einrichtungsassistent(self).zeigen()
if self._schliesst:
return # Programm wird gerade beendet – beim nächsten Start erscheint der Assistent erneut
self.settings["assistent_erledigt"] = True
config.save(self.settings)
self._aktualisieren()
@@ -821,6 +916,8 @@ class App:
self.root.after(200, self.rundgang)
def rundgang(self):
if self._schliesst:
return
tb = self.tb
schritte = [
(self.rahmen.titelleiste or self.menueleiste, "Willkommen zum Rundgang!",
@@ -841,9 +938,9 @@ class App:
"Das wird nicht exportiert."),
(self.f_p, "Positionen prüfen",
"Rechts stehen die Positionen des gewählten Händlers, gruppiert nach Provisionsart.\n\n"
"• Doppelklick auf eine Position: Betrag frei ändern\n"
"• Rechtsklick: Auszahlung 0 €, Händler zuweisen, …\n"
"• Blau = von Hand geändert, Rot = durch Prozent-Regel geändert"),
"- Doppelklick auf eine Position: Betrag frei ändern\n"
"- Rechtsklick: Auszahlung 0 €, Händler zuweisen, ...\n"
"- Blau = von Hand geändert, Rot = durch Prozent-Regel geändert"),
(self.b_prozent, "Auszahlung in Prozent",
"Graue Gruppenzeile (Provisionsart) markieren und hier klicken – z. B. 80 % für den Sonderbonus. "
"Mit „Als Standard merken“ gilt das auch in den nächsten Monaten automatisch."),
@@ -868,24 +965,86 @@ class App:
text = "".join(traceback.format_exception(exc, val, tb))
try:
with (config.app_dir() / "fehler.log").open("a", encoding="utf-8") as f:
f.write(text + "\n")
f.write(f"--- {date.today().isoformat()} {APP_NAME}\n{text}\n")
except OSError:
pass
# Beim/nach dem Beenden keine Meldung mehr zeigen – das Fenster gibt es dann nicht mehr
if self._beendet or self._schliesst or not self._lebt():
return
try:
w.fehler(self.root, "Allgemeine Schutzverletzung",
f"Im Programm ist ein Fehler aufgetreten:\n\n{val}\n\n"
f"Details stehen in {config.app_dir() / 'fehler.log'}")
except tk.TclError:
pass
def _lebt(self) -> bool:
try:
return bool(self.root.winfo_exists())
except tk.TclError:
return False
def _offene_fenster(self) -> list[tk.Toplevel]:
"""Alle offenen Dialoge (auch verschachtelte), innerste zuletzt."""
out: list[tk.Toplevel] = []
def suche(widget):
for kind in widget.winfo_children():
if isinstance(kind, tk.Toplevel):
out.append(kind)
suche(kind)
if self._lebt():
suche(self.root)
return out
def beenden(self):
"""Programm schließen – auch wenn gerade Dialoge, Rückfragen oder der Rundgang offen sind.
Offene Dialoge werden zuerst geschlossen; der Code, der auf sie gewartet hat, läuft dann mit
„abgebrochen“ zu Ende (Datenbank noch offen). Neue Dialoge öffnen sich in dieser Phase nicht
mehr. Erst danach werden Einstellungen gespeichert, Datenbank und Fenster geschlossen."""
if self._beendet or not self._lebt():
return
self._schliesst = True
self.root._provtool_schliesst = True # type: ignore[attr-defined] # Signal an win95.Dialog
rg = getattr(self, "_rundgang", None)
if rg is not None:
rg.beenden()
if self._edit is not None:
self._edit.destroy()
self._edit = None
offen = self._offene_fenster()
if offen:
for t in reversed(offen):
try:
if t.winfo_exists():
t.destroy()
except tk.TclError:
pass
self.root.after(30, self.beenden) # wenn alle wartenden Abläufe zurückgekehrt sind
return
self._beendet = True
try:
self.settings["trenner"] = self.pw.sash_coord(0)[0]
self.settings["fenster"] = self.rahmen.normale_geometrie()
self.settings["maximiert"] = self.rahmen.maximiert
config.save(self.settings)
except (tk.TclError, OSError):
pass
finally:
try:
self.db.close()
finally:
try:
self.root.destroy()
except tk.TclError:
pass
def main():
if len(sys.argv) > 1 and sys.argv[1] == "--selbsttest":
from provtool.selbsttest import ausfuehren
sys.exit(ausfuehren(sys.argv[2] if len(sys.argv) > 2 else "selbsttest.txt"))
if sys.platform.startswith("win"):
try: # scharfe Darstellung auf HiDPI-Bildschirmen
import ctypes
+79 -36
View File
@@ -9,6 +9,7 @@ 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 win32
from provtool.gui import win95 as w
from provtool.importer import lies_datei
from provtool.money import fmt, parse_percent
@@ -17,7 +18,7 @@ DATEITYPEN = [("Abrechnungen (CSV, Excel)", "*.csv *.xlsx *.xlsm *.xls *.txt"),
def _nummern(text: str) -> list[str]:
"""'11111102 (6 Pos.), 27518999' → ['11111102', '27518999']"""
"""'11111102 (6 Pos.), 27518999' -> ['11111102', '27518999']"""
out = []
for teil in text.split(","):
m = re.match(r"\s*([A-Za-z0-9]+)", teil)
@@ -121,7 +122,7 @@ class Einrichtungsassistent(w.Dialog):
if w.frage(self, "Einrichtung abbrechen",
"Möchten Sie die Einrichtung wirklich abbrechen?\n\n"
"Bereits angelegte Händler bleiben gespeichert. Den Assistenten finden Sie jederzeit "
"unter ? ▸ Einrichtungs-Assistent.", default_ja=False):
"unter ? > Einrichtungs-Assistent.", default_ja=False):
self.result = None
self.destroy()
@@ -132,7 +133,7 @@ class Einrichtungsassistent(w.Dialog):
for punkt in ("Speicherort der Daten festlegen",
"Unterhändler mit ihren Kennungen anlegen",
"die erste Abrechnung anlegen und Dateien importieren"):
self._text(f" • {punkt}")
self._text(f" - {punkt}")
self._text("\nDanach zeigt Ihnen ein kurzer Rundgang, wo Sie was finden.\n\n"
"Alle Angaben lassen sich später jederzeit ändern.")
self._text("Klicken Sie auf „Weiter“, um fortzufahren.", fg=w.NAVY)
@@ -157,7 +158,7 @@ class Einrichtungsassistent(w.Dialog):
e = tk.Entry(zeile, textvariable=self.v_db_pfad)
e.pack(side="left", fill="x", expand=True)
e.bind("<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 – "
"bitte aber nicht gleichzeitig damit arbeiten.", fg=w.SHADOW)
@@ -221,7 +222,7 @@ class Einrichtungsassistent(w.Dialog):
k = tk.Frame(self.seite, bg=w.FACE)
k.pack(fill="x", pady=(4, 4))
w.knopf(k, "Händler hinzufügen", self._haendler_dazu, self.app.icons("haendler")).pack(side="left")
w.knopf(k, "Nummern aus Abrechnung vorschlagen…", self._vorschlagen).pack(side="left", padx=6)
w.knopf(k, "Nummern aus Abrechnung vorschlagen...", self._vorschlagen).pack(side="left", padx=6)
tk.Label(self.seite, text="Bereits angelegt:", anchor="w").pack(fill="x")
lf = tk.Frame(self.seite, bg=w.FACE)
@@ -312,7 +313,7 @@ class Einrichtungsassistent(w.Dialog):
w.warnung(self, "Nummern vorschlagen", "Nicht lesbar:\n" + "\n".join(fehler))
else:
w.info(self, "Nummern vorschlagen",
"Die gefundenen Nummern stehen jetzt in den Auswahllisten (▼) bei freenet und Vodafone – "
"Die gefundenen Nummern stehen jetzt in den Auswahllisten (Pfeil-Knopf rechts) bei freenet und Vodafone – "
"sortiert nach Anzahl der Positionen.\n\nDie Nummer mit den meisten Positionen ist meist "
"Ihre eigene Filiale.\n\nDie Dateien werden im nächsten Schritt gleich mit importiert.")
@@ -348,7 +349,7 @@ class Einrichtungsassistent(w.Dialog):
self.lb_d.pack(side="left", fill="both", expand=True)
kf = tk.Frame(lf, bg=w.FACE)
kf.pack(side="left", anchor="n", padx=(6, 0))
self._b_dazu = w.knopf(kf, "Hinzufügen…", self._datei_dazu, breite=11)
self._b_dazu = w.knopf(kf, "Hinzufügen...", self._datei_dazu, breite=11)
self._b_dazu.pack(pady=(0, 4))
self._b_weg = w.knopf(kf, "Entfernen", self._datei_weg, breite=11)
self._b_weg.pack()
@@ -384,12 +385,12 @@ class Einrichtungsassistent(w.Dialog):
self._ueberschrift("Fertig!")
n = len(self.app.db.haendler_liste())
self._text(f"Der {APP_NAME} ist eingerichtet.")
self._text(f" • {n} Händler angelegt")
self._text(f" - {n} Händler angelegt")
if self.v_abrechnung.get():
self._text(f" • Abrechnung „{self.v_titel.get().strip()}“"
self._text(f" - Abrechnung „{self.v_titel.get().strip()}“"
+ (f" mit {len(self.dateien)} Datei(en)" if self.dateien else " (noch ohne Dateien)"))
self._text("\nSo geht es danach jeden Monat weiter:\n"
" Neu → Import → prüfen & anpassen → Export")
" Neu > Import > prüfen & anpassen > Export")
tk.Checkbutton(self.seite, text="Kurzen Rundgang durch das Programm starten", variable=self.v_rundgang
).pack(anchor="w", pady=(12, 0))
self._text("\nKlicken Sie auf „Fertig stellen“.", fg=w.NAVY)
@@ -406,34 +407,55 @@ class Rundgang:
self.nr = 0
self.blase: tk.Toplevel | None = None
self.rahmen: list[tk.Toplevel] = []
self._sperre = None # Windows: Hauptfenster während des Rundgangs gesperrt (win32.Modal)
self._zeige(0)
def _weg(self):
for r in self.rahmen:
r.destroy()
self.rahmen = []
if self.blase is not None:
@staticmethod
def _entsorgen(blase, rahmen):
for r in rahmen:
try:
self.blase.grab_release()
r.destroy()
except tk.TclError:
pass
self.blase.destroy()
if blase is not None:
try:
blase.grab_release()
except tk.TclError:
pass
try:
blase.destroy()
except tk.TclError:
pass
def _weg(self):
self._entsorgen(self.blase, self.rahmen)
self.rahmen = []
self.blase = None
def beenden(self):
if self._sperre is not None: # vor dem Schließen freigeben, sonst springt Windows woandershin
self._sperre.beenden()
self._sperre = None
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.overrideredirect(True) # kein transient() – siehe win32.py
t.geometry(f"{max(1, gw)}x{max(1, gh)}+{gx}+{gy}")
t.update_idletasks()
win32.besitzer_setzen(t, self.root) # bleibt über dem Hauptfenster, auch nach Alt+Tab
self.rahmen.append(t)
def _zeige(self, nr: int):
self._weg()
# Die alte Sprechblase verschwindet erst, wenn die neue steht: Wäre kurz kein Popup da,
# während das Hauptfenster gesperrt ist, würde Windows ein fremdes Programm aktivieren.
alt_blase, alt_rahmen = self.blase, self.rahmen
self.blase, self.rahmen = None, []
if getattr(self.root, "_provtool_schliesst", False):
self._entsorgen(alt_blase, alt_rahmen)
return
self.nr = nr
ziel, titel, text = self.schritte[nr]
if callable(ziel) and not isinstance(ziel, tk.Misc):
@@ -444,9 +466,9 @@ class Rundgang:
self._markieren(x, y, bw, bh)
b = self.blase = tk.Toplevel(self.root, bg=w.DARK)
b.abbrechen = self.beenden # Alt+F4 auf der Sprechblase beendet den Rundgang sauber
b.withdraw()
b.overrideredirect(True)
b.transient(self.root)
b.overrideredirect(True) # kein transient() – Besitzer setzt win32.Modal
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)
@@ -488,6 +510,13 @@ class Rundgang:
b.geometry(f"{pw}x{ph}+{px}+{py}")
b.deiconify()
b.lift()
b.update_idletasks()
b.focus_force()
if self._sperre is None:
self._sperre = win32.Modal(b, self.root)
else:
win32.besitzer_setzen(b, self.root)
self._entsorgen(alt_blase, alt_rahmen)
try:
b.grab_set()
except tk.TclError:
@@ -510,26 +539,37 @@ class Rundgang:
TIPPS = [
"Sie können mehrere Dateien gleichzeitig importieren – zum Beispiel alle freenet-Wochenlisten eines Monats "
"und die Vodafone-Datei in einem Rutsch.",
"Markieren Sie eine ganze Provisionsart (die graue Zeile) und klicken Sie auf „Prozent…“, um z. B. nur 80 % "
"Markieren Sie eine ganze Provisionsart (die graue Zeile) und klicken Sie auf „Prozent...“, um z. B. nur 80 % "
"auszuzahlen. Mit dem Häkchen „Als Standard merken“ gilt das automatisch auch in den nächsten Monaten.",
"Doppelklick auf eine Position – und Sie können den Auszahlungsbetrag direkt überschreiben. Mit ↓ geht es "
"Doppelklick auf eine Position – und Sie können den Auszahlungsbetrag direkt überschreiben. Mit Pfeil runter geht es "
"gleich in die nächste Zeile.",
"Blau dargestellte Beträge wurden von Hand geändert, rote weichen durch eine Prozent-Regel von der "
"Provision ab.",
"Eine Position soll gar nicht ausgezahlt werden? Rechtsklick ▸ „Auszahlung 0 €“.",
"Die GROSSBUCHSTABEN-Regel hat einen Kunden falsch erwischt? Rechtsklick ▸ „Händler zuweisen…“ – diese "
"Eine Position soll gar nicht ausgezahlt werden? Rechtsklick > „Auszahlung 0 €“.",
"Die GROSSBUCHSTABEN-Regel hat einen Kunden falsch erwischt? Rechtsklick > „Händler zuweisen...“ – diese "
"Zuordnung hat immer Vorrang.",
"Unter „(Nicht zugeordnet)“ sehen Sie alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. "
"Diese Positionen werden nicht exportiert.",
"Mit Strg+F springen Sie ins Suchfeld. Gesucht wird in Rufnummer, Name, Tarif, Provisionsart und mehr.",
"Wurde eine Datei versehentlich doppelt oder falsch importiert? Datei ▸ Importierte Dateien… – dort lässt "
"Wurde eine Datei versehentlich doppelt oder falsch importiert? Datei > Importierte Dateien... – dort lässt "
"sie sich wieder entfernen.",
"Erstellen Sie ab und zu eine Datensicherung: Datei ▸ Datensicherung erstellen…",
"Erstellen Sie ab und zu eine Datensicherung: Datei > Datensicherung erstellen...",
"Alle Änderungen werden sofort gespeichert. Ein „Speichern“-Knopf ist daher nicht nötig.",
"Ein Händler wurde erst nach dem Import angelegt? Kein Problem – die Zuordnung passt sich sofort an.",
]
BILDER = Path(__file__).resolve().parent / "bilder"
def tipp_bild(master) -> tk.PhotoImage | None:
"""Der Büroklammer-Assistent für den Tipp des Tages (liegt auch in der .exe, siehe .spec)."""
try:
return tk.PhotoImage(master=master, file=str(BILDER / "tipp.png"))
except tk.TclError:
return None
class TippDesTages(w.Dialog):
def __init__(self, app):
super().__init__(app.root, "Tipp des Tages")
@@ -538,17 +578,20 @@ class TippDesTages(w.Dialog):
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)
self._bild = tipp_bild(self)
self.bild_ok = self._bild is not None
if self._bild is None: # Ersatz, falls das Bild fehlt
self._bild = w.make_icon(self, "birne", 3)
tk.Label(box, image=self._bild, bg=w.WHITE).pack(side="left", anchor="n", padx=(12, 4), pady=10)
# fester Textbereich: der Tipp bricht darin um und kann nicht mehr abgeschnitten werden
rechts = tk.Frame(box, bg=w.WHITE, padx=12, pady=10, width=380, height=self._bild.height() + 20)
rechts.pack(side="left", fill="both")
tk.Label(rechts, text="Wussten Sie schon…", bg=w.WHITE, font=(w.FONT_FAMILY, w.FONT_SIZE + 6, "bold"),
rechts.pack_propagate(False)
tk.Label(rechts, text="Wussten Sie schon...", bg=w.WHITE, font=(w.FONT_FAMILY, w.FONT_SIZE + 6, "bold"),
anchor="w").pack(fill="x")
tk.Frame(rechts, bg=w.DARK, height=1).pack(fill="x", pady=6)
self.text = tk.Label(rechts, bg=w.WHITE, justify="left", anchor="nw", wraplength=330, width=48, height=6)
self.text.pack(fill="both")
self.text = tk.Label(rechts, bg=w.WHITE, justify="left", anchor="nw", wraplength=380 - 2 * 12)
self.text.pack(fill="both", expand=True)
knoepfe = tk.Frame(haupt, bg=w.FACE)
knoepfe.pack(side="left", anchor="n", padx=(10, 0))
w.knopf(knoepfe, "Schließen", self.abbrechen, breite=12, default="active").pack(pady=(0, 6))
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+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.importer import ANBIETER
# Auswahl im Händler-Dialog: Schlüssel → Anzeige
# Auswahl im Händler-Dialog: Schlüssel -> Anzeige
KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)",
CAPS_ANBIETER: "Vodafone (Name in GROSSBUCHSTABEN)"}
CAPS_TEXT = "Kunden-Nachname komplett in GROSSBUCHSTABEN"
@@ -100,7 +100,7 @@ class HaendlerDialog(w.Dialog):
r3.pack(fill="both", expand=True)
rf = tk.Frame(g3, bg=w.FACE)
rf.pack(fill="x", pady=(4, 0))
tk.Label(rf, text="Regeln entstehen im Hauptfenster über „Prozent…“.", fg=w.SHADOW).pack(side="left")
tk.Label(rf, text="Regeln entstehen im Hauptfenster über „Prozent...“.", fg=w.SHADOW).pack(side="left")
w.knopf(rf, "Entfernen", self._regel_weg).pack(side="right")
unten = tk.Frame(self, bg=w.FACE)
@@ -292,7 +292,7 @@ class ProzentDialog(w.Dialog):
from provtool.money import apply_percent
try:
p = parse_percent(self.v.get())
self.vorschau.configure(text=f"→ Auszahlung ca. {fmt(apply_percent(self.summe_orig, p))}")
self.vorschau.configure(text=f"-> Auszahlung ca. {fmt(apply_percent(self.summe_orig, p))}")
except ValueError:
self.vorschau.configure(text="(ungültiger Wert)")
@@ -327,7 +327,7 @@ class AbrechnungenDialog(w.Dialog):
k = tk.Frame(self, bg=w.FACE)
k.pack(fill="x", padx=10, pady=10)
w.knopf(k, "Öffnen", self._oeffnen, breite=10, default="active").pack(side="left")
w.knopf(k, "Umbenennen…", self._umbenennen, breite=12).pack(side="left", padx=6)
w.knopf(k, "Umbenennen...", self._umbenennen, breite=12).pack(side="left", padx=6)
w.knopf(k, "Löschen", self._loeschen, breite=10).pack(side="left")
w.knopf(k, "Abbrechen", self.abbrechen, breite=10).pack(side="right")
self.bind("<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):",
anchor="w").pack(fill="x", padx=10, pady=(10, 2))
r2, self.ut = w.scroll_tree(self, columns=("anbieter", "kennung", "anzahl", "summe"), show="headings",
height=min(6, len(unbekannt)), selectmode="browse")
height=min(6, len(unbekannt)), selectmode="extended")
for c, h, b, a in (("anbieter", "Anbieter", 90, "w"), ("kennung", "Kennung", 140, "w"),
("anzahl", "Pos.", 50, "e"), ("summe", "Summe", 90, "e")):
self.ut.heading(c, text=h)
@@ -451,14 +451,80 @@ class ImportBericht(w.Dialog):
k = tk.Frame(self, bg=w.FACE)
k.pack(fill="x", padx=10, pady=10)
if unbekannt:
w.knopf(k, "Kennung einem Händler zuordnen…", self._zuordnen).pack(side="left")
w.knopf(k, "Kennung einem Händler zuordnen...", self._zuordnen).pack(side="left")
w.knopf(k, "Als eigene ausblenden...", self._eigen).pack(side="left", padx=6)
w.knopf(k, "OK", self.abbrechen, breite=10, default="active").pack(side="right")
self.bind("<Return>", lambda e: self.abbrechen())
def _zuordnen(self):
s = self.ut.selection()
if s:
self.result = tuple(s[0].split("\t", 1))
a, k = s[0].split("\t", 1)
self.result = ("zuordnen", a, k)
self.destroy()
def _eigen(self):
s = self.ut.selection()
if s:
self.result = ("eigen", [tuple(x.split("\t", 1)) for x in s])
self.destroy()
class EigeneKennungenDialog(w.Dialog):
"""Kennungen der eigenen Filiale: Positionen damit werden ausgeblendet und nicht exportiert."""
def __init__(self, master, db: Datenbank):
super().__init__(master, "Eigene Kennungen")
self.db, self.geaendert = db, False
tk.Label(self, text="Positionen mit diesen Kennungen gehören zur eigenen Filiale. Sie werden\n"
"ausgeblendet, nicht mitgerechnet und nicht exportiert.\n"
"(Ist eine Kennung einem Händler zugeordnet, hat der Händler Vorrang.)",
justify="left", anchor="w").pack(fill="x", padx=10, pady=(10, 4))
r, self.tree = w.scroll_tree(self, columns=("anbieter", "wert"), show="headings", height=7,
selectmode="extended")
self.tree.heading("anbieter", text="Anbieter")
self.tree.heading("wert", text="Unterhändler-Nr. / VO-Nr.")
self.tree.column("anbieter", width=100)
self.tree.column("wert", width=200)
r.pack(fill="both", expand=True, padx=10)
z = tk.Frame(self, bg=w.FACE)
z.pack(fill="x", padx=10, pady=6)
self.v_anb = tk.StringVar(value="freenet")
ttk.Combobox(z, textvariable=self.v_anb, values=list(ANBIETER.values()), width=10,
state="readonly").pack(side="left")
self.v_wert = tk.StringVar()
e = tk.Entry(z, textvariable=self.v_wert, width=18)
e.pack(side="left", padx=4)
e.bind("<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()
@@ -473,7 +539,7 @@ class KennungZuordnen(w.Dialog):
self.v = tk.StringVar(value="__neu__")
f = tk.Frame(self, bg=w.FACE)
f.pack(fill="x", padx=20)
tk.Radiobutton(f, text="Neuem Händler …", variable=self.v, value="__neu__").pack(anchor="w")
tk.Radiobutton(f, text="Neuem Händler ...", variable=self.v, value="__neu__").pack(anchor="w")
haendler = db.haendler_liste()
if haendler:
tk.Radiobutton(f, text="Bestehendem Händler:", variable=self.v, value="__alt__").pack(anchor="w")
@@ -515,7 +581,7 @@ class HaendlerWaehlen(w.Dialog):
self.cb = ttk.Combobox(self, values=list(self._hmap) + [self.KEINER], state="readonly", width=40)
self.cb.pack(padx=12, pady=4)
tk.Label(self, text="Die feste Zuweisung hat Vorrang vor Kennungen und der GROSSBUCHSTABEN-Regel.\n"
"Rückgängig: Rechtsklick ▸ „Automatisch zuordnen“.", justify="left",
"Rückgängig: Rechtsklick > „Automatisch zuordnen“.", justify="left",
fg=w.SHADOW).pack(fill="x", padx=12)
self.bind("<Return>", lambda e: self._ok())
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=12)
@@ -571,7 +637,7 @@ class ExportDialog(w.Dialog):
tk.Label(g, text="Zielordner:").grid(row=1, column=0, sticky="w")
self.v_ordner = tk.StringVar(value=settings.get("export_ordner") or "")
tk.Entry(g, textvariable=self.v_ordner, width=40).grid(row=1, column=1, sticky="we", pady=2)
w.knopf(g, "Durchsuchen…", self._ordner).grid(row=1, column=2, padx=(4, 0))
w.knopf(g, "Durchsuchen...", self._ordner).grid(row=1, column=2, padx=(4, 0))
self.v_anb = tk.BooleanVar(value=settings.get("export_pro_anbieter", True))
self.v_kopf = tk.BooleanVar(value=settings.get("export_kopfzeile", False))
self.v_sum = tk.BooleanVar(value=settings.get("export_summe", True))
@@ -637,7 +703,7 @@ class EinstellungenDialog(w.Dialog):
row=0, column=0, columnspan=2, sticky="w")
self.v = tk.StringVar(value=db_pfad)
tk.Entry(g, textvariable=self.v, width=56).grid(row=1, column=0, sticky="we", pady=4)
w.knopf(g, "Ändern…", self._waehlen).grid(row=1, column=1, padx=(4, 0))
w.knopf(g, "Ändern...", self._waehlen).grid(row=1, column=1, padx=(4, 0))
tk.Label(g, text="Tipp: Liegt die Datei auf einem Netzlaufwerk, können mehrere PCs dieselben\n"
"Händler nutzen – aber bitte nicht gleichzeitig damit arbeiten.",
justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w")
+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)
+122 -34
View File
@@ -183,38 +183,33 @@ class Titelleiste(tk.Frame):
def _start(self, e):
self._ab = (e.x_root - self.fenster.winfo_rootx(), e.y_root - self.fenster.winfo_rooty())
self._nativ = False
if NATIV_VERSCHIEBEN and IS_WIN and self.ziehen_erlaubt():
# Windows übernimmt das Ziehen: flüssig und mit Andocken am Bildschirmrand
from provtool.gui import win32
self._nativ = win32.nativ_verschieben(self.fenster)
if self._nativ:
return "break"
def _ziehen(self, e):
if not self.ziehen_erlaubt():
if self._nativ or not self.ziehen_erlaubt():
return
self.fenster.geometry(f"+{e.x_root - self._ab[0]}+{e.y_root - self._ab[1]}")
def blinken(self, mal: int = 3):
"""Titelleiste kurz grau/blau blinken lassen (wie Windows bei gesperrtem Fenster)."""
farben = [w.SHADOW, w.NAVY] * mal
class Menueleiste(tk.Frame):
"""Graue Menüleiste (die native von Windows 10/11 ist weiß und lässt sich nicht umfärben)."""
def schritt(i=0):
if i >= len(farben) or not self.winfo_exists():
return
for widget in (self, self.titel_lbl):
widget.configure(bg=farben[i])
self.after(70, lambda: schritt(i + 1))
schritt()
def __init__(self, master, root: tk.Misc):
super().__init__(master, bg=w.FACE)
self.root = root
self.knoepfe: dict[str, tk.Menubutton] = {}
def menue(self, label: str, underline: int | None = 0) -> tk.Menu:
mb = tk.Menubutton(self, text=label, bg=w.FACE, relief="flat", bd=1, padx=6, pady=1,
activebackground=w.NAVY, activeforeground=w.WHITE,
underline=-1 if underline is None else underline)
menu = tk.Menu(mb, tearoff=0)
mb.configure(menu=menu)
mb.pack(side="left")
self.knoepfe[label] = mb
if underline is not None:
taste = label[underline].lower()
self.root.bind_all(f"<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"
NATIV_VERSCHIEBEN = True # Tests können das abschalten, um den Tk-Weg zu prüfen
# --------------------------------------------------------------------------- Hauptfenster
@@ -222,7 +217,8 @@ class Menueleiste(tk.Frame):
class Hauptrahmen:
"""Umgibt das Hauptfenster. Im klassischen Modus mit eigener Titelleiste, sonst nur ein Container."""
RAND = 5 # Pixel am Rand, an denen man die Größe ziehen kann
RAND = 6 # Pixel am Rand, an denen man die Größe ziehen kann (= sichtbarer Rahmen)
ECKE = 18 # so weit vom Eck entfernt gilt der Rand noch als Ecke (diagonal ziehen)
def __init__(self, root: tk.Tk, titel: str, icon: tk.PhotoImage, schliessen, klassisch: bool = True,
minbreite: int = 900, minhoehe: int = 520):
@@ -237,10 +233,11 @@ class Hauptrahmen:
self.aussen.pack(fill="both", expand=True)
self.inhalt = self.aussen
self.titelleiste = None
root.sperrbereich = lambda: [self.inhalt] # type: ignore[attr-defined] # für modal.py
return
root.overrideredirect(True)
self.aussen = tk.Frame(root, bg=w.FACE, bd=2, relief="raised", padx=2, pady=2)
self.aussen = tk.Frame(root, bg=w.FACE, bd=2, relief="raised", padx=self.RAND - 2, pady=self.RAND - 2)
self.aussen.pack(fill="both", expand=True)
self.titelleiste = Titelleiste(self.aussen, root, titel, icon, ("min", "max", "close"),
schliessen=schliessen, minimieren=self.minimieren,
@@ -249,15 +246,71 @@ class Hauptrahmen:
self.titelleiste.ziehen_erlaubt = lambda: not self.maximiert
self.inhalt = tk.Frame(self.aussen, bg=w.FACE)
self.inhalt.pack(fill="both", expand=True)
# Gesperrt wird bei offenem Dialog nur der Inhalt – Titelleiste und Rand bleiben bedienbar
root.sperrbereich = lambda: [self.inhalt] # type: ignore[attr-defined]
self._rand: str = ""
self._resize_geplant = None
self.aussen.bind("<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("<B1-Motion>", self._resize)
root.bind_all("<Alt-F4>", lambda e: schliessen())
root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget))
root.after(50, lambda: taskleiste_anzeigen(root))
def alt_f4(self, widget) -> str:
"""Alt+F4 wie unter Windows: schließt nur das Fenster, in dem man gerade ist."""
try:
# Ist ein modaler Dialog offen, gilt Alt+F4 immer ihm – egal wo der Tastaturfokus liegt
# (unter Windows liefert Tk Tastendrücke an das Fenster mit Fokus, das kann noch das
# Hauptfenster sein).
from provtool.gui import modal
sperre = self.root.grab_current() or modal.oberstes()
popup = self._offenes_popup()
if sperre is not None and sperre.winfo_toplevel() is not self.root:
fenster = sperre.winfo_toplevel()
elif popup is not None: # Sperre (noch) nicht gesetzt – trotzdem gilt Alt+F4 dem Popup
fenster = popup
else:
fenster = widget.winfo_toplevel() if isinstance(widget, tk.Misc) else self.root
except tk.TclError:
fenster = self.root
if fenster is self.root:
from provtool.gui import modal
offen = modal.oberstes()
if offen is not None: # Alt+F4 bei offenem Dialog schließt den Dialog
offen.abbrechen()
return "break"
self._schliessen()
elif hasattr(fenster, "abbrechen"):
fenster.abbrechen()
else:
fenster.destroy()
return "break"
def _offenes_popup(self):
"""Innerstes sichtbares Popup mit abbrechen() (Dialog, Rückfrage, Rundgang-Sprechblase)."""
gefunden = []
def suche(widget):
for kind in widget.winfo_children():
# Fensterstatus statt winfo_ismapped(): direkt nach dem Einblenden ist der Status schon
# „normal“, gezeichnet ist das Fenster aber evtl. noch nicht (langsamer PC, Wine).
if isinstance(kind, tk.Toplevel) and hasattr(kind, "abbrechen"):
try:
offen = kind.winfo_exists() and kind.wm_state() != "withdrawn"
except tk.TclError:
offen = False
if offen:
gefunden.append(kind)
suche(kind)
suche(self.root)
return gefunden[-1] if gefunden else None
# ---- öffentliche Helfer
def set_titel(self, text: str):
self.root.title(text)
@@ -295,7 +348,14 @@ class Hauptrahmen:
# ---- Fensterfunktionen
def minimieren(self):
# Windows minimiert keine overrideredirect-Fenster → kurz normalen Rahmen aktivieren
if IS_WIN:
# Direkt über Windows: offene Dialoge (gehören dem Hauptfenster) werden mit aus- und
# wieder eingeblendet. NICHT overrideredirect umschalten – Tk baut dabei das Fenster neu
# und Windows würde die zugehörigen Dialoge zerstören.
from provtool.gui import win32
if win32.minimieren(self.root):
return
# Tk minimiert keine overrideredirect-Fenster -> kurz normalen Rahmen aktivieren
geo = self.root.geometry()
self.root.overrideredirect(False)
self.root.iconify()
@@ -330,9 +390,25 @@ class Hauptrahmen:
# ---- Größe ändern
def _welcher_rand(self, x, y) -> str:
bw, bh, r = self.aussen.winfo_width(), self.aussen.winfo_height(), self.RAND
rand = ("n" if y < r else "s" if y >= bh - r else "") + ("w" if x < r else "e" if x >= bw - r else "")
return rand
bw, bh, r, k = self.aussen.winfo_width(), self.aussen.winfo_height(), self.RAND, self.ECKE
ns = "n" if y < r else "s" if y >= bh - r else ""
we = "w" if x < r else "e" if x >= bw - r else ""
# an einer Kante nahe am Eck -> Ecke (diagonal), wie bei Windows
if ns and not we:
we = "w" if x < k else "e" if x >= bw - k else ""
elif we and not ns:
ns = "n" if y < k else "s" if y >= bh - k else ""
return ns + we
def _cursor_zurueck(self, _e=None):
if self._rand_zeiger:
self._rand_zeiger = False
try:
self.aussen.configure(cursor="")
except tk.TclError:
pass
_rand_zeiger = False
CURSOR_WIN = {"n": "size_ns", "s": "size_ns", "e": "size_we", "w": "size_we",
"nw": "size_nw_se", "se": "size_nw_se", "ne": "size_ne_sw", "sw": "size_ne_sw"}
@@ -344,8 +420,10 @@ class Hauptrahmen:
if self.maximiert:
return
rand = self._welcher_rand(e.x, e.y)
zeiger = (self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "")
try:
self.aussen.configure(cursor=(self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, ""))
self.aussen.configure(cursor=zeiger)
self._rand_zeiger = bool(zeiger)
except tk.TclError:
pass
@@ -369,4 +447,14 @@ class Hauptrahmen:
if "n" in self._rand:
neu = max(self.minhoehe, bh - dy)
y, bh = y + (bh - neu), neu
self.root.geometry(f"{bw}x{bh}+{x}+{y}")
# Mausbewegungen bündeln: nur die jeweils letzte Größe anwenden (flüssiger unter Windows)
self._resize_ziel = f"{bw}x{bh}+{x}+{y}"
if self._resize_geplant is None:
self._resize_geplant = self.root.after(15, self._resize_anwenden)
def _resize_anwenden(self):
self._resize_geplant = None
try:
self.root.geometry(self._resize_ziel)
except tk.TclError:
pass
+203
View File
@@ -0,0 +1,203 @@
"""Win32-Hilfen für modale Fenster (nur unter Windows wirksam, sonst ohne Funktion).
Wichtig: Rahmenlose Fenster NICHT zusätzlich mit Tk `transient()` versehen – Tk legt sie dann als
Kindfenster des Desktops (WS_CHILD) an, und denen kann Windows keinen Besitzer geben.
Rahmenlose Tk-Fenster (overrideredirect) gehören für Windows zu keinem anderen Fenster. Holt man das
Programm per Taskleiste oder Alt+Tab zurück, kommt dann nur das Hauptfenster nach vorn – ein offener
Dialog bliebe dahinter verborgen. Deshalb machen wir es wie Windows bei echten modalen Dialogen:
der Dialog bekommt das aufrufende Fenster als *Besitzer*, und der Besitzer wird solange *deaktiviert*.
Windows bringt dann bei Klick aufs Hauptfenster, Taskleiste und Alt+Tab automatisch den Dialog nach vorn.
"""
from __future__ import annotations
import sys
import tkinter as tk
IS_WIN = sys.platform.startswith("win")
GWLP_HWNDPARENT = -8
GW_OWNER = 4
_u32 = None
def user32():
"""user32 mit korrekten 64-Bit-Signaturen (Fensterhandles dürfen nicht abgeschnitten werden)."""
global _u32
if _u32 is None:
import ctypes
from ctypes import wintypes
u = ctypes.WinDLL("user32", use_last_error=True)
u.GetParent.argtypes = [wintypes.HWND]
u.GetParent.restype = wintypes.HWND
u.SetWindowLongPtrW.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_void_p]
u.SetWindowLongPtrW.restype = ctypes.c_void_p
u.EnableWindow.argtypes = [wintypes.HWND, wintypes.BOOL]
u.EnableWindow.restype = wintypes.BOOL
u.IsWindowEnabled.argtypes = [wintypes.HWND]
u.IsWindowEnabled.restype = wintypes.BOOL
u.GetWindow.argtypes = [wintypes.HWND, wintypes.UINT]
u.GetWindow.restype = wintypes.HWND
u.GetLastActivePopup.argtypes = [wintypes.HWND]
u.GetLastActivePopup.restype = wintypes.HWND
u.SetForegroundWindow.argtypes = [wintypes.HWND]
u.SetForegroundWindow.restype = wintypes.BOOL
u.IsWindow.argtypes = [wintypes.HWND]
u.IsWindow.restype = wintypes.BOOL
u.GetAncestor.argtypes = [wintypes.HWND, wintypes.UINT]
u.GetAncestor.restype = wintypes.HWND
u.GetTopWindow.argtypes = [wintypes.HWND]
u.GetTopWindow.restype = wintypes.HWND
u.BringWindowToTop.argtypes = [wintypes.HWND]
u.BringWindowToTop.restype = wintypes.BOOL
u.GetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int]
u.GetWindowLongW.restype = ctypes.c_long
u.SetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_long]
u.SetWindowLongW.restype = ctypes.c_long
u.ReleaseCapture.argtypes = []
u.ReleaseCapture.restype = wintypes.BOOL
u.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
u.SendMessageW.restype = wintypes.LPARAM
u.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
u.ShowWindow.restype = wintypes.BOOL
u.IsIconic.argtypes = [wintypes.HWND]
u.IsIconic.restype = wintypes.BOOL
u.FlashWindow.argtypes = [wintypes.HWND, wintypes.BOOL]
u.FlashWindow.restype = wintypes.BOOL
_u32 = u
return _u32
def hwnd(fenster: tk.Misc) -> int | None:
"""Handle des äußeren (obersten) Windows-Fensters eines Tk-Toplevels."""
if not IS_WIN:
return None
try:
return user32().GetAncestor(fenster.winfo_toplevel().winfo_id(), 2) # GA_ROOT
except (tk.TclError, OSError):
return None
def stapel(*fenster: tk.Misc) -> list[int]:
"""Reihenfolge der übergebenen Fenster im Windows-Fensterstapel, oberstes zuerst (Indizes)."""
ziele = [hwnd(f) for f in fenster]
out, w = [], user32().GetTopWindow(None)
while w:
if w in ziele:
out.append(ziele.index(w))
w = user32().GetWindow(w, 2) # GW_HWNDNEXT
return out
def nach_vorn(fenster: tk.Misc) -> None:
h = hwnd(fenster)
if h:
user32().SetForegroundWindow(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:
h, b = hwnd(fenster), hwnd(besitzer)
if h and b:
user32().SetWindowLongPtrW(h, GWLP_HWNDPARENT, b)
def besitzer_von(fenster: tk.Misc) -> int | None:
h = hwnd(fenster)
return user32().GetWindow(h, GW_OWNER) if h else None
def bedienbar(fenster: tk.Misc, an: bool) -> None:
h = hwnd(fenster)
if h and user32().IsWindow(h):
user32().EnableWindow(h, bool(an))
def ist_bedienbar(fenster: tk.Misc) -> bool:
h = hwnd(fenster)
return bool(user32().IsWindowEnabled(h)) if h else True
def letztes_popup(fenster: tk.Misc) -> int | None:
h = hwnd(fenster)
return user32().GetLastActivePopup(h) if h else None
class Modal:
"""Macht `fenster` modal zu `besitzer` (Windows-Art). beenden() muss VOR dem Zerstören des
Fensters laufen: erst Besitzer wieder freigeben, dann schließen – sonst aktiviert Windows
beim Schließen ein fremdes Programm statt unseres Hauptfensters."""
def __init__(self, fenster: tk.Misc, besitzer: tk.Misc, sperren: bool = True):
self.besitzer = besitzer
self.gesperrt = False
if not IS_WIN:
return
besitzer_setzen(fenster, besitzer)
if sperren and ist_bedienbar(besitzer):
bedienbar(besitzer, False)
self.gesperrt = True
def beenden(self) -> None:
if self.gesperrt:
self.gesperrt = False
try:
bedienbar(self.besitzer, True)
except tk.TclError:
pass
+134 -24
View File
@@ -294,6 +294,9 @@ def apply(root: tk.Tk) -> None:
o("*Background", FACE)
o("*Foreground", DARK)
o("*activeBackground", FACE)
# Tk-Eigenheit: „activeForeground“ gehört zur Optionsklasse „Background“ – ohne diese Zeile
# wäre die Schrift gedrückter Knöpfe grau auf grau (unsichtbar).
o("*activeForeground", DARK)
o("*highlightThickness", 0)
o("*selectBackground", NAVY)
o("*selectForeground", WHITE)
@@ -322,6 +325,7 @@ def apply(root: tk.Tk) -> None:
from tkinter import font as tkfont
zeile = max(18, tkfont.Font(root=root, font=FONT).metrics("linespace") + 5)
wort_loeschen_einrichten(root)
st = ttk.Style(root)
st.theme_use("classic" if "classic" in st.theme_names() else "default")
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=LIGHT,
@@ -341,6 +345,78 @@ def apply(root: tk.Tk) -> None:
st.configure("Sash", sashthickness=6, background=FACE)
# --------------------------------------------------------------------------- Wort löschen
def _wortgrenze(text: str, pos: int, richtung: int) -> int:
"""Position der nächsten Wortgrenze von pos aus (richtung -1 = links, +1 = rechts),
wie Strg+Rücktaste / Strg+Entf unter Windows: erst Leerzeichen, dann das Wort."""
if richtung < 0:
i = pos
while i > 0 and text[i - 1].isspace():
i -= 1
if i > 0 and text[i - 1].isalnum():
while i > 0 and text[i - 1].isalnum():
i -= 1
elif i > 0:
i -= 1 # Satzzeichen einzeln
return i
i = pos
if i < len(text) and text[i].isalnum():
while i < len(text) and text[i].isalnum():
i += 1
elif i < len(text):
i += 1
while i < len(text) and text[i].isspace():
i += 1
return i
def _wort_weg(event, richtung: int):
e = event.widget
try:
if str(e.cget("state")) in ("disabled", "readonly"):
return "break"
except tk.TclError:
pass
try:
if e.selection_present():
e.delete("sel.first", "sel.last")
return "break"
except (tk.TclError, AttributeError):
pass
text, pos = e.get(), e.index("insert")
grenze = _wortgrenze(text, pos, richtung)
if richtung < 0 and grenze < pos:
e.delete(grenze, pos)
elif richtung > 0 and grenze > pos:
e.delete(pos, grenze)
return "break"
def _text_wort_weg(event, richtung: int):
t = event.widget
if str(t.cget("state")) == "disabled":
return "break"
if t.tag_ranges("sel"):
t.delete("sel.first", "sel.last")
return "break"
if richtung < 0:
t.delete("insert -1c wordstart" if t.get("insert -1c").isspace() else "insert wordstart", "insert")
else:
t.delete("insert", "insert wordend")
return "break"
def wort_loeschen_einrichten(root: tk.Misc):
"""Strg+Rücktaste / Strg+Entf löschen das ganze Wort – in allen Eingabefeldern (Tk kann das
unter Windows von Haus aus nicht, dort wird nur ein Zeichen gelöscht)."""
for klasse in ("Entry", "TEntry", "TCombobox", "Spinbox", "TSpinbox"):
root.bind_class(klasse, "<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
class Sunken(tk.Frame):
@@ -434,8 +510,12 @@ class Tooltip:
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
self.tip = tw = tk.Toplevel(self.widget)
tw.wm_overrideredirect(True)
tw.wm_geometry(f"+{x}+{y}")
tw.withdraw()
tk.Label(tw, text=self.text, bg=INFO_BG, bd=1, relief="solid", padx=4, pady=1).pack()
tw.update_idletasks()
# Größe + Position zusammen (reine Position ignoriert Tk unter Windows bei rahmenlosen Fenstern)
tw.wm_geometry(f"{tw.winfo_reqwidth()}x{tw.winfo_reqheight()}+{x}+{y}")
tw.deiconify()
def _weg(self, _e=None):
if self._after:
@@ -502,15 +582,24 @@ KLASSISCH = False
class Dialog(tk.Toplevel):
"""Modaler Dialog im Win95-Stil."""
"""Modaler Dialog im Win95-Stil.
Modal heißt hier: Der INHALT des aufrufenden Fensters ist gesperrt (siehe modal.py), dessen
Titelleiste und Rand bleiben bedienbar. Unter Windows gehört der Dialog dem aufrufenden Fenster
(bleibt darüber, kommt mit Alt+Tab nach vorn) und hat einen eigenen Taskleisten-Eintrag.
"""
def __init__(self, master, titel: str, resizable: bool = False):
super().__init__(master, bg=FACE)
self.withdraw()
self.title(titel)
if not KLASSISCH:
# Rahmenlose Dialoge bekommen ihren Besitzer über win32 – mit transient() würde Tk sie
# unter Windows als Kindfenster anlegen, die dann hinter anderen Programmen hängen.
self.transient(master.winfo_toplevel())
self.resizable(resizable, resizable)
self.result = None
self._sperre = None
self.protocol("WM_DELETE_WINDOW", self.abbrechen)
self.bind("<Escape>", lambda e: self.abbrechen())
try:
@@ -526,40 +615,61 @@ class Dialog(tk.Toplevel):
self.titelleiste.pack(side="top", fill="x")
def zeigen(self, fokus=None):
from provtool.gui import modal, win32
if getattr(self._root(), "_provtool_schliesst", False):
self.destroy() # Programm wird beendet – keine neuen Dialoge mehr
return None
self.update_idletasks()
m = self.master.winfo_toplevel()
self._aufrufer = m
w, h = self.winfo_reqwidth(), self.winfo_reqheight()
x = m.winfo_rootx() + max(0, (m.winfo_width() - w) // 2)
y = m.winfo_rooty() + max(0, (m.winfo_height() - h) // 3)
self.geometry(f"+{max(0, x)}+{max(0, y)}")
self.geometry(f"{w}x{h}+{max(0, x)}+{max(0, y)}") # Größe + Position zusammen (siehe Tooltip)
self.deiconify()
self.update_idletasks()
if KLASSISCH and win32.IS_WIN:
# eigener Taskleisten-Eintrag; wird erst nach kurzem Aus-/Einblenden wirksam
if win32.taskleisteneintrag(self):
self.withdraw()
self.deiconify()
self.update_idletasks()
win32.besitzer_setzen(self, m)
self._sperre = modal.Sperre(m, self)
self.lift()
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 nach_vorn(self, blinken: bool = False):
"""Dialog nach vorn holen (z. B. nach Klick auf das gesperrte Hauptfenster)."""
from provtool.gui import win32
try:
self.lift()
self.focus_force()
win32.nach_vorn(self)
except tk.TclError:
return
if blinken:
self.bell()
win32.blinken(self)
if self.titelleiste is not None:
self.titelleiste.blinken()
def destroy(self):
sperre = getattr(self, "_sperre", None)
if sperre is not None:
sperre.aufheben()
self._sperre = None
aufrufer = getattr(self, "_aufrufer", None)
super().destroy()
try: # Fokus zurück an das aufrufende Fenster
if aufrufer is not None and aufrufer.winfo_exists():
aufrufer.focus_force()
except tk.TclError:
pass
def abbrechen(self):
self.result = None
self.destroy()
+182
View File
@@ -0,0 +1,182 @@
"""Selbsttest der fertigen Programmdatei: `Provisionsmanager95.exe --selbsttest ergebnis.txt`
Läuft im Build (Wine + virtueller Bildschirm) gegen die tatsächlich ausgelieferte .exe. Startet die
echte Oberfläche in einem Wegwerf-Profil, spielt kritische Abläufe durch und schreibt „OK“ bzw. die
gefundenen Fehler in die Ergebnisdatei (Exit-Code 0 = bestanden).
Geprüft wird u. a. der Fall „Programm schließen, während der Einrichtungs-Assistent offen ist“
sowie Import und Export (openpyxl/xlrd müssen in der .exe enthalten sein).
"""
from __future__ import annotations
import os
import sys
import tempfile
import time
import tkinter as tk
import traceback
from pathlib import Path
ZEITLIMIT = 60 # Sekunden je Szenario
class _Szenario:
def __init__(self, name: str, home: Path, einstellungen: str | None = None):
self.name, self.home, self.fehler = name, home, []
home.mkdir(parents=True, exist_ok=True)
os.environ["PROVTOOL_HOME"] = str(home)
if einstellungen:
(home / "einstellungen.json").write_text(einstellungen, encoding="utf-8")
from provtool.gui.app import App
self.root = tk.Tk()
self.app = App(self.root)
echt = self.root.report_callback_exception
def melden(exc, val, tb):
self.fehler.append("".join(traceback.format_exception(exc, val, tb)))
try:
echt(exc, val, tb)
except Exception:
self.fehler.append("Fehler-Handler ist abgestürzt:\n" + traceback.format_exc())
self.root.report_callback_exception = melden
def lebt(self) -> bool:
try:
return bool(self.root.winfo_exists())
except tk.TclError:
return False
def dialog(self, typ):
from provtool.gui import win95 as w
gefunden = []
def suche(widget):
for kind in widget.winfo_children():
if isinstance(kind, w.Dialog) and isinstance(kind, typ) and kind.winfo_exists():
gefunden.append(kind)
suche(kind)
if self.lebt():
suche(self.root)
return gefunden[0] if gefunden else None
def ablauf(self, schritte):
offen, start = list(schritte), time.time()
def poll():
if not self.lebt():
return
if time.time() - start > ZEITLIMIT:
self.fehler.append(f"Zeitüberschreitung bei: {offen[0][2] if offen else 'Ende'}")
try:
self.root.destroy()
except tk.TclError:
pass
return
if offen:
try:
bereit = offen[0][0]()
except Exception:
bereit = False
if bereit:
_, aktion, _ = offen.pop(0)
def ausfuehren(a=aktion):
try:
a()
except Exception:
self.fehler.append(traceback.format_exc())
self.root.after(0, ausfuehren)
self.root.after(20, poll)
self.root.after(20, poll)
self.root.mainloop()
if offen:
self.fehler.append("Nicht ausgeführt: " + ", ".join(s[2] for s in offen))
log = self.home / "fehler.log"
if log.exists() and log.read_text(encoding="utf-8").strip():
self.fehler.append("fehler.log:\n" + log.read_text(encoding="utf-8"))
return [f"[{self.name}] {f}" for f in self.fehler]
def _beispiel_csv(pfad: Path):
kopf = ("Zeitraum;Verguetungsart;Haendlernummer;Unterhaendler;Vertragsnummer;Aktiv;Deaktiv;Rufnummer;"
"Kartennummer;IMEI_SN;Geraetename;Kundenvorname;Kundename;Tarif-Kurz;Tarif;Prozent;Betrag")
zeilen = [kopf,
"2026.10.01-2026.10.07;SIM-Karten;1;111;1;05.10.2026;;1601111111;;;;;Muster GmbH;;Allnet;0;8",
"2026.10.01-2026.10.07;Sonderbonus Aktion;1;111;2;06.10.2026;;1602222222;;;;;Beispiel KG;;Classic;0;170"]
pfad.write_bytes(("\r\n".join(zeilen) + "\r\n").encode("cp1252"))
def ausfuehren(ergebnisdatei: str) -> int:
from provtool.gui.assistent import Einrichtungsassistent
basis = Path(tempfile.mkdtemp(prefix="pm95-selbsttest-"))
fehler: list[str] = []
immer = lambda: True # noqa: E731
try:
# 1) Gemeldeter Absturz: Schließen, während der Einrichtungs-Assistent offen ist
s = _Szenario("Schließen bei offenem Assistenten", basis / "a")
cmd = s.root.tk.call("wm", "protocol", s.root._w, "WM_DELETE_WINDOW")
fehler += s.ablauf([(lambda: s.dialog(Einrichtungsassistent) is not None, lambda: None, "Assistent erscheint"),
(immer, lambda: s.root.tk.call(cmd), "Fenster schließen")])
# 2) Assistent komplett, Import, Export, Rundgang, Beenden
csv = basis / "freenet.csv"
_beispiel_csv(csv)
s = _Szenario("Assistent, Import, Export", basis / "b")
wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731
seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731
def haendler():
wiz().v_name.set("Selbsttest-Händler")
wiz().v_fn.set("111")
wiz()._haendler_dazu()
def abrechnung():
wiz().dateien.append(str(csv))
wiz().v_rundgang.set(False)
wiz()._weiter()
def import_bericht_zu():
from provtool.gui.dialogs import ImportBericht
s.dialog(ImportBericht).abbrechen()
def export():
from provtool import export as exp
app = s.app
pos = [p for p in app.positionen if p["haendler_id"] > 0]
dateien = exp.exportiere_haendler(basis / "export", "Selbsttest", pos, "Test")
if len(pos) != 2 or len(dateien) != 1 or not dateien[0].exists():
s.fehler.append(f"Export fehlerhaft: {len(pos)} Positionen, Dateien {dateien}")
from provtool.gui.assistent import TippDesTages
from provtool.gui.dialogs import ImportBericht
def tipp_pruefen():
d = s.dialog(TippDesTages)
if not d.bild_ok:
s.fehler.append("Bild für den Tipp des Tages fehlt in der .exe (provtool/gui/bilder/tipp.png)")
d.abbrechen()
fehler += s.ablauf([
(seite(0), lambda: wiz()._weiter(), "Willkommen"),
(seite(1), lambda: wiz()._weiter(), "Speicherort"),
(seite(2), haendler, "Händler anlegen"),
(lambda: len(s.app.db.haendler_liste()) == 1, lambda: wiz()._weiter(), "Händler -> weiter"),
(seite(3), abrechnung, "Abrechnung mit Datei"),
(seite(4), lambda: wiz()._weiter(), "Fertig stellen"),
(lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"),
(lambda: len(s.app.positionen) == 2, export, "Export"),
(immer, s.app.tipp_des_tages, "Tipp des Tages öffnen"),
(lambda: s.dialog(TippDesTages) is not None, tipp_pruefen, "Tipp-Bild in der .exe"),
(lambda: s.dialog(TippDesTages) is None, s.app.rundgang, "Rundgang"),
(lambda: getattr(s.app, "_rundgang", None) is not None, s.app.beenden, "Beenden während Rundgang"),
])
except Exception:
fehler.append(traceback.format_exc())
Path(ergebnisdatei).write_text("OK\n" if not fehler else "FEHLER\n\n" + "\n\n".join(fehler), encoding="utf-8")
return 0 if not fehler else 1
if __name__ == "__main__":
sys.exit(ausfuehren(sys.argv[1] if len(sys.argv) > 1 else "selbsttest.txt"))
+5
View File
@@ -1,3 +1,8 @@
[pytest]
pythonpath = .
testpaths = tests
# Kein Test darf den Build blockieren: nach 90 s wird abgebrochen und der Aufrufstapel aller
# Threads ausgegeben (zeigt, wo es hängt – auch in Windows-Systemaufrufen, wo der Tk-Watchdog
# der GUI-Tests nicht greift).
timeout = 90
timeout_method = thread
+1
View File
@@ -1,3 +1,4 @@
-r requirements.txt
pytest==8.3.3
pyinstaller==6.11.1
pytest-timeout==2.3.1
+877
View File
@@ -0,0 +1,877 @@
"""GUI-Tests: bedienen die echte Oberfläche (unter Xvfb bzw. Wine+Xvfb im Build).
Jede Ausnahme in einem Tk-Callback, jeder Absturz des Fehler-Handlers und jeder Eintrag in
fehler.log lässt den Test fehlschlagen. Modale Dialoge blockieren eine eigene Ereignisschleife –
deshalb laufen die Testschritte als Tk-Callbacks (Schrittplan) und ein Watchdog bricht hängende
Tests nach einer Zeitgrenze ab, statt den Build zu blockieren.
Ohne Bildschirm werden die Tests übersprungen – außer PROVTOOL_GUI_TESTS=1 ist gesetzt (Build).
"""
from __future__ import annotations
import json
import os
import time
import tkinter as tk
import traceback
import pytest
from provtool import config
from provtool.db import Datenbank, Haendler
PFLICHT = os.environ.get("PROVTOOL_GUI_TESTS") == "1"
IMMER = lambda: True # noqa: E731
class Lauf:
"""Startet die App in einem eigenen Profil, führt Schrittpläne aus und sammelt alle Fehler."""
def __init__(self, home, einstellungen: dict | None = None):
self.home = home
if einstellungen is not None:
(home / "einstellungen.json").write_text(json.dumps(einstellungen), encoding="utf-8")
self.root = None
for versuch in range(3):
try:
self.root = tk.Tk()
break
except tk.TclError as exc:
# Unter Wine scheitert das Anlegen vieler Tk-Instanzen in einem Prozess gelegentlich
# mit „couldn't read file init.tcl: Success“ – das betrifft nur die Tests (die App
# legt genau eine Instanz an). Daher kurz warten und erneut versuchen.
if "init.tcl" in str(exc) and versuch < 2:
time.sleep(0.5)
continue
if PFLICHT:
raise
pytest.skip(f"kein Bildschirm: {exc}")
self.fehler: list[str] = []
self.watchdog = False # True = der TEST hat wegen Zeitüberschreitung abgebrochen
from provtool.gui import app as A
self.app = A.App(self.root)
echt = self.root.report_callback_exception
def melden(exc, val, tb):
self.fehler.append("".join(traceback.format_exception(exc, val, tb)))
try:
echt(exc, val, tb) # der echte Fehler-Handler darf dabei selbst nicht abstürzen
except Exception:
self.fehler.append("Fehler-Handler ist abgestürzt:\n" + traceback.format_exc())
# eine evtl. geöffnete Fehlermeldung schließen, damit der Test nicht hängt
self.root.after(100, self._meldungen_zu)
self.root.report_callback_exception = melden
self._sichtbar_seit: float | None = None
def hauptfenster_sichtbar(self) -> bool:
"""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).
Wird als erster Schritt jedes Ablaufs geprüft – NICHT mit einer eigenen update()-Schleife,
denn ein modaler Dialog (z. B. der Assistent beim Erststart) würde die festhalten."""
try:
sichtbar = bool(self.root.winfo_viewable()) and self.app.inhalt.winfo_height() > 50
except tk.TclError:
return True
if not sichtbar:
self._sichtbar_seit = None
return False
if self._sichtbar_seit is None:
self._sichtbar_seit = time.time()
return time.time() - self._sichtbar_seit >= 0.1
# ---------------------------------------------------------------- Zustand
def lebt_sonst(self, text: str) -> str:
"""Fehlertext für „Programm ist zu“, der Watchdog-Abbrüche klar unterscheidet."""
if self.watchdog:
return "Zeitüberschreitung – der Test hat abgebrochen (nicht das Programm hat sich beendet):\n" \
+ "\n".join(self.fehler)
return text
@property
def lebt(self) -> bool:
try:
return bool(self.root.winfo_exists())
except tk.TclError:
return False
def dialoge(self) -> list[tk.Toplevel]:
from provtool.gui import win95 as w
out = []
def suche(widget):
try:
kinder = widget.winfo_children()
except tk.TclError:
return
for kind in kinder:
if isinstance(kind, w.Dialog) and kind.winfo_exists():
out.append(kind)
suche(kind)
if self.lebt:
suche(self.root)
return out
def dialog(self, typ):
return next((d for d in self.dialoge() if isinstance(d, typ)), None)
def _meldungen_zu(self):
for d in reversed(self.dialoge()):
if d.winfo_exists() and d.title() == "Allgemeine Schutzverletzung":
d.destroy()
def _notaus(self):
self.watchdog = True
for d in reversed(self.dialoge()):
try:
d.destroy()
except tk.TclError:
pass
try:
self.root.destroy()
except tk.TclError:
pass
# ---------------------------------------------------------------- Ablauf
def ablauf(self, schritte: list[tuple], sekunden: float = 20.0, nachlauf: float = 0.3):
"""schritte: [(bedingung, aktion, beschreibung), ...] – jede Aktion läuft als eigener
Tk-Callback, sobald ihre Bedingung erfüllt ist (auch während ein modaler Dialog offen ist)."""
offen = [(self.hauptfenster_sichtbar, lambda: None, "Hauptfenster ist sichtbar")] + list(schritte)
start = time.time()
zustand = {"fertig_seit": None}
def poll():
if not self.lebt:
return
if time.time() - start > sekunden:
text = offen[0][2] if offen else "(nach dem letzten Schritt hängt ein Dialog)"
self.fehler.append(f"Zeitüberschreitung – hängt bei: {text}")
self._notaus()
return
if offen:
bed, akt, _ = offen[0]
try:
bereit = bed()
except Exception:
bereit = False
if bereit:
offen.pop(0)
self.root.after(0, akt)
elif zustand["fertig_seit"] is None:
zustand["fertig_seit"] = time.time()
self.root.after(20, poll)
self.root.after(20, poll)
while self.lebt:
try:
self.root.update()
except tk.TclError:
break
f = zustand["fertig_seit"]
if f is not None and time.time() - f >= nachlauf:
break
time.sleep(0.01)
self.uebrig = [s[2] for s in offen]
def diagnose(self) -> str:
"""Für Fehlermeldungen: welche Schritte nicht liefen + alle gesammelten Fehler."""
return f"nicht ausgeführt: {self.uebrig}\n" + "\n".join(self.fehler)
def schliessen_ueber_fenster(self):
"""Wie Klick auf × / Taskleiste ▸ Fenster schließen (WM_DELETE_WINDOW)."""
cmd = self.root.tk.call("wm", "protocol", self.root._w, "WM_DELETE_WINDOW")
self.root.tk.call(cmd)
def pruefen(self):
log = self.home / "fehler.log"
inhalt = log.read_text(encoding="utf-8") if log.exists() else ""
assert not self.fehler, "Fehler in der Oberfläche:\n\n" + "\n".join(self.fehler)
assert not inhalt, "fehler.log wurde geschrieben:\n" + inhalt
def ende(self):
if self.lebt:
self.ablauf([(IMMER, self.app.beenden, "beenden")], sekunden=10)
@pytest.fixture
def home(tmp_path, monkeypatch):
monkeypatch.setenv("PROVTOOL_HOME", str(tmp_path))
return tmp_path
@pytest.fixture
def lauf_mit_daten(home):
"""App mit einem Händler und einer Abrechnung (erfundene Daten)."""
from tests.test_kern import FREENET_KOPF, freenet_zeile
csv = home / "freenet.csv"
csv.write_bytes(("\r\n".join([
FREENET_KOPF,
freenet_zeile("SIM-Karten", "111", "05.10.2026", "1601111111", "Muster GmbH", "Allnet", "8"),
freenet_zeile("Sonderbonus Aktion", "111", "06.10.2026", "1602222222", "Beispiel KG", "Classic", "170"),
freenet_zeile("SIM-Karten", "999", "07.10.2026", "1603333333", "Eigener Kunde", "Flex", "8"),
]) + "\r\n").encode("cp1252"))
db = Datenbank(config.db_path())
db.haendler_speichern(Haendler(0, "Händler A"), [("freenet", "111")])
from provtool.importer import lies_datei
db.importieren(db.abrechnung_neu("Oktober 2026"), lies_datei(csv))
db.close()
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False})
yield lauf
lauf.ende()
def _assistent_offen(lauf):
from provtool.gui.assistent import Einrichtungsassistent
return lambda: lauf.dialog(Einrichtungsassistent) is not None
# =========================================================================== Schließen bei offenem Dialog
@pytest.mark.parametrize("weg", ["fenster", "beenden"])
def test_schliessen_waehrend_assistent(home, weg):
"""Reproduziert den gemeldeten Absturz: Programm schließen, während der Assistent offen ist."""
lauf = Lauf(home) # frisches Profil → Assistent startet automatisch
zu = {"fenster": lauf.schliessen_ueber_fenster, "beenden": lauf.app.beenden}[weg]
lauf.ablauf([(_assistent_offen(lauf), lambda: None, "Assistent erscheint"),
(IMMER, zu, f"schließen über {weg}")])
assert not lauf.lebt, "Programm wurde nicht beendet"
lauf.pruefen()
def test_alt_f4_im_assistent_fragt_und_schliesst_nur_assistent(home):
"""Alt+F4 bei offenem Assistenten = „Abbrechen“ mit Rückfrage; das Programm bleibt offen."""
from provtool.gui.assistent import Einrichtungsassistent
lauf = Lauf(home)
rueckfrage = lambda: next((d for d in lauf.dialoge() if d.title() == "Einrichtung abbrechen"), None) # noqa: E731
def ja_klicken():
d = rueckfrage()
d.result = True
d.destroy()
lauf.ablauf([
(_assistent_offen(lauf), lambda: lauf.app.rahmen.alt_f4(lauf.dialog(Einrichtungsassistent)), "Alt+F4"),
(lambda: rueckfrage() is not None, ja_klicken, "Rückfrage mit Ja beantworten"),
(lambda: lauf.dialog(Einrichtungsassistent) is None, lambda: None, "Assistent ist zu"),
])
assert lauf.lebt, lauf.lebt_sonst("Programm darf nicht beendet werden")
assert not lauf.uebrig, lauf.diagnose()
assert lauf.app.settings.get("assistent_erledigt") is True
lauf.pruefen()
lauf.ende()
lauf.pruefen()
def test_alt_f4_im_dialog_schliesst_nur_den_dialog(lauf_mit_daten, monkeypatch):
from provtool.gui import rahmen
from provtool.gui.dialogs import InfoDialog
lauf = lauf_mit_daten
protokoll: list[str] = [] # Diagnose: wer hat Alt+F4 bzw. Beenden ausgelöst?
alt_f4, beenden = rahmen.Hauptrahmen.alt_f4, type(lauf.app).beenden
def alt_f4_spion(self, widget):
protokoll.append(f"alt_f4(widget={widget}, sperre={self.root.grab_current()}, "
f"popup={self._offenes_popup()}, fokus={self.root.focus_get()})\n"
+ "".join(traceback.format_stack(limit=8)))
return alt_f4(self, widget)
def beenden_spion(self):
protokoll.append("beenden()\n" + "".join(traceback.format_stack(limit=10)))
return beenden(self)
monkeypatch.setattr(rahmen.Hauptrahmen, "alt_f4", alt_f4_spion)
monkeypatch.setattr(type(lauf.app), "beenden", beenden_spion)
lauf.ablauf([
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Info öffnen"),
# Handler direkt mit dem Dialog als Empfänger aufrufen: Erzeugte Tastendrücke stellt Tk unter
# Windows nur dem Fenster mit Tastaturfokus zu – auf dem Build-Rechner gingen sie verloren.
# Dass Alt+F4 an diesen Handler gebunden ist, prüft test_alt_f4_ist_gebunden.
(lambda: lauf.dialog(InfoDialog) is not None,
lambda: lauf.app.rahmen.alt_f4(lauf.dialog(InfoDialog)), "Alt+F4 im Dialog"),
(lambda: lauf.dialog(InfoDialog) is None, lambda: None, "Dialog geschlossen"),
], sekunden=8)
diagnose = "\n---\n".join(protokoll) + "\nFehler:\n" + "\n".join(lauf.fehler)
assert lauf.lebt, lauf.lebt_sonst("Alt+F4 im Dialog darf nicht das ganze Programm beenden\n" + diagnose)
assert not lauf.uebrig, f"{lauf.uebrig}\n{diagnose}"
lauf.pruefen()
def _dialog_oeffner():
from provtool.gui import dialogs as dlg
return {
"haendler": lambda a: a.haendler(),
"abrechnungen": lambda a: a.abrechnung_waehlen(),
"export": lambda a: a.exportieren(),
"einstellungen": lambda a: a.einstellungen(),
"neu": lambda a: a.neu(),
"info": lambda a: dlg.InfoDialog(a.root).zeigen(),
"tipp": lambda a: a.tipp_des_tages(),
"dateien": lambda a: a.dateien(),
"hilfe": lambda a: a.hilfe(),
"assistent": lambda a: a.assistent(),
"prozent": lambda a: (a.ptree.selection_set(a.ptree.get_children()[0]), a.prozent()),
"neu_berechnen": lambda a: (a.db.betrag_setzen(a.positionen[0]["id"], 1), a._aktualisieren(),
a.neu_berechnen()),
}
@pytest.mark.parametrize("name", ["haendler", "abrechnungen", "export", "einstellungen", "neu", "info", "tipp",
"dateien", "hilfe", "assistent", "prozent", "neu_berechnen"])
def test_schliessen_waehrend_dialog_offen(lauf_mit_daten, name):
lauf = lauf_mit_daten
oeffnen = _dialog_oeffner()[name]
lauf.ablauf([(IMMER, lambda: oeffnen(lauf.app), f"{name} öffnen"),
(lambda: bool(lauf.dialoge()), lauf.schliessen_ueber_fenster, "Programm schließen")])
assert not lauf.lebt, f"Programm wurde nicht beendet (offen: {lauf.uebrig})"
lauf.pruefen()
def test_schliessen_waehrend_rueckfrage_im_assistent(home):
"""Assistent offen → „Abbrechen“ → Rückfrage offen → Programm schließen."""
from provtool.gui.assistent import Einrichtungsassistent
lauf = Lauf(home)
lauf.ablauf([
(_assistent_offen(lauf), lambda: lauf.dialog(Einrichtungsassistent).abbrechen(), "Abbrechen klicken"),
(lambda: len(lauf.dialoge()) >= 2, lauf.schliessen_ueber_fenster, "Programm schließen"),
])
assert not lauf.lebt
lauf.pruefen()
def test_schliessen_waehrend_rundgang(lauf_mit_daten):
lauf = lauf_mit_daten
lauf.ablauf([
(IMMER, lauf.app.rundgang, "Rundgang starten"),
(lambda: getattr(lauf.app, "_rundgang", None) and lauf.app._rundgang.blase, lauf.schliessen_ueber_fenster,
"Programm schließen"),
])
assert not lauf.lebt
lauf.pruefen()
def test_alt_f4_ist_gebunden(lauf_mit_daten):
"""Alt+F4 muss im Win95-Rahmen an den eigenen Handler gebunden sein (für alle Fenster)."""
lauf = lauf_mit_daten
if not lauf.app.rahmen.klassisch:
pytest.skip("nur im Win95-Rahmen")
skript = lauf.root.bind_all("<Alt-F4>")
assert skript, "Alt+F4 ist nicht gebunden"
lauf.pruefen()
def test_alt_f4_bevor_die_sperre_steht(lauf_mit_daten, monkeypatch):
"""Erzwingt den Zustand vom langsamen Build-Rechner: Dialog ist offen, aber Tk-Sperre und
Zeichnen sind noch nicht fertig. Alt+F4 muss trotzdem nur den Dialog schließen."""
from provtool.gui import win95 as w
from provtool.gui.dialogs import InfoDialog
monkeypatch.setattr(w.Dialog, "winfo_ismapped", lambda self: 0) # „noch nicht gezeichnet“
lauf = lauf_mit_daten
lauf.ablauf([
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Info öffnen"),
# wie unter Windows: Tk liefert den Tastendruck an das Fenster mit Fokus – hier noch das Hauptfenster
(lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.app.rahmen.alt_f4(lauf.root),
"Alt+F4 (Fokus noch beim Hauptfenster)"),
(lambda: lauf.dialog(InfoDialog) is None, lambda: None, "Dialog geschlossen"),
], sekunden=8)
assert lauf.lebt, lauf.lebt_sonst("Alt+F4 darf bei offenem Dialog nicht das ganze Programm beenden")
assert not lauf.uebrig, lauf.diagnose()
lauf.pruefen()
def test_alt_f4_im_rundgang_beendet_nur_den_rundgang(lauf_mit_daten):
lauf = lauf_mit_daten
rg = lambda: getattr(lauf.app, "_rundgang", None) # noqa: E731
lauf.ablauf([
(IMMER, lauf.app.rundgang, "Rundgang starten"),
(lambda: rg() is not None and rg().blase is not None,
lambda: lauf.app.rahmen.alt_f4(rg().blase), "Alt+F4"),
(lambda: rg().blase is None and not rg().rahmen, lambda: None, "Rundgang samt Rahmen weg"),
], sekunden=8)
assert lauf.lebt, lauf.lebt_sonst("Alt+F4 im Rundgang darf nicht das Programm beenden")
assert not lauf.uebrig, lauf.diagnose()
lauf.pruefen()
def test_betrag_dialog_wenn_zelle_unsichtbar(lauf_mit_daten):
"""Ist die Betragszelle nicht sichtbar, öffnet sich ein Dialog statt still nichts zu tun."""
lauf = lauf_mit_daten
app = lauf.app
hid = next(h.id for h in app.db.haendler_liste())
eingabe = lambda: next((d for d in lauf.dialoge() if d.title() == "Betrag ändern"), None) # noqa: E731
def tippen():
d = eingabe()
entry = next(k for k in d.winfo_children() if isinstance(k, tk.Entry))
entry.delete(0, "end")
entry.insert(0, "7,77")
d.result = entry.get()
d.destroy()
def oeffnen():
pos = app.ptree.get_children(app.ptree.get_children()[0])[0]
app.ptree.bbox = lambda *a, **k: "" # Zelle „unsichtbar“
app.betrag_bearbeiten(pos)
lauf.ablauf([
(IMMER, lambda: app.htree.selection_set(str(hid)), "Händler wählen"),
(lambda: bool(app.ptree.get_children()), oeffnen, "Betrag ändern bei unsichtbarer Zelle"),
(lambda: eingabe() is not None, tippen, "Betrag im Dialog eingeben"),
])
assert not lauf.uebrig, lauf.diagnose()
assert any(p["betrag"] == 777 and p["manuell"] for p in app.positionen)
del app.ptree.bbox
lauf.pruefen()
def test_fehler_handler_nach_zerstoertem_fenster(home):
"""Der Fehler-Handler darf nicht abstürzen, wenn das Fenster schon weg ist – nur protokollieren."""
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False})
app = lauf.app
lauf.root.destroy()
try:
raise RuntimeError("Testfehler")
except RuntimeError as exc:
app._fehler_handler(type(exc), exc, exc.__traceback__) # darf nicht werfen
assert "RuntimeError: Testfehler" in (home / "fehler.log").read_text(encoding="utf-8")
def test_zweimal_beenden(lauf_mit_daten):
lauf = lauf_mit_daten
lauf.ablauf([(IMMER, lambda: (lauf.app.beenden(), lauf.app.beenden()), "zweimal beenden")])
assert not lauf.lebt
lauf.pruefen()
# =========================================================================== Rauchtests der Abläufe
def test_assistent_komplett_durchklicken(home):
from provtool.gui.assistent import Einrichtungsassistent
lauf = Lauf(home)
wiz = lambda: lauf.dialog(Einrichtungsassistent) # noqa: E731
def haendler_eintragen():
wiz().v_name.set("Testhändler")
wiz().v_fn.set("111 (3 Pos., 10,00 €)")
wiz().v_caps.set(True)
assert wiz()._haendler_dazu()
seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731
lauf.ablauf([
(seite(0), lambda: wiz()._weiter(), "Willkommen → Speicherort"),
(seite(1), lambda: wiz()._weiter(), "Speicherort → Händler"),
(seite(2), haendler_eintragen, "Händler eintragen"),
(lambda: len(lauf.app.db.haendler_liste()) == 1, lambda: wiz()._zurueck(), "zurück"),
(seite(1), lambda: wiz()._weiter(), "wieder vor"),
(seite(2), lambda: wiz()._weiter(), "Händler → Abrechnung"),
(seite(3), lambda: wiz()._weiter(), "Abrechnung → Fertig"),
(seite(4), lambda: wiz()._weiter(), "Fertig stellen"),
(lambda: getattr(lauf.app, "_rundgang", None) is not None and lauf.app._rundgang.blase is not None,
lambda: [lauf.app._rundgang._weiter() for _ in range(len(lauf.app._rundgang.schritte))],
"Rundgang komplett durchklicken"),
])
assert not lauf.uebrig, f"nicht ausgeführt: {lauf.uebrig}"
db = lauf.app.db
h = db.haendler_liste()
assert [x.name for x in h] == ["Testhändler"]
assert ("vodafone_caps", "*") in db.kennungen(h[0].id)
assert [a["titel"] for a in db.abrechnungen()]
assert lauf.app._rundgang.blase is None
lauf.pruefen()
lauf.ende()
lauf.pruefen()
def test_hauptfenster_ablaeufe(lauf_mit_daten):
from provtool.gui.dialogs import ProzentDialog
lauf = lauf_mit_daten
app = lauf.app
hid = next(h.id for h in app.db.haendler_liste())
def betrag_aendern():
# Feld öffnen und sofort tippen: ohne Fenstermanager (Xvfb/Wine) verliert das Feld sonst
# gleich den Fokus und speichert unverändert – genau wie beim Wegklicken.
pos = app.ptree.get_children(app.ptree.get_children()[0])[0]
app.betrag_bearbeiten(pos)
lauf.root.update_idletasks()
e = app._edit
e.focus_force()
e.delete(0, "end")
e.insert(0, "5,55")
e.event_generate("<Return>")
if app._edit is e:
# Unter Windows gehen erzeugte Tastendrücke an das Fenster mit Fokus – ohne Fenster-
# manager hat das Feld ihn evtl. nicht. Dann wie „wegklicken“ speichern (FocusOut).
e.event_generate("<FocusOut>")
def prozent_ok():
d = lauf.dialog(ProzentDialog)
d.v.set("80")
d._ok()
def fenster():
if app.rahmen.klassisch:
app.rahmen.maximieren()
app.rahmen.wiederherstellen()
lauf.ablauf([
(IMMER, lambda: app.htree.selection_set(str(hid)), "Händler wählen"),
(lambda: bool(app.ptree.get_children())
and bool(app.ptree.bbox(app.ptree.get_children(app.ptree.get_children()[0])[0], "ausz")),
betrag_aendern, "Betrag inline ändern (sobald die Zeile sichtbar ist)"),
(lambda: any(p["betrag"] == 555 and p["manuell"] for p in app.positionen),
lambda: (app.ptree.selection_set(app.ptree.get_children()[-1]), app.prozent()), "Prozent-Dialog"),
(lambda: lauf.dialog(ProzentDialog) is not None, prozent_ok, "80 % setzen"),
(lambda: lauf.dialog(ProzentDialog) is None, lambda: app.v_suche.set("Beispiel"), "suchen"),
(IMMER, lambda: app.v_suche.set(""), "Suche leeren"),
(IMMER, fenster, "maximieren/wiederherstellen"),
])
assert not lauf.uebrig, f"nicht ausgeführt: {lauf.uebrig}"
assert any(p["prozent"] == 80 for p in app.positionen)
lauf.pruefen()
# =========================================================================== Windows: Popups bleiben erreichbar
import sys # noqa: E402
nur_windows = pytest.mark.skipif(not sys.platform.startswith("win"),
reason="Win32-Fensterbesitz gibt es nur unter Windows (im Build: Wine)")
@nur_windows
def test_windows_popup_kommt_nach_alt_tab_wieder_nach_vorn(lauf_mit_daten):
"""Gemeldet: Popup offen, woanders hingeklickt – danach kam das Popup nicht mehr nach vorn
(auch nicht per Alt+Tab). Geprüft wird: Popup gehört dem Hauptfenster, das Hauptfenster ist
gesperrt, und holt man das Hauptfenster zurück, liegt das Popup darüber."""
from provtool.gui import win32
from provtool.gui.dialogs import InfoDialog
lauf = lauf_mit_daten
m = {}
def messen():
d = lauf.dialog(InfoDialog)
fremd = tk.Toplevel(lauf.root) # steht für ein anderes Programm
fremd.geometry("400x300+50+50")
fremd.update()
m.update(besitzer=win32.besitzer_von(d), haupt=win32.hwnd(lauf.root),
haupt_bedienbar=win32.ist_bedienbar(lauf.root), taskleiste=win32.hat_taskleisteneintrag(d))
win32.nach_vorn(fremd) # „woanders hingeklickt“
lauf.root.update()
m["woanders"] = win32.stapel(d, lauf.root, fremd)
win32.nach_vorn(lauf.root) # Alt+Tab / Taskleiste zurück zu uns
lauf.root.update()
m["zurueck"] = win32.stapel(d, lauf.root, fremd)
fremd.destroy()
d.abbrechen()
lauf.ablauf([
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Info öffnen"),
(lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.root.after(150, messen), "messen"),
(lambda: "zurueck" in m and lauf.dialog(InfoDialog) is None, lambda: None, "geschlossen"),
], sekunden=10)
assert not lauf.uebrig, lauf.diagnose()
assert m["besitzer"] == m["haupt"], "Popup gehört nicht dem Hauptfenster"
assert m["haupt_bedienbar"] is True, "Hauptfenster soll verschiebbar bleiben (nur der Inhalt ist gesperrt)"
assert m["taskleiste"] is True, "Popup braucht einen eigenen Eintrag in der Taskleiste"
assert m["woanders"][0] == 2, f"Testaufbau: das fremde Fenster sollte oben liegen ({m['woanders']})"
assert m["zurueck"][:2] == [0, 1], f"Popup liegt nach dem Zurückholen nicht über dem Hauptfenster: {m['zurueck']}"
assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Schließen nicht wieder freigegeben"
lauf.pruefen()
def test_verschachtelte_rueckfrage_sperrt_der_reihe_nach(lauf_mit_daten):
"""Händler-Dialog -> Rückfrage: gesperrt sind Hauptfenster-Inhalt und Händler-Dialog (nicht dessen
Titelleiste); nach der Rückfrage ist der Händler-Dialog wieder frei, das Hauptfenster noch nicht."""
from provtool.gui import modal, win32
from provtool.gui import win95 as w
from provtool.gui.dialogs import HaendlerDialog
lauf = lauf_mit_daten
m = {}
rueckfrage = lambda: next((d for d in lauf.dialoge() if d.title() == "Test-Rückfrage"), None) # noqa: E731
gesperrt = lambda wdg: modal.TAG in wdg.bindtags() # noqa: E731
def messen_innen():
h, r = lauf.dialog(HaendlerDialog), rueckfrage()
m.update(h_inhalt=gesperrt(h.liste), h_titel=gesperrt(h.titelleiste) if h.titelleiste else False,
haupt_inhalt=gesperrt(lauf.app.ptree), haupt_titel=gesperrt(lauf.app.rahmen.titelleiste)
if lauf.app.rahmen.titelleiste else False,
r_besitzer=win32.besitzer_von(r), h=win32.hwnd(h))
r.destroy()
def messen_aussen():
h = lauf.dialog(HaendlerDialog)
m.update(h_inhalt_danach=gesperrt(h.liste), haupt_inhalt_danach=gesperrt(lauf.app.ptree))
h.abbrechen()
lauf.ablauf([
(IMMER, lauf.app.haendler, "Händler-Dialog öffnen"),
(lambda: lauf.dialog(HaendlerDialog) is not None,
lambda: w.info(lauf.dialog(HaendlerDialog), "Test-Rückfrage", "?"), "Rückfrage öffnen"),
(lambda: rueckfrage() is not None, lambda: lauf.root.after(100, messen_innen), "innen messen"),
(lambda: "h" in m and rueckfrage() is None, messen_aussen, "außen messen"),
(lambda: lauf.dialog(HaendlerDialog) is None, lambda: None, "alles zu"),
], sekunden=12)
assert not lauf.uebrig, lauf.diagnose()
assert m["h_inhalt"] and m["haupt_inhalt"], "Inhalte müssen gesperrt sein, solange die Rückfrage offen ist"
assert not m["h_titel"] and not m["haupt_titel"], "Titelleisten (verschieben) dürfen nicht gesperrt sein"
assert not m["h_inhalt_danach"] and m["haupt_inhalt_danach"]
assert not gesperrt(lauf.app.ptree), "Hauptfenster nach dem Schließen nicht wieder freigegeben"
if sys.platform.startswith("win"):
assert m["r_besitzer"] == m["h"], "Rückfrage gehört nicht dem Händler-Dialog"
lauf.pruefen()
@nur_windows
def test_windows_rundgang_sperrt_und_gibt_frei(lauf_mit_daten):
from provtool.gui import win32
lauf = lauf_mit_daten
rg = lambda: getattr(lauf.app, "_rundgang", None) # noqa: E731
m = {}
def messen_und_weiter():
m.setdefault("gesperrt", []).append(not win32.ist_bedienbar(lauf.root))
m.setdefault("besitzer", []).append(win32.besitzer_von(rg().blase) == win32.hwnd(lauf.root))
rg()._weiter()
schritte = [(IMMER, lauf.app.rundgang, "Rundgang starten")]
for i in range(3):
schritte.append((lambda i=i: rg() is not None and rg().blase is not None and rg().nr == i,
messen_und_weiter, f"Schritt {i + 1}"))
schritte.append((IMMER, lambda: rg().beenden(), "Rundgang beenden"))
lauf.ablauf(schritte, sekunden=12)
assert not lauf.uebrig, lauf.diagnose()
assert all(m["gesperrt"]), "Hauptfenster während des Rundgangs nicht gesperrt"
assert all(m["besitzer"]), "Sprechblase gehört nicht dem Hauptfenster"
assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Rundgang nicht freigegeben"
lauf.pruefen()
# =========================================================================== Rückmeldungen vom 2.11. (Teil 2)
def test_gedrueckte_knoepfe_behalten_ihre_schrift(lauf_mit_daten):
"""Gemeldet: Im gedrückten Zustand war die Beschriftung weg (activeforeground = Grau)."""
from provtool.gui import win95 as w
lauf = lauf_mit_daten
for b in (w.knopf(lauf.root, "Test"), tk.Button(lauf.root, text="roh")):
assert b.cget("activeforeground") != b.cget("activebackground"), "Schrift gedrückter Knöpfe unsichtbar"
b.destroy()
lauf.pruefen()
def test_tipp_text_bleibt_im_sichtbaren_bereich(lauf_mit_daten):
from provtool.gui.assistent import TIPPS, TippDesTages
lauf = lauf_mit_daten
m = {}
def messen():
d = lauf.dialog(TippDesTages)
rahmen = d.text.master
for t in TIPPS: # jeden Tipp einmal anzeigen
d.text.configure(text=t)
d.update_idletasks()
m.setdefault("breiten", []).append((d.text.winfo_reqwidth(), rahmen.winfo_width()))
m.setdefault("hoehen", []).append((d.text.winfo_reqheight(), rahmen.winfo_height()))
d.abbrechen()
lauf.ablauf([
(IMMER, lauf.app.tipp_des_tages, "Tipp öffnen"),
(lambda: lauf.dialog(TippDesTages) is not None, lambda: lauf.root.after(100, messen), "messen"),
(lambda: "breiten" in m and lauf.dialog(TippDesTages) is None, lambda: None, "zu"),
])
assert not lauf.uebrig, lauf.diagnose()
for text_b, rahmen_b in m["breiten"]:
assert text_b <= rahmen_b, f"Tipp-Text ({text_b}px) breiter als sein Bereich ({rahmen_b}px)"
for text_h, rahmen_h in m["hoehen"]:
assert text_h <= rahmen_h, f"Tipp-Text ({text_h}px) höher als sein Bereich ({rahmen_h}px)"
lauf.pruefen()
def test_strg_ruecktaste_loescht_ganzes_wort(lauf_mit_daten):
from provtool.gui import win95 as w
lauf = lauf_mit_daten
e = tk.Entry(lauf.root)
e.insert(0, "Mobilfunk Schmid GmbH")
ev = type("E", (), {"widget": e})()
e.icursor("end")
w._wort_weg(ev, -1)
assert e.get() == "Mobilfunk Schmid "
w._wort_weg(ev, -1)
assert e.get() == "Mobilfunk "
e.icursor(0)
w._wort_weg(ev, 1)
assert e.get() == ""
# Bindung ist für alle Eingabefelder aktiv
for klasse in ("Entry", "TEntry", "TCombobox"):
assert lauf.root.bind_class(klasse, "<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)")}
assert "haendler_fest" in spalten
d.close()
def test_eigene_kennungen_ausblenden(db, freenet_csv):
from provtool.db import EIGEN
hid = db.haendler_speichern(Haendler(0, "Händler A"), [("freenet", "111")])
aid = db.abrechnung_neu("X")
db.importieren(aid, lies_datei(freenet_csv))
assert {p["haendler_id"] for p in db.positionen(aid)} == {hid, UNZUGEORDNET}
db.eigene_setzen("freenet", "999")
ids = {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}
assert ids["999"] == EIGEN and ids["111"] == hid
# Händler-Kennung hat Vorrang vor „eigen“
db.eigene_setzen("freenet", "111")
assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["111"] == hid
db.eigene_setzen("freenet", "999", False)
assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["999"] == UNZUGEORDNET