Compare commits

...

9 commits
v1.9.0 ... main

Author SHA1 Message Date
TheMockTv
f7c5a1618d Fassung 1.9.2
Aufraeumrunde nach der Pruefkette. Fuer den Nutzer sichtbar ist ein Punkt:
der Wechsel von einem Menuetitel zum anderen braucht nur noch EINEN Klick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 18:41:34 +02:00
TheMockTv
96671027c8 Aufraeumen nach der Pruefkette: doppelter Code an eine Stelle
Die Runde vom 07.09.2026 hat neben den zwei echten Fehlern eine Reihe
Wiederholungen und stumme Stellen gemeldet. Abgearbeitet in Etappen, jede fuer
sich pruefbar - nach jeder liefen beide Pruefketten gruen.

* Menueleiste: der Wechsel von einem Titel zum anderen brauchte ZWEI Klicks.
  Der grab_set der offenen Liste schluckt den ersten, die <Button-1>-Bindung
  des Titels feuert gar nicht. Das native tk.Menu, das diese Leiste ersetzt
  hat, wechselte schon beim blossen Ueberfahren - zwei Klicks waren also eine
  Verschlechterung gegenueber dem, was vorher da war. Die Klappliste wird
  ausserdem jetzt wie jede andere Position ueber theme.auf_bildschirm()
  begrenzt; am unteren Fensterrand lief sie vorher hinaus.
  Neu: pruef_menueleiste.py misst beides ueber echte Klickereignisse.

* %LOCALAPPDATA%/ravokk wurde an drei Stellen einzeln ausgerechnet. Jetzt
  fragen alle gemeinsam.standard_ordner(). Laufen die je auseinander, fuehren
  die beiden Programme zwei Nummernbuecher - und das faellt erst auf, wenn
  eine Rechnungsnummer zum zweiten Mal vergeben ist (§ 14 Abs. 4 Nr. 4 UStG).

* pruefe() stand 35-mal im Quelltext, in sechs Fassungen -> pruefhelfer.py.
  Die zwei Ausreisser sind mitgezogen: pruef_storno_verrechnet.py zaehlte in
  einer Zahl und konnte am Ende nicht sagen, WAS fehlschlug, pruef_bilder.py
  im Rechnungstool schrieb ein eigenes Format.

* dlg_darstellung() und die drei Handgriffe des Umschaltens standen in beiden
  app.py fast wortgleich -> ThemeMixin in theme.py. Beide trugen inzwischen
  denselben langen Kommentar zu demselben Fehler; das war der Beweis, dass es
  eine Sache ist. Programmspezifisch bleiben der Hinweistext im Dialog (R31)
  und die Frage, ob die Einstellungen ausdruecklich gespeichert werden muessen.

* pruef_gemeinsam_automatisch.py sagte im Text "genau ein Buch" und prueft
  ">= 1" mit einem any() - eine Pruefung, die nicht rot werden kann. Jetzt
  == 1, und der Dateiname wird mitgeprueft.
  Dazu neu: die neun Dateien, die im Kopf zusagen, sie laegen in beiden
  Programmen gleich, werden byteweise verglichen. Bei hinweis.py stimmte die
  Zusage seit dem 06.09. nicht mehr - gleicher Inhalt, CRLF gegen LF.

* .gitattributes, damit die Zeilenenden nicht von der Maschine abhaengen.
  Ohne das meldet genau diese Pruefung nach einem frischen Checkout einen
  Unterschied, den es im Repository gar nicht gibt.

* Die stummen "except OSError" in api.py, updater.py und einzelinstanz.py
  sagen jetzt, warum sie schweigen duerfen. Die stummen tk.TclError bleiben:
  dort wird ein Widget angefasst, das gerade zerstoert wurde, und eine Meldung
  waere Rauschen.

* Tote Parameter und Variablen aus dem Umbau des Vortags: symbol_setzen(dunkel=),
  kachel(grund=, radius=), mit_trennlinie, sechs x/y-Berechnungen, die von
  theme.mittig() sofort ueberschrieben wurden, vier lokale "import ctypes".

39 Pruefstaende, 0 rot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 18:39:03 +02:00
TheMockTv
039a23478f Zwei echte Fehler aus der Pruefkette behoben
## 1. Der Temp-Schutz war geschrieben, aber nicht angeschlossen

_ist_temp() stand seit heute Vormittag in ablage.py, ausfuehrlich begruendet
("Ein Merkzettel, der dorthin zeigt, wird deshalb verworfen") - und wurde von
NIEMANDEM gerufen. Der Schutz war ein Kommentar, keine Massnahme.

Zwei Pruefagenten haben das unabhaengig voneinander gefunden, einer mit
Messung: das Journal landete weiterhin im Temp-Ordner. Windows raeumt den von
selbst auf; es geht um aufbewahrungspflichtige Aufzeichnungen (§ 147 AO).

⚠️ Das ist die schlimmste Sorte Fehler - ein Kommentar, der eine
Schutzmassnahme zusichert, die es nicht gibt. Wer die Datei liest, haelt die
Falle fuer geschlossen. Und ich hatte sie dem Auftraggeber als behoben
gemeldet.

Behoben: _gemerkter_ordner() prueft jetzt wirklich.
pruef_temp_merkzettel.py sichert es ab - er prueft nicht, ob die FUNKTION
existiert, sondern ob sie WIRKT. Dazu die Gegenprobe, dass ein GUELTIGER
Merkzettel weiter befolgt wird: sonst haette der Schutz jemandem sein
Netzlaufwerk weggenommen.

⚠️ Im ersten Anlauf war dieser Pruefstand rot - weil ich seine Spielwiese mit
tempfile.mkdtemp() angelegt hatte und die selbst im Temp liegt. Wer das
Verhalten IM Temp-Ordner prueft, darf seine Spielwiese nicht dort aufbauen.

## 2. Der Darstellungsdialog zerstoerte sich beim ersten Klick selbst

Gleiche Ursache und gleiche Behebung wie im Rechnungstool: _oberflaeche_neu()
loeschte alle Kinder des Hauptfensters, und ein tk.Toplevel ist eines.
pruef_darstellung drueckt jetzt den Radioknopf statt cfg von Hand zu setzen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 18:10:41 +02:00
TheMockTv
dcc556217b pruef_installation hinterlaesst den Zustand, den er vorfindet
War das Programm vorher installiert, ist es danach wieder installiert. Beim
ersten Lauf war es das NICHT - danach stand ein deinstalliertes Programm da,
und der naechste Schritt (den Updater starten lassen) lief ins Leere.

Ein Pruefstand, der aufraeumt, muss auch sich selbst aufraeumen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 10:57:35 +02:00
TheMockTv
8cdd106a08 Windows-Symbol in den Programmfarben
Sein Befund: "hast du bei dem windows icon das gleich farbige wie im
programm, wo als wasserzeichen gilt, gemacht - weil das schaut blau aus".

Er hatte recht: auf der Kachel stand das ROHE Logo mit seinem Petrol
#004860, waehrend im Programm (Kopfzeile, Wasserzeichen) laengst die
umgefaerbte Fassung laeuft. Zwei Farben fuer dasselbe Zeichen.

Jetzt geht das Logo auch fuers Symbol durch _umfaerben() - dieselbe
Funktion, dieselben Farben: Tuerkis fuer die Wolke, Dunkelgrau fuer das
Zeichen daneben. Die Kachel ist weiss, also gilt die HELLE Fassung.

Fassung 1.9.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 10:52:02 +02:00
TheMockTv
1562388e78 Alles unter ravokk - und ein Deinstallieren laesst nichts liegen
Gleiche Umstellung wie im Rechnungstool, gleiche gemeinsame Dateien
(gemeinsam.py, ordnerwahl.py, wasserzeichen.py, bauen.py).

  %LOCALAPPDATA%/ravokk/
      journal.sqlite3, berichte/, Nummernbuch, config.json   <- Daten
      Rechnungstool/                                          <- Programm
      Steuerrechnungstool/                                    <- Programm

⚠ Hier gefunden und behoben: das Journal der INSTALLIERTEN Fassung lag im
  Temp-Ordner. Der Merkzettel (ablage.py) zeigte auf
  %TEMP%/abnahme_bst/Daten - haengengeblieben aus einem Abnahmelauf vom
  03.09.2026. Windows raeumt den Temp-Ordner von selbst auf; das Journal
  waere ohne Vorwarnung verschwunden. Ein Merkzettel, der dorthin zeigt,
  wird jetzt verworfen statt befolgt.

  Sein Echtbestand (238 Buchungen) lag davon unberuehrt in der Quelle und
  ist gesichert.

Kein Ordner-Dialog mehr beim ersten Start - der gemeinsame Ordner steht
fest. Der Knopf hiess dort "Install-Ordner auswaehlen...", was schon immer
falsch war: es geht um das gemeinsame Nummernbuch, nicht um den
Installationsort.

Uninstaller raeumt jetzt auch die vom Updater geladenen Fassungen weg.
pruef_installation.py prueft beides zusammen: Programmordner leer, Daten
unangetastet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 10:43:24 +02:00
TheMockTv
319c680a62 Logo in den Programmfarben, einen Tick groesser
Gleiche Umstellung wie im Rechnungstool (gemeinsame wasserzeichen.py): das
Blaugruen wird Tuerkis (#06C6A4), die zweite Haelfte weiss im Dunkeln und
dunkelgrau im Hellen, unterschieden nach Saettigung.

"beim steuertool koennte es ein tick groesser sein" - 28 -> 34 Pixel, wie im
Rechnungstool. Die Stelle bleibt rechts in der Kopfleiste: links stehen die
Bedienelemente (Jahr, Einlesen, Bericht), ein Logo davor wuerde sie alle nach
rechts schieben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 01:02:04 +02:00
TheMockTv
755c844ee4 Popups gehen mittig ueber dem Programmfenster auf
Gleiche Aenderung wie im Rechnungstool, gleiche theme.py: theme.mittig()
setzt jeden Dialog mittig ueber sein Elternfenster, theme.auf_bildschirm()
begrenzt auf den gesamten Desktop ueber alle Monitore statt auf den
Hauptmonitor.

Sein Befund: "da gehen noch die popups auf dem video monitor auf". Ursache
war ein `max(0, x)`, das jede negative Koordinate auf null klemmte - bei ihm
steht der zweite Monitor links (x = -1920).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 00:53:00 +02:00
TheMockTv
f57c7f52f4 Pruefstaende auf den zweiten Monitor, Desktop-Verknuepfung mit Symbol
Seine Ansage: "teste links auf dem 1080p monitor nicht mehr auf dem main ...
weil da schau ich nun video" - und danach "das nun der richtige das fuer
beide".

Dieses Programm hatte ueberhaupt keine Sonderposition fuer den Testlauf:
seine Pruefstandsfenster gingen mitten auf den Hauptbildschirm auf, waehrend
jemand daran arbeitete. Jetzt auf dem zweiten Monitor und ohne Eintrag in der
Taskleiste, wie im Rechnungstool.

Der Platz kommt aus theme.pruefplatz() - dieselbe Datei in beiden Programmen.

Dazu im Installer: der Haken "Verknuepfung auf dem Desktop" ist vorgesetzt
statt abgewaehlt, und jede Verknuepfung traegt IconFilename ausdruecklich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 00:41:36 +02:00
31 changed files with 1547 additions and 357 deletions

23
.gitattributes vendored Normal file
View file

@ -0,0 +1,23 @@
# Zeilenenden festlegen, damit sie nicht von der Maschine abhaengen.
#
# Neun Dateien liegen in beiden Programmen gleich und werden von Hand
# hinueberkopiert; pruef_gemeinsam_automatisch.py prueft das byteweise.
# Ohne diese Festlegung entscheidet core.autocrlf jeder Maschine einzeln,
# wie eine Datei im Arbeitsverzeichnis aussieht - und dann meldet die
# Pruefung einen Unterschied, den es im Repository gar nicht gibt.
# Genau so stand es am 07.09.2026 bei hinweis.py: gleicher Inhalt, die
# eine Fassung CRLF, die andere LF.
* text=auto
*.py text eol=crlf
*.iss text eol=crlf
*.md text eol=crlf
*.json text eol=crlf
# Binaeres nie anfassen.
*.png binary
*.ico binary
*.svg text eol=crlf
*.ttf binary
*.exe binary
*.pdf binary
*.sqlite3 binary

View file

@ -1,93 +1,125 @@
; Installer fuer das Steuerrechnungstool (Inno Setup 6)
;
; ENTSCHEIDUNGEN, DIE HIER STECKEN - und warum
; --------------------------------------------
; 1. INSTALLATION PRO BENUTZER nach %LOCALAPPDATA%\Programs, NICHT nach
; "Program Files". Grund: der Updater muss die EXE ablegen duerfen. In
; Program Files darf ein normaler Benutzer nicht schreiben - jedes Update
; wuerde an fehlenden Rechten scheitern oder eine Administrator-Abfrage
; erzwingen. Chrome, VS Code, Discord, Teams und Fusion 360 machen es
; genauso, aus demselben Grund.
;
; 2. DIE VERKNUEPFUNG ZEIGT AUF DEN UPDATER, nicht auf das Programm. So schaut
; jeder Start zuerst nach einer neuen Fassung. Der Updater startet danach
; die eigentliche EXE - die ist dabei noch gar nicht in Benutzung und laesst
; sich deshalb problemlos ersetzen.
;
; 3. KEINE DATEN ANFASSEN. Der Installer legt keine Rechnungsordner an, kein
; Journal, kein Nummernbuch - und beim Deinstallieren wird davon nichts
; entfernt. Das Programm schreibt aufbewahrungspflichtige Belege
; (§ 147 AO, zehn Jahre). Ein Deinstallieren darf sie nie mitnehmen.
;
; 4. Der Eintrag unter "Apps & Features" entsteht von selbst - Inno Setup legt
; den Registry-Schluessel unter HKCU an. Da muss niemand von Hand hinein.
#define Name "Steuerrechnungstool"
#define Herausgeber "ravokk"
#define ExeUpdater "Updater.exe"
#define Webseite "https://git.pcore.de/TheMockTv/Steuerrechnungstool"
[Setup]
AppId={{B4D71E08-9C36-42AF-8E5B-13A7F0C29D64}
AppName={#Name}
AppVersion={#Version}
AppVerName={#Name} {#Version}
AppPublisher={#Herausgeber}
AppPublisherURL={#Webseite}
AppSupportURL={#Webseite}/issues
AppUpdatesURL={#Webseite}/releases
; --- pro Benutzer, ohne Adminrechte ---
PrivilegesRequired=lowest
; Der Zielordner ist NICHT waehlbar. Wer hier "Programme" auswaehlen
; koennte, braeche sich den Updater: dort darf ein normaler Benutzer
; nicht schreiben. Chrome, Teams und Discord blenden diese Seite aus
; demselben Grund aus. In "Programme" gehoert hoechstens eine
; Verknuepfung - und die liegt ohnehin im Startmenue.
DisableDirPage=yes
DefaultDirName={localappdata}\Programs\{#Name}
DefaultGroupName={#Name}
DisableProgramGroupPage=yes
UsePreviousAppDir=yes
OutputDir=setup
OutputBaseFilename=Steuerrechnungstool-Setup-{#Version}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
SetupIconFile=assets\programm.ico
UninstallDisplayIcon={app}\{#ExeProgramm}
ArchitecturesInstallIn64BitMode=x64compatible
; Erprobungsfassung - das steht auch im Programm selbst.
AppComments=Erprobungsfassung. Benutzung auf eigene Gefahr.
[Languages]
Name: "deutsch"; MessagesFile: "compiler:Languages\German.isl"
[Tasks]
Name: "desktopicon"; Description: "Verknüpfung auf dem Desktop anlegen"; \
GroupDescription: "Zusätzliche Verknüpfungen:"; Flags: unchecked
[Files]
; Der Updater - das ist, was der Benutzer startet.
Source: "dist\{#ExeUpdater}"; DestDir: "{app}"; Flags: ignoreversion
; Das Programm selbst, mit der Nummer im Namen. Der Updater erkennt daran,
; was installiert ist, und legt neue Fassungen daneben statt darueber.
Source: "dist\{#ExeProgramm}"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
; Beide Verknuepfungen zeigen auf den UPDATER, nicht auf das Programm.
Name: "{group}\{#Name}"; Filename: "{app}\{#ExeUpdater}"
Name: "{group}\{#Name} deinstallieren"; Filename: "{uninstallexe}"
Name: "{userdesktop}\{#Name}"; Filename: "{app}\{#ExeUpdater}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#ExeUpdater}"; Description: "{#Name} jetzt starten"; \
Flags: nowait postinstall skipifsilent
[UninstallDelete]
; Nur die Buchfuehrung des Updaters.
; NICHTS von den Daten des Betriebs - keine Rechnungen, kein Journal, kein
; Nummernbuch. Die liegen ohnehin woanders und muessen zehn Jahre bleiben.
Type: files; Name: "{app}\installiert.json"
; Installer fuer das Steuerrechnungstool (Inno Setup 6)
;
; ENTSCHEIDUNGEN, DIE HIER STECKEN - und warum
; --------------------------------------------
; 1. INSTALLATION PRO BENUTZER nach %LOCALAPPDATA%\Programs, NICHT nach
; "Program Files". Grund: der Updater muss die EXE ablegen duerfen. In
; Program Files darf ein normaler Benutzer nicht schreiben - jedes Update
; wuerde an fehlenden Rechten scheitern oder eine Administrator-Abfrage
; erzwingen. Chrome, VS Code, Discord, Teams und Fusion 360 machen es
; genauso, aus demselben Grund.
;
; 2. DIE VERKNUEPFUNG ZEIGT AUF DEN UPDATER, nicht auf das Programm. So schaut
; jeder Start zuerst nach einer neuen Fassung. Der Updater startet danach
; die eigentliche EXE - die ist dabei noch gar nicht in Benutzung und laesst
; sich deshalb problemlos ersetzen.
;
; 3. KEINE DATEN ANFASSEN. Der Installer legt keine Rechnungsordner an, kein
; Journal, kein Nummernbuch - und beim Deinstallieren wird davon nichts
; entfernt. Das Programm schreibt aufbewahrungspflichtige Belege
; (§ 147 AO, zehn Jahre). Ein Deinstallieren darf sie nie mitnehmen.
;
; 4. Der Eintrag unter "Apps & Features" entsteht von selbst - Inno Setup legt
; den Registry-Schluessel unter HKCU an. Da muss niemand von Hand hinein.
#define Name "Steuerrechnungstool"
#define Herausgeber "ravokk"
#define ExeUpdater "Updater.exe"
#define Webseite "https://git.pcore.de/TheMockTv/Steuerrechnungstool"
[Setup]
AppId={{B4D71E08-9C36-42AF-8E5B-13A7F0C29D64}
AppName={#Name}
AppVersion={#Version}
AppVerName={#Name} {#Version}
AppPublisher={#Herausgeber}
AppPublisherURL={#Webseite}
AppSupportURL={#Webseite}/issues
AppUpdatesURL={#Webseite}/releases
; --- pro Benutzer, ohne Adminrechte ---
PrivilegesRequired=lowest
; Der Zielordner ist NICHT waehlbar. Wer hier "Programme" auswaehlen
; koennte, braeche sich den Updater: dort darf ein normaler Benutzer
; nicht schreiben. Chrome, Teams und Discord blenden diese Seite aus
; demselben Grund aus. In "Programme" gehoert hoechstens eine
; Verknuepfung - und die liegt ohnehin im Startmenue.
DisableDirPage=yes
; EIN Verzeichnis fuer beides - seine Ansage am 07.09.2026:
; "es gibt ravokk als main verzeichnis, da liegt config und sqlite
; drin, und dann gibt es den ordner rechnungstool und steuertool"
;
; %LOCALAPPDATA%\ravokk\
; campinghof-nummernbuch.sqlite3 <- gemeinsam, beide schauen hierher
; Rechnungstool\ <- Programm + Updater
; Steuerrechnungstool\ <- Programm + Updater
;
; Jedes Programm in seinem eigenen Unterordner: die beiden Updater heissen
; gleich und wuerden sich sonst gegenseitig ueberschreiben.
;
; Das Nummernbuch liegt eine Ebene DARUEBER und gehoert keinem der beiden.
; Ein Deinstallieren nimmt es deshalb nicht mit - und wer nur eines der
; Programme installiert, legt es trotzdem am richtigen Ort an
; (gemeinsam.standard_ordner).
DefaultDirName={localappdata}\ravokk\{#Name}
DefaultGroupName={#Name}
DisableProgramGroupPage=yes
UsePreviousAppDir=yes
OutputDir=setup
OutputBaseFilename=Steuerrechnungstool-Setup-{#Version}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
SetupIconFile=assets\programm.ico
UninstallDisplayIcon={app}\{#ExeProgramm}
ArchitecturesInstallIn64BitMode=x64compatible
; Erprobungsfassung - das steht auch im Programm selbst.
AppComments=Erprobungsfassung. Benutzung auf eigene Gefahr.
[Languages]
Name: "deutsch"; MessagesFile: "compiler:Languages\German.isl"
[Tasks]
; Kein "unchecked": der Haken ist vorgesetzt. Er hatte nach dem
; Installieren kein Symbol auf dem Desktop und musste das Programm
; suchen (07.09.2026). Wer es nicht will, nimmt den Haken heraus.
Name: "desktopicon"; Description: "Verknüpfung auf dem Desktop anlegen"; \
GroupDescription: "Zusätzliche Verknüpfungen:"
[Files]
; Der Updater - das ist, was der Benutzer startet.
Source: "dist\{#ExeUpdater}"; DestDir: "{app}"; Flags: ignoreversion
; Das Programm selbst, mit der Nummer im Namen. Der Updater erkennt daran,
; was installiert ist, und legt neue Fassungen daneben statt darueber.
Source: "dist\{#ExeProgramm}"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
; Beide Verknuepfungen zeigen auf den UPDATER, nicht auf das Programm.
; IconFilename ausdruecklich: ohne das erbt die Verknuepfung das Symbol vom
; UPDATER, und Windows zeigte an ihrer Stelle das nackte Standardsymbol.
Name: "{group}\{#Name}"; Filename: "{app}\{#ExeUpdater}"; IconFilename: "{app}\{#ExeProgramm}"
Name: "{group}\{#Name} deinstallieren"; Filename: "{uninstallexe}"
Name: "{userdesktop}\{#Name}"; Filename: "{app}\{#ExeUpdater}"; IconFilename: "{app}\{#ExeProgramm}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#ExeUpdater}"; Description: "{#Name} jetzt starten"; \
Flags: nowait postinstall skipifsilent
[UninstallDelete]
; Was der INSTALLER gelegt hat, raeumt Inno Setup von selbst weg. Hier
; steht, was der UPDATER angelegt hat - davon weiss der Installer nichts:
; <Name>-<Nummer>.exe jede geladene Fassung
; installiert.json die Buchfuehrung des Updaters
; Ohne diese Zeilen bleibt der Ordner voller alter EXEs stehen.
;
; NICHT geloescht werden Rechnungen, Journal und Nummernbuch. Die liegen
; ohnehin woanders (%LOCALAPPDATA%/ravokk) und muessen zehn Jahre bleiben
; (§ 147 AO). Deshalb steht hier auch kein 'Type: filesandordirs' auf
; {app} - das waere die bequeme, aber falsche Zeile.
Type: files; Name: "{app}\installiert.json"
Type: files; Name: "{app}\{#Name}-*.exe"
Type: files; Name: "{app}\Updater.exe"
; Zum Schluss den Ordner selbst - aber NUR, wenn er leer ist.
Type: dirifempty; Name: "{app}"

View file

@ -5,7 +5,7 @@ a = Analysis(
['app.py'],
pathex=[],
binaries=[],
datas=[('assets/programm.ico', 'assets'), ('assets/programm_dunkel.ico', 'assets'), ('assets/programm_logo.png', 'assets'),],
datas=[('assets/programm.ico', 'assets'), ('assets/programm_logo.png', 'assets'),],
hiddenimports=[],
hookspath=[],
hooksconfig={},

View file

@ -44,6 +44,48 @@ def _beschreibbar(ordner):
return False
def _ist_temp(pfad):
"""Liegt dieser Pfad im Temp-Ordner von Windows?
⚠ Am 07.09.2026 gefunden: der Merkzettel zeigte auf
%TEMP%/abnahme_bst/Daten - haengengeblieben aus einem Abnahmelauf vom
03.09. Die INSTALLIERTE Fassung fuehrte ihr Journal seitdem im
Temp-Ordner. Windows raeumt den automatisch auf (Datentraegerbereinigung,
Speicheroptimierung); das Journal waere ohne Vorwarnung verschwunden.
Ein Merkzettel, der dorthin zeigt, wird deshalb verworfen - lieber ein
frischer, richtiger Ordner als ein Journal auf Abruf.
"""
import tempfile
try:
temp = os.path.realpath(tempfile.gettempdir()).lower()
ziel = os.path.realpath(pfad).lower()
return ziel == temp or ziel.startswith(temp + os.sep)
except (OSError, ValueError):
return False
def firmen_ordner():
"""Das gemeinsame Dach: %LOCALAPPDATA%/ravokk.
Seine Ansage am 07.09.2026: *"es gibt ravokk als main verzeichnis, da
liegt config und sqlite drin, und dann gibt es den ordner rechnungstool
und steuertool"* - und dazu, warum es so heisst: *"ravokk ist meine firma
und die programme gehoeren zu der firma"*.
Darunter liegt je Programm ein eigener Ordner, und das gemeinsame
Nummernbuch eine Ebene darueber - so nimmt ein Deinstallieren es nicht mit
(siehe gemeinsam.standard_ordner).
"""
# EINE Quelle fuer diesen Pfad. Er wurde an drei Stellen einzeln
# ausgerechnet - hier, in gemeinsam.py und in der config.py des
# Rechnungstools. Laeuft eine davon je auseinander, fuehren die beiden
# Programme zwei Nummernbuecher, und das faellt erst auf, wenn eine
# Rechnungsnummer zum zweiten Mal vergeben ist.
import gemeinsam
return gemeinsam.standard_ordner()
def _zeiger_datei():
"""Merkzettel, WO der Datenordner liegt - liegt bewusst ausserhalb davon."""
heim = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
@ -66,6 +108,14 @@ def _gemerkter_ordner():
if not pfad or "\x00" in pfad or not os.path.isabs(pfad):
log.warning("Merkzettel unbrauchbar - es wird neu angelegt")
return None, None
# HIER wird _ist_temp tatsaechlich benutzt. Bis zum 07.09.2026 stand die
# Funktion samt Begruendung da und wurde von niemandem gerufen - der
# Schutz war ein Kommentar, keine Massnahme. Zwei Pruefagenten haben das
# unabhaengig gefunden, einer mit Messung: das Journal landete weiter im
# Temp-Ordner.
if _ist_temp(pfad):
log.warning("Merkzettel zeigt in den Temp-Ordner - verworfen: %s", pfad)
return None, None
try:
laufwerk = os.path.splitdrive(pfad)[0]
erreichbar = (not laufwerk) or os.path.exists(
@ -106,11 +156,22 @@ def daten_ordner():
log.warning("Datenordner %s ist nicht erreichbar - es wird nichts geändert", gemerkt)
return gemerkt if _beschreibbar(gemerkt) else EXE_DIR
kandidat = os.path.join(EXE_DIR, "Daten")
# Erste Wahl: der Ordner der Firma. Dort liegen beide Programme und das
# gemeinsame Nummernbuch - alles an einer Stelle, und ein Deinstallieren
# raeumt nur den Programmordner.
# NEBEN dem Programm, nicht darin - siehe firmen_ordner(). Journal,
# Berichte und Log liegen damit in ravokk, das Programm in
# ravokk/Steuerrechnungstool. Ein Deinstallieren leert nur den
# Programmordner.
kandidat = firmen_ordner()
if not _beschreibbar(kandidat):
kandidat = os.path.join(os.environ.get("LOCALAPPDATA", EXE_DIR), "Beherbergungssteuer")
# Rueckfall wie frueher: neben der EXE, dann der alte eigene Ordner.
kandidat = os.path.join(EXE_DIR, "Daten")
if not _beschreibbar(kandidat):
return EXE_DIR
kandidat = os.path.join(os.environ.get("LOCALAPPDATA", EXE_DIR),
"Beherbergungssteuer")
if not _beschreibbar(kandidat):
return EXE_DIR
_ordner_merken(kandidat)
for name in EIGENE_DATEN:
alt, neu = os.path.join(EXE_DIR, name), os.path.join(kandidat, name)

4
api.py
View file

@ -138,6 +138,10 @@ class _Handler(socketserver.StreamRequestHandler):
try:
self.wfile.write((json.dumps({"antwort": antwort}) + "\n").encode("utf-8"))
except OSError:
# Der Rufer hat die Verbindung schon zugemacht - das ist der
# Normalfall bei "schau_nach", wenn das andere Programm beendet
# wurde, waehrend die Antwort unterwegs war. Die Arbeit ist
# getan; nur die Empfangsbestaetigung geht ins Leere.
pass

119
app.py
View file

@ -86,6 +86,22 @@ class App(theme.ThemeMixin, tk.Tk):
self._fenstersymbol()
self.geometry("1000x680")
self.minsize(820, 520)
if TESTLAUF:
# Ein Pruefstand bedient das Programm, waehrend nebenan jemand
# arbeitet. Das Fenster geht deshalb auf dem ZWEITEN Monitor auf -
# sichtbar, aber nicht im Weg. Seine Ansage am 07.09.2026: "teste
# links auf dem 1080p monitor nicht mehr auf dem main". Bisher
# ging es hier mitten auf den Hauptbildschirm.
platz = theme.pruefplatz()
if platz:
self.geometry(platz)
try:
# Kein Eintrag in der Taskleiste: ein Eintrag, den man
# anklickt, ohne dass etwas erscheint, ist schlimmer als
# gar keiner.
self.attributes("-toolwindow", True)
except tk.TclError:
pass
self.journal = db.Journal(JOURNAL)
# Die Einstellungen des Programms liegen in der Journal-Tabelle, nicht
@ -180,28 +196,11 @@ class App(theme.ThemeMixin, tk.Tk):
niemand mehr findet. Neu bauen kann nichts vergessen.
Die Werte haengen an Tk-Variablen (Jahr, Suchfeld) und am Journal -
beides ueberlebt. Gesichert werden sie trotzdem, denn die
_build-Methoden legen ihre Variablen selbst an: nach dem Neubau
stuenden sonst neue, leere da. Genau das ist im Rechnungstool beim
ersten Versuch passiert.
beides ueberlebt. Gesichert werden sie trotzdem; warum, steht bei
variablen_sichern() im ThemeMixin.
"""
gesichert = {}
for name in dir(self):
if name.startswith("__"):
continue
try:
wert = getattr(self, name)
except Exception: # noqa: BLE001 - Eigenschaften koennen werfen
continue
if isinstance(wert, (tk.StringVar, tk.IntVar, tk.DoubleVar,
tk.BooleanVar)):
try:
gesichert[name] = wert.get()
except tk.TclError:
pass
for kind in self.winfo_children():
kind.destroy()
gesichert = self.variablen_sichern()
self.kinder_loeschen()
self._apply_theme()
self._menue()
@ -209,59 +208,19 @@ class App(theme.ThemeMixin, tk.Tk):
self._blaetter()
self._statusleiste()
for name, wert in gesichert.items():
neu = getattr(self, name, None)
if isinstance(neu, (tk.StringVar, tk.IntVar, tk.DoubleVar,
tk.BooleanVar)):
try:
neu.set(wert)
except tk.TclError:
pass
self.variablen_zuruecksetzen(gesichert)
self.aktualisieren()
def dlg_darstellung(self):
"""Hell, dunkel oder wie Windows - und sofort sichtbar."""
d = tk.Toplevel(self)
d.title("Darstellung")
d.transient(self)
d.resizable(False, False)
self._style_dialog(d)
rahmen = ttk.Frame(d, padding=18)
rahmen.pack(fill="both", expand=True)
ttk.Label(rahmen, text="Darstellung",
font=(theme.SCHRIFT_NAME, 11, "bold"),
foreground=self.col_accent).pack(anchor="w")
ttk.Label(rahmen, justify="left", wraplength=340,
foreground=self.col_muted,
text="Gilt für das Programmfenster. Das Menü bleibt in der "
"Farbe von Windows – ein natives Menü lässt sich dort "
"nicht einfärben.").pack(anchor="w", pady=(6, 12))
gewaehlt = tk.StringVar(value=theme.modus(self.cfg))
def uebernehmen():
neu = gewaehlt.get()
if neu == theme.modus(self.cfg):
return
self.cfg["darstellung"] = neu
self._oberflaeche_neu()
self._style_dialog(d)
for wert in theme.MODI:
ttk.Radiobutton(rahmen, text=theme.MODUS_NAMEN[wert], value=wert,
variable=gewaehlt, command=uebernehmen).pack(
anchor="w", pady=3)
ttk.Separator(rahmen, orient="horizontal").pack(fill="x", pady=(12, 10))
ttk.Button(rahmen, text="Schließen", width=14,
command=d.destroy).pack(anchor="e")
d.geometry(f"+{self.winfo_rootx() + 120}+{self.winfo_rooty() + 140}")
return d
# Der Dialog selbst steht im ThemeMixin (theme.py). Gespeichert werden
# muss hier nichts: self.cfg ist die Ablage aus _einstellungen(), die
# jeden Schreibzugriff sofort in die Journaltabelle durchreicht.
DARSTELLUNG_HINWEIS = (
"Gilt für das Programmfenster. Das Menü bleibt in der Farbe von "
"Windows – ein natives Menü lässt sich dort nicht einfärben.")
def _fenstersymbol(self):
"""Das Programmsymbol auf das Fenster legen."""
return wasserzeichen.symbol_setzen(self, melden=log.info,
dunkel=getattr(self, "_dark_mode", False))
return wasserzeichen.symbol_setzen(self, melden=log.info)
def _wasserzeichen_setzen(self, groesse=220):
"""Das Programmlogo blass in die untere rechte Ecke."""
@ -391,7 +350,9 @@ class App(theme.ThemeMixin, tk.Tk):
# Das Programmlogo rechts in der Kopfzeile - das Erkennungszeichen.
# Es steht rechts, damit es die Bedienelemente nicht nach hinten
# schiebt, wenn es einmal fehlt.
self._kopf_bild = wasserzeichen.kopf(28, master=self, melden=log.info,
# 34 Pixel wie im Rechnungstool - bei 28 war es ihm zu klein
# ("beim steuertool koennte es ein tick groesser sein", 07.09.2026).
self._kopf_bild = wasserzeichen.kopf(34, master=self, melden=log.info,
dunkel=getattr(self, "_dark_mode", False))
if self._kopf_bild is not None:
ttk.Label(leiste, image=self._kopf_bild).pack(side="right")
@ -673,13 +634,25 @@ class App(theme.ThemeMixin, tk.Tk):
"""
self._buch_oeffnen()
if self.buch is None and not TESTLAUF:
self.dlg_gemeinsam(erststart=True)
# Kein Dialog mehr beim ersten Start: der Ordner steht fest und ist
# in beiden Programmen derselbe (gemeinsam.standard_ordner).
ordner = gemeinsam.standard_anlegen()
if ordner:
self.journal.setze("gemeinsam_ordner", ordner)
self._buch_oeffnen()
if self.buch is None:
self.dlg_gemeinsam(erststart=True)
self._anmelden()
self._buch_fuellen()
def dlg_gemeinsam(self, erststart=False):
ordner = ordnerwahl.ordner_abfragen(self, "Steuerjournal",
self.journal.hole("gemeinsam_ordner", ""))
# Ist noch keiner gesetzt, wird der Rechnungsordner vorgeschlagen:
# den kennen beide Programme, er wird mitgesichert, und er ueberlebt
# ein Deinstallieren. Der Ordner, in dem das Programm liegt, taugt
# dafuer NICHT mehr - seit dem Installer sind das zwei getrennte.
ordner = ordnerwahl.ordner_abfragen(
self, "Steuerjournal",
self.journal.hole("gemeinsam_ordner", "") or self.pdf_ordner)
if not ordner:
if erststart:
messagebox.showwarning(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

View file

@ -82,14 +82,15 @@ def main():
for ordner in ("build", "dist", "setup", "release"):
shutil.rmtree(os.path.join(HIER, ordner), ignore_errors=True)
# Die aufgehellte Fassung des Symbols fuer die dunkle Darstellung. Sie
# wird HIER erzeugt und nicht erst beim ersten dunklen Start: sonst muesste
# das Programm in seinen eigenen Ordner schreiben duerfen - unter
# "Programme" darf es das nicht.
# Das Windows-Symbol aus dem Logo bauen: helle Kachel mit runden Ecken,
# Zeichen darauf, in allen Groessen von 16 bis 256. Es wird HIER erzeugt
# und nicht zur Laufzeit - ein installiertes Programm soll nicht in seinen
# eigenen Ordner schreiben muessen.
sys.path.insert(0, HIER)
import wasserzeichen
dunkles = wasserzeichen.symbol_datei_dunkel()
print(f"--- Symbol dunkel: {dunkles or 'FEHLT'}")
ico = wasserzeichen.symbol_bauen(
os.path.join(HIER, "assets", "programm.ico"))
print(f"--- Symbol: {ico or 'FEHLT'}")
lauf([sys.executable, "-m", "PyInstaller", "--noconfirm", f"{stamm}.spec"],
f"{stamm} {version.VERSION}")

View file

@ -61,6 +61,10 @@ def eigene_pid_merken():
with open(pid_datei(), "w", encoding="ascii") as f:
f.write(str(os.getpid()))
except OSError:
# Der Merkzettel ist eine Bequemlichkeit, keine Bedingung: ohne ihn
# findet eine zweite Instanz das laufende Fenster nicht und meldet
# nur, dass das Programm schon laeuft. Daran darf der Start nicht
# scheitern - der Ordner kann schreibgeschuetzt sein.
pass
def laufende_pid_lesen():
"""PID der laufenden Instanz aus dem Merkzettel; None wenn unlesbar."""

View file

@ -90,6 +90,49 @@ FELDER = ("jahr", "datum", "nachname", "vorname", "naechte", "zwischensumme",
"freigabe")
def standard_ordner():
"""Wo das Nummernbuch liegt, wenn niemand etwas anderes einstellt.
WARUM ES DIESEN FESTEN ORT GIBT (07.09.2026)
--------------------------------------------
Vorher fragte jedes Programm beim ersten Start nach einem Ordner - und der
Nutzer musste in BEIDEN denselben eintragen. Sein Einwand, wortwoertlich:
"wie soll das rechnungstool und steuertool wissen wo es ist, das muss
im ... ravokk liegen und dann zusammen, sonst kommt das eine nicht an
das andere ran"
Er hat recht: die beiden sind ueber genau zwei Dinge verbunden - diese
SQLite-Datei und die kleine API, die "hier ist etwas Neues" ruft. Wenn
schon der Ort geraten werden muss, ist die Verbindung nur so gut wie die
Aufmerksamkeit dessen, der zweimal denselben Pfad tippt. Ein falscher
Buchstabe faellt erst auf, wenn eine Rechnungsnummer zum zweiten Mal
vergeben wurde.
Deshalb: ein fester Ort, den beide von selbst kennen. Gefragt wird nur
noch, wer ihn ausdruecklich verschieben will (Menue -> Gemeinsamer Ordner) -
etwa auf ein Netzlaufwerk, wenn die Programme auf zwei Rechnern laufen.
LOCALAPPDATA und nicht "Dokumente": dort liegen die Programmdaten ohnehin,
und niemand raeumt den Ordner versehentlich auf. Das Buch ist ein
REGISTER, kein Beleg - geht es verloren, fehlt keine Rechnung, es wird beim
naechsten Einlesen neu gefuellt.
"""
heim = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
return os.path.join(heim, "ravokk")
def standard_anlegen():
"""Den festen Ordner anlegen und zurueckgeben - oder '' wenn das nicht geht."""
ordner = standard_ordner()
try:
os.makedirs(ordner, exist_ok=True)
return ordner
except OSError as e:
log.warning("Gemeinsamer Ordner %s liess sich nicht anlegen: %s", ordner, e)
return ""
def pfad_zur_datei(ordner):
"""Vollstaendiger Pfad der gemeinsamen Datei in diesem Ordner."""
return os.path.join(ordner or "", DATEINAME)

View file

@ -32,29 +32,32 @@ Beschriftung ergibt eine Trennlinie.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber.
"""
import theme
import tkinter as tk
from tkinter import ttk
class Menueleiste(tk.Frame):
def __init__(self, master, menues, farben=None, mit_trennlinie=True):
def __init__(self, master, menues, farben=None):
self.f = farben if farben is not None else master
super().__init__(master, bg=self.f.col_bg)
self.menues = menues
self._offen = None
self._anker = None # welcher Titel gerade offen ist
self._titel = {} # Label -> Eintraege, fuer den Wechsel im Klick
self.pack(side="top", fill="x")
for titel, eintraege in menues.items():
lbl = tk.Label(self, text=titel, bg=self.f.col_bg, fg=self.f.col_fg,
padx=8, pady=3)
lbl.pack(side="left")
self._titel[lbl] = eintraege
lbl.bind("<Enter>", lambda e, w=lbl: w.configure(bg=self.f.col_sel))
lbl.bind("<Leave>", lambda e, w=lbl: w.configure(bg=self.f.col_bg))
lbl.bind("<Button-1>",
lambda e, b=lbl, it=eintraege: self.aufklappen(b, it))
if mit_trennlinie:
ttk.Separator(master, orient="horizontal").pack(side="top", fill="x")
ttk.Separator(master, orient="horizontal").pack(side="top", fill="x")
# ---------------------------------------------------------------- Klappen
def aufklappen(self, anker, eintraege):
@ -83,9 +86,15 @@ class Menueleiste(tk.Frame):
eintrag.bind("<Button-1>", lambda e, c=befehl: self._ausfuehren(c))
pop.update_idletasks()
pop.geometry(f"+{anker.winfo_rootx()}"
f"+{anker.winfo_rooty() + anker.winfo_height()}")
# Auf den sichtbaren Bereich begrenzen, wie jede andere Position im
# Programm auch. Steht das Fenster am unteren oder rechten Rand, lief
# die Liste sonst hinaus und war nicht mehr zu treffen.
x, y = theme.auf_bildschirm(
anker.winfo_rootx(), anker.winfo_rooty() + anker.winfo_height(),
pop.winfo_reqwidth(), pop.winfo_reqheight())
pop.geometry(f"+{x}+{y}")
self._offen = pop
self._anker = anker
pop.grab_set()
pop.bind("<Button-1>", self._klick_ausserhalb, add="+")
pop.bind("<Escape>", lambda e: self.zuklappen())
@ -94,14 +103,53 @@ class Menueleiste(tk.Frame):
faerben(pop)
def _klick_ausserhalb(self, ereignis):
"""Klick neben die Liste: schliessen - und auf einem anderen Titel
gleich dessen Liste aufklappen.
WARUM DAS HIER STEHT UND NICHT IM LABEL (07.09.2026)
----------------------------------------------------
Solange eine Liste offen ist, haelt ihr `grab_set` alle Mausklicks der
Anwendung fest. Die `<Button-1>`-Bindung des Menuetitels feuert dann
gar nicht - der Klick landet hier. Vorher endete er damit beim blossen
Zuklappen, und man musste ein zweites Mal klicken, um von "Datei" zu
"Hilfe" zu kommen. Das native tk.Menu, das diese Leiste ersetzt hat,
wechselte schon beim blossen Ueberfahren; zwei Klicks waren also eine
Verschlechterung gegenueber dem, was vorher da war.
Ein Klick auf DENSELBEN Titel schliesst weiterhin nur - sonst koennte
man eine Liste nie ueber ihren Titel wieder loswerden.
"""
pop = self._offen
if not pop:
return
if (ereignis.x_root < pop.winfo_rootx()
or ereignis.x_root > pop.winfo_rootx() + pop.winfo_width()
or ereignis.y_root < pop.winfo_rooty()
or ereignis.y_root > pop.winfo_rooty() + pop.winfo_height()):
self.zuklappen()
if (pop.winfo_rootx() <= ereignis.x_root
<= pop.winfo_rootx() + pop.winfo_width()
and pop.winfo_rooty() <= ereignis.y_root
<= pop.winfo_rooty() + pop.winfo_height()):
return # der Klick lag in der Liste
vorher = self._anker
self.zuklappen()
getroffen = self._titel_bei(ereignis.x_root, ereignis.y_root)
if getroffen is not None and getroffen is not vorher:
self.aufklappen(getroffen, self._titel[getroffen])
def _titel_bei(self, x_root, y_root):
"""Welcher Menuetitel an dieser Bildschirmstelle liegt - oder None.
Nachgerechnet statt gefragt: `winfo_containing()` waere kuerzer, gibt
aber nichts zurueck, sobald das Fenster einer ANDEREN Anwendung ueber
dem Punkt liegt. Beim Bedienen ist das nie der Fall - beim Pruefen
schon, und dort meldete es dann einen Fehler, den es nicht gibt
(07.09.2026, zwei Pruefstaende liefen gleichzeitig auf demselben
Bildschirmplatz). Wo die eigenen Titel liegen, weiss diese Leiste
selbst; dafuer muss sie das Fenstersystem nicht fragen.
"""
for lbl in self._titel:
x, y = lbl.winfo_rootx(), lbl.winfo_rooty()
if (x <= x_root <= x + lbl.winfo_width()
and y <= y_root <= y + lbl.winfo_height()):
return lbl
return None
def _ausfuehren(self, befehl):
# Erst zuklappen, dann ausfuehren: sonst haelt der grab_set der Liste
@ -118,3 +166,4 @@ class Menueleiste(tk.Frame):
except tk.TclError:
pass
self._offen = None
self._anker = None

View file

@ -6,6 +6,14 @@ Rechnungstool und Steuerjournal fuehren ein gemeinsames Nummernbuch
genannt werden - deshalb fragt jedes Programm beim ersten Start danach und
sagt ausdruecklich dazu, dass es derselbe sein muss.
⚠ NICHT der Ordner, in dem das Programm liegt. Frueher stand das so im Text
("am besten der Ordner, in dem die Programme liegen") und der Knopf hiess
sogar "Install-Ordner auswaehlen". Seit es einen Installer gibt, geht das gar
nicht mehr: die beiden Programme liegen in ZWEI getrennten Ordnern unter
%LOCALAPPDATA%/Programs, und ein Deinstallieren wuerde das Nummernbuch
mitnehmen. Vorgeschlagen wird deshalb der Ordner, in dem auch die Rechnungen
liegen - den kennen beide Programme ohnehin, und er wird mitgesichert.
Der Weg ist bewusst umstaendlich: auswaehlen, dann OK, dann noch einmal
bestaetigen. Ein falscher Ordner faellt sonst erst auf, wenn eine
Rechnungsnummer zum zweiten Mal vergeben wurde.
@ -25,6 +33,8 @@ import os
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import theme
import gemeinsam
BREITE = 780
@ -39,6 +49,18 @@ class OrdnerWahl(tk.Toplevel):
self.ergebnis = None
self.title("Gemeinsamer Ordner für das Nummernbuch")
self.transient(master)
# Dieses Fenster stand als einziges noch in Weiss da: es kam nie durch
# _style_dialog. Farben holt es sich jetzt vom Hauptfenster, damit es
# nicht wieder auseinanderlaeuft, wenn dort etwas geaendert wird.
self._bg = getattr(master, "col_bg", "#0d0e10")
self._fg = getattr(master, "col_fg", "#e8e8ea")
self._warn = getattr(master, "col_gefahr", "#e85a5a")
self._gut = getattr(master, "col_accent", "#06C6A4")
self._muted = getattr(master, "col_muted", "#8a8e96")
if hasattr(master, "_style_dialog"):
master._style_dialog(self)
else:
self.configure(bg=self._bg)
self.pfad = tk.StringVar(value=vorgabe or "")
rahmen = ttk.Frame(self, padding=18)
@ -47,7 +69,7 @@ class OrdnerWahl(tk.Toplevel):
breite = BREITE - 60
ttk.Label(rahmen, text="Bitte den gemeinsamen Ordner auswählen",
font=("Segoe UI", 13, "bold")).grid(row=0, column=0, sticky="w")
font=(theme.SCHRIFT_NAME, 13, "bold")).grid(row=0, column=0, sticky="w")
ttk.Label(rahmen, wraplength=breite, justify="left", text=(
"Rechnungstool und Steuerjournal führen zusammen ein Nummernbuch. Darin steht "
"jede vergebene Rechnungsnummer – auch die aus der alten Excel-Tabelle, zu der "
@ -56,10 +78,10 @@ class OrdnerWahl(tk.Toplevel):
"eine Nummer ein zweites Mal. Jede Nummer darf es aber nur einmal geben "
"(§ 14 Abs. 4 Nr. 4 UStG).")).grid(row=1, column=0, sticky="w", pady=(8, 12))
tk.Label(rahmen, wraplength=breite, justify="left", fg="#a00000",
font=("Segoe UI", 10, "bold"),
tk.Label(rahmen, wraplength=breite, justify="left", fg=self._warn, bg=self._bg,
font=(theme.SCHRIFT_NAME, 10, "bold"),
text="WICHTIG: In BEIDEN Programmen muss derselbe Ordner stehen – am besten "
"der Ordner, in dem die Programme liegen.").grid(
"der Ordner, in dem auch die Rechnungen liegen.").grid(
row=2, column=0, sticky="w", pady=(0, 14))
wahl = ttk.Frame(rahmen)
@ -67,11 +89,18 @@ class OrdnerWahl(tk.Toplevel):
wahl.columnconfigure(0, weight=1)
self.feld = ttk.Entry(wahl, textvariable=self.pfad, state="readonly")
self.feld.grid(row=0, column=0, sticky="ew")
ttk.Button(wahl, text="Install-Ordner auswählen…", command=self.waehlen,
width=26).grid(row=0, column=1, sticky="w", padx=(10, 0))
# "Install-Ordner" hiess der Knopf frueher - das war schon immer
# falsch und ist es seit dem Installer erst recht: hier geht es NICHT
# um den Ordner, in dem das Programm liegt, sondern um den, in dem die
# beiden Programme ihr gemeinsames Nummernbuch fuehren. Sein Befund am
# 07.09.2026: "gibt es noch das popup install ordner auswaehlen, das
# stimmt ja nicht".
ttk.Button(wahl, text="Ordner auswählen…", command=self.waehlen,
width=22).grid(row=0, column=1, sticky="w", padx=(10, 0))
self.stand = tk.Label(rahmen, text="", wraplength=breite, justify="left",
font=("Segoe UI", 9))
bg=self._bg,
font=(theme.SCHRIFT_NAME, 9))
self.stand.grid(row=4, column=0, sticky="w", pady=(10, 0))
knoepfe = ttk.Frame(rahmen)
@ -89,9 +118,8 @@ class OrdnerWahl(tk.Toplevel):
# zweiten Spalte ab und der Auswahl-Knopf ist nicht erreichbar.
self.update_idletasks()
hoehe = max(self.winfo_reqheight(), 300)
x = master.winfo_rootx() + max(0, (master.winfo_width() - BREITE) // 2)
y = master.winfo_rooty() + 90
self.geometry(f"{BREITE}x{hoehe}+{max(0, x)}+{max(0, y)}")
self.geometry(f"{BREITE}x{hoehe}")
theme.mittig(self, master)
self.minsize(BREITE, hoehe)
self.grab_set()
self.lift()
@ -127,12 +155,12 @@ class OrdnerWahl(tk.Toplevel):
"""
ordner = self.pfad.get().strip()
if not ordner:
self.stand.configure(text="Noch kein Ordner gewählt.", fg="#a00000")
self.stand.configure(text="Noch kein Ordner gewählt.", fg=self._warn)
self.ok.state(["disabled"])
return
taugt, grund = gemeinsam.ordner_taugt(ordner)
if not taugt:
self.stand.configure(text=grund, fg="#a00000")
self.stand.configure(text=grund, fg=self._warn)
self.ok.state(["disabled"])
return
self.ok.state(["!disabled"])
@ -144,27 +172,39 @@ class OrdnerWahl(tk.Toplevel):
self.stand.configure(
text=f"Hier liegt schon ein Nummernbuch mit {anzahl} Rechnungsnummern – "
"das ist der Ordner des anderen Programms. Genau richtig.",
fg="#1f5c3d")
fg=self._gut)
else:
self.stand.configure(
text="Hier ist noch kein Nummernbuch – es wird neu angelegt. Wähle im "
"anderen Programm denselben Ordner.", fg="#444444")
"anderen Programm denselben Ordner.", fg=self._muted)
def _melden(self, titel, text):
"""Meldung im Theme des Hauptfensters - sonst wie bisher."""
eltern = self.master
if hasattr(eltern, "melden_warnung"):
return eltern.melden_warnung(titel, text)
return messagebox.showwarning(titel, text, parent=self)
def _frage(self, titel, text):
eltern = self.master
if hasattr(eltern, "frage"):
return eltern.frage(titel, text)
return messagebox.askyesno(titel, text, parent=self)
def uebernehmen(self):
ordner = self.pfad.get().strip()
taugt, grund = gemeinsam.ordner_taugt(ordner)
if not taugt:
messagebox.showwarning("Ordner geht nicht", grund or "Bitte einen Ordner wählen.",
parent=self)
self._melden("Ordner geht nicht", grund or "Bitte einen Ordner wählen.")
return
if not messagebox.askyesno(
if not self._frage(
"Sind Sie sicher?",
f"Gemeinsamer Ordner:\n\n{ordner}\n\n"
"Steht im anderen Programm derselbe Ordner? Nur dann kennen beide "
"dieselben Rechnungsnummern.\n\n"
"Ja = Ordner übernehmen\n"
"Nein = zurück zur Auswahl",
icon="question", default="yes", parent=self):
"Nein = zurück zur Auswahl"):
# Zurueck ins Auswahlfeld - der Dialog bleibt stehen, nichts ist gesetzt.
self.feld.focus_set()
return

View file

@ -24,16 +24,6 @@ HIER = r"C:\claude\beherbergungssteuer"
TOOL = r"C:\claude\rechnungstool"
sys.path.insert(0, HIER)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
# --- Reissleine: ein Pruefstand darf nie haengen ---
import threading as _th
@ -59,6 +49,7 @@ shutil.copy(os.path.join(TOOL, "config.json"), os.path.join(tmp, "config.json"))
tool_config.CONFIG_PATH = os.path.join(tmp, "config.json")
import berechnung as tb # noqa: E402
import pdf_renderer # noqa: E402
from pruefhelfer import fehler, pruefe
cfg_tool = tool_config.load_config()

View file

@ -34,16 +34,6 @@ import tkinter as tk
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kod = sys.stdout.encoding or "utf-8"
print(zeile.encode(kod, "replace").decode(kod))
if not bedingung:
fehler.append(text)
os.chdir(QUELLE)
os.environ["CAMPINGHOF_TEST"] = "1"
@ -72,6 +62,7 @@ os.chdir(prog)
import app as A # noqa: E402
import theme # noqa: E402
from pruefhelfer import fehler, pruefe
f = A.App()
@ -156,6 +147,88 @@ for wunsch, soll_dunkel in (("hell", False), ("dunkel", True)):
f"in '{wunsch}' ist keine Farbe der anderen Darstellung "
f"uebrig ({reste[:3]})")
# ---- Dialoge gehen mittig ueber dem Programmfenster auf ------------------
# Seine Ansage: "einfach so programmieren das die popup auf dem fenster mittig
# vom main fenster auf gehen". Das ist zugleich die Antwort auf die
# Monitorfrage - steht das Programm auf dem zweiten Bildschirm, geht der
# Dialog dort auf. Vorher klemmte ein max(0, x) jede negative Koordinate auf
# null und riss die Fenster auf den Hauptmonitor zurueck.
d = f.dlg_darstellung()
pumpen(1.2)
d.update_idletasks()
mitte_dialog = d.winfo_rootx() + d.winfo_width() // 2
mitte_fenster = f.winfo_rootx() + f.winfo_width() // 2
pruefe(abs(mitte_dialog - mitte_fenster) <= 30,
f"der Dialog steht mittig ueber dem Fenster "
f"({mitte_dialog} vs. {mitte_fenster})")
pruefe(d.winfo_rootx() >= f.winfo_rootx() - 40
and d.winfo_rootx() + d.winfo_width() <= f.winfo_rootx() + f.winfo_width() + 40,
"und liegt waagerecht innerhalb des Fensters")
pruefe(d.winfo_rooty() >= f.winfo_rooty() - 40,
"und nicht oberhalb davon")
d.destroy()
pumpen(0.3)
# ---- Der Dialog muss den Klick UEBERLEBEN -------------------------------
# Gefunden am 07.09.2026 von der Pruefkette: _oberflaeche_neu() loeschte alle
# Kinder des Hauptfensters - und ein Toplevel IST ein Kind. Der offene Dialog
# wurde also beim ersten Klick mitgeloescht, die Zeile danach fasste eine
# Leiche an ("invalid command name .!toplevel").
#
# Dieser Pruefstand hat es NICHT gefunden, weil er cfg["darstellung"] von Hand
# setzte. Hier wird jetzt der Radioknopf gedrueckt - derselbe Weg, den ein
# Mensch geht.
d = f.dlg_darstellung()
pumpen(1.0)
def radioknoepfe(fenster):
"""Alle Radiobuttons unterhalb eines Fensters, mit ihrer Beschriftung."""
treffer = []
def durchgehen(w):
for kind in w.winfo_children():
if kind.winfo_class() == "TRadiobutton":
try:
treffer.append((str(kind.cget("text")), kind))
except tk.TclError:
pass
durchgehen(kind)
durchgehen(fenster)
return treffer
knoepfe = radioknoepfe(d)
pruefe(len(knoepfe) == len(theme.MODI),
f"der Dialog zeigt alle {len(theme.MODI)} Moeglichkeiten "
f"({[t for t, _ in knoepfe]})")
ziel = next((w for t, w in knoepfe if t == theme.MODUS_NAMEN["dunkel"]), None)
pruefe(ziel is not None, "der Knopf 'Dunkel' ist da")
if ziel is not None:
ziel.invoke() # der echte Klick
pumpen(1.2)
pruefe(bool(d.winfo_exists()),
"der Dialog lebt nach dem Klick noch")
pruefe(f._dark_mode is True,
f"und die Darstellung ist umgestellt (Hintergrund {f.col_bg})")
# Und noch einmal, denn genau das war vorher unmoeglich:
hell = next((w for t, w in knoepfe if t == theme.MODUS_NAMEN["hell"]), None)
if hell is not None:
try:
hell.invoke()
pumpen(1.0)
pruefe(f._dark_mode is False,
"ein ZWEITER Klick im selben Dialog wirkt auch")
except tk.TclError as e:
pruefe(False, f"der zweite Klick scheiterte ({e})")
try:
d.destroy()
except tk.TclError:
pass
pumpen(0.3)
# ---- Zurueck auf automatisch --------------------------------------------
f.cfg["darstellung"] = "auto"
f._oberflaeche_neu()

View file

@ -24,16 +24,6 @@ from datetime import date
HIER = r"C:\claude\beherbergungssteuer"
sys.path.insert(0, HIER)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
@ -74,6 +64,7 @@ messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None
from modell import Buchung, doppelte_nummern, summiere # noqa: E402
from pruefhelfer import fehler, pruefe
pruefe(app_mod.BASE_DIR == tmp, f"Pruefstand laeuft im Testordner ({app_mod.BASE_DIR})")

View file

@ -24,16 +24,6 @@ import time
HIER = r"C:\claude\beherbergungssteuer"
sys.path.insert(0, HIER)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
laeufer = []
@ -54,6 +44,7 @@ _wecker.daemon = True
_wecker.start()
from einzelinstanz import pid_datei # noqa: E402
from pruefhelfer import fehler, pruefe
if os.path.exists(pid_datei()):
print(f" (Merkzettel: {pid_datei()})")

View file

@ -0,0 +1,149 @@
# -*- coding: utf-8 -*-
"""Pruefstand: finden sich die beiden Programme von SELBST?
Sein Einwand vom 07.09.2026, wortwoertlich:
"wie soll das rechnungstool und steuertool wissen wo es ist, das muss im
... ravokk liegen und dann zusammen, sonst kommt das eine nicht an das
andere ran - die sind doch nur ueber die gemeinsame sqlite verbunden und
mit api, die sagt hallo da ist was neues"
Er hat recht: die beiden haengen an genau zwei Faeden - dieser SQLite-Datei
und der kleinen API. Vorher fragte jedes Programm beim ersten Start nach einem
Ordner, und der Nutzer musste in BEIDEN denselben eintragen. Ein Buchstabe
daneben, und jedes Programm fuehrt sein eigenes Buch weiter - auffallen wuerde
das erst, wenn eine Rechnungsnummer zum zweiten Mal vergeben ist
(§ 14 Abs. 4 Nr. 4 UStG).
GEPRUEFT WIRD
1. Beide Programme nennen denselben Standardordner. Das ist die Grundlage;
weichen sie hier ab, hilft alles Weitere nichts.
2. Der Ordner laesst sich anlegen, und darin entsteht EIN Nummernbuch.
3. Was das eine eintraegt, findet das andere - ohne dass jemand einen Pfad
eingegeben hat.
4. Die Dateien, die von sich sagen, sie laegen in beiden Programmen gleich,
sind auch wirklich gleich.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber - und sie prueft sich unter Punkt 4 selbst mit, denn sie traegt
denselben Satz wie die anderen.
Angefasst wird dabei nichts Echtes: der Standardordner wird ueber die
Umgebungsvariable LOCALAPPDATA auf einen Temp-Ordner umgelenkt.
"""
import os
import shutil
import sys
import tempfile
RECHNUNG = r"C:\claude\rechnungstool"
STEUER = r"C:\claude\beherbergungssteuer"
# ---- 1. Beide Programme meinen denselben Ort ----------------------------
# Geladen wird jede Fassung EINZELN, damit wirklich die Datei des jeweiligen
# Programms befragt wird und nicht die zufaellig zuerst importierte.
import importlib.util # noqa: E402
from pruefhelfer import fehler, pruefe
def modul_aus(ordner, name):
pfad = os.path.join(ordner, name + ".py")
kennung = f"{name}_{os.path.basename(ordner)}"
spec = importlib.util.spec_from_file_location(kennung, pfad)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
tmp = tempfile.mkdtemp(prefix="gemeinsam_auto_")
os.environ["LOCALAPPDATA"] = tmp
g_rechnung = modul_aus(RECHNUNG, "gemeinsam")
g_steuer = modul_aus(STEUER, "gemeinsam")
a, b = g_rechnung.standard_ordner(), g_steuer.standard_ordner()
pruefe(a == b, f"beide Programme nennen denselben Ordner ({a})")
pruefe(a.startswith(tmp),
"und er haengt an LOCALAPPDATA, nicht an einem festen Pfad im Quelltext")
pruefe(os.path.basename(a) == "ravokk",
f"er heisst 'ravokk', wie er es wollte ({os.path.basename(a)})")
# ---- 2. Er wird angelegt, und darin liegt EIN Buch ----------------------
angelegt = g_rechnung.standard_anlegen()
pruefe(bool(angelegt) and os.path.isdir(angelegt),
f"der Ordner wird angelegt ({angelegt})")
pruefe(g_rechnung.DATEINAME == g_steuer.DATEINAME,
f"beide erwarten dieselbe Datei ({g_rechnung.DATEINAME})")
pruefe(g_rechnung.pfad_zur_datei(angelegt) == g_steuer.pfad_zur_datei(angelegt),
"und damit denselben vollen Pfad")
# ---- 3. Was das eine eintraegt, findet das andere -----------------------
buch_r = g_rechnung.Nummernbuch(angelegt)
buch_s = g_steuer.Nummernbuch(angelegt)
buch_r.eintragen("2026-777", jahr=2026, nachname="Probe", quelle="rechnungstool")
gefunden = [n for n in buch_s.nummern(2026) if n == "2026-777"]
pruefe(bool(gefunden),
"was das Rechnungstool eintraegt, sieht das Steuerjournal sofort")
buch_s.eintragen("2026-778", jahr=2026, nachname="Gegenprobe", quelle="journal")
zurueck = [n for n in buch_r.nummern(2026) if n == "2026-778"]
pruefe(bool(zurueck), "und umgekehrt genauso")
# GENAU eins - nicht "mindestens eins". Vorher stand hier `>= 1` mit einem
# any(); im Text darueber stand "genau ein Buch". Eine Pruefung, die nicht rot
# werden kann, prueft nichts: laege dort ein zweites Buch, weil eine der
# Fassungen einen anderen Dateinamen bildet, waere sie gruen geblieben - und
# das ist genau der Fall, den dieser Pruefstand abfangen soll.
#
# Die Begleitdateien von SQLite (-wal, -shm) zaehlen nicht mit; sie gehoeren
# zum selben Buch.
buecher = [n for n in os.listdir(angelegt) if n.endswith(".sqlite3")]
pruefe(len(buecher) == 1,
f"es liegt genau EIN Buch dort ({os.listdir(angelegt)})")
pruefe(buecher == [g_rechnung.DATEINAME],
f"und es traegt den Namen, den beide erwarten ({buecher})")
# ---- 4. Was "in beiden gleich" heisst, ist auch gleich ------------------
# Neun Dateien tragen im Kopf den Satz, sie laegen in beiden Programmen
# gleich. Das ist eine Zusage an den Naechsten, der eine davon anfasst - und
# geprueft hat sie bis zum 07.09.2026 niemand. Bei hinweis.py stimmte sie da
# schon nicht mehr: gleicher Inhalt, andere Zeilenenden.
#
# Die Liste steht bewusst NICHT hier, sondern wird aus dem Satz selbst
# gelesen. Eine Liste, die jemand von Hand nachfuehren muss, veraltet still
# (und still veraltete Pruefstaende fuehren in die Irre).
MARKE = "Diese Datei liegt in BEIDEN Programmen gleich"
gemeinsame = sorted(n for n in os.listdir(RECHNUNG) if n.endswith(".py")
and MARKE in open(os.path.join(RECHNUNG, n),
encoding="utf-8").read())
pruefe(len(gemeinsame) >= 5,
f"{len(gemeinsame)} Dateien sagen von sich, sie seien in beiden gleich")
for name in gemeinsame:
hier = os.path.join(RECHNUNG, name)
dort = os.path.join(STEUER, name)
if not os.path.isfile(dort):
pruefe(False, f"{name} fehlt im Steuerrechnungstool")
continue
roh_h, roh_d = open(hier, "rb").read(), open(dort, "rb").read()
if roh_h == roh_d:
pruefe(True, f"{name} ist byteweise gleich")
continue
# Nicht gleich - dann wenigstens sagen, WORAN es liegt. Das ist der
# Unterschied zwischen einer Meldung und einer Suche.
if roh_h.replace(b"\r\n", b"\n") == roh_d.replace(b"\r\n", b"\n"):
grund = "nur die Zeilenenden weichen ab"
else:
grund = "der Inhalt weicht ab"
pruefe(False, f"{name}: {grund}")
shutil.rmtree(tmp, ignore_errors=True)
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Beide Programme finden dasselbe Nummernbuch ohne Nachfrage")

190
pruef_installation.py Normal file
View file

@ -0,0 +1,190 @@
# -*- coding: utf-8 -*-
"""Pruefstand: installieren, deinstallieren - bleibt der Ordner sauber?
Seine Ansage vom 07.09.2026:
"du solltest die auch mal deinstallieren und neu installieren von windows
lassen, dass du auch pruefen kannst, dass der ordner dann leer ist und
sauber - will keine tools die unsauber sind"
Er hatte recht. Gemessen blieb nach dem Deinstallieren liegen:
Rechnungstool-1.7.1.exe, Rechnungstool-1.8.0.exe <- vom UPDATER geladen
Daten/config.json, Daten/rechnungstool.pid
Die EXEs kennt der Installer nicht - die hat der Updater abgelegt. Ohne eine
eigene Regel bleiben sie stehen, und mit jeder Fassung wird es eine mehr.
ZWEI DINGE MUESSEN GLEICHZEITIG STIMMEN
---------------------------------------
1. Der PROGRAMMordner ist danach restlos weg.
2. Die DATEN sind unangetastet - Rechnungen, Journal, Nummernbuch. Die sind
zehn Jahre aufbewahrungspflichtig (§ 147 AO); ein Deinstallieren darf sie
nie mitnehmen.
Beides zusammen geht nur, weil die Daten NEBEN dem Programm liegen:
%LOCALAPPDATA%/ravokk/ <- Daten, gemeinsames Nummernbuch
Rechnungstool/ <- Programm, wird geleert
Steuerrechnungstool/ <- Programm, wird geleert
Genau so hat er es beschrieben: *"es gibt ravokk als main verzeichnis, da liegt
config und sqlite drin, und dann gibt es den ordner rechnungstool und
steuertool"* - und dazu, warum es so heisst: *"ravokk ist meine firma und die
programme gehoeren zu der firma"*.
⚠ DIESER PRUEFSTAND INSTALLIERT WIRKLICH. Er laeuft deshalb nur, wenn das
Setup gebaut ist, und er fasst NUR die Programmordner an - vorhandene Daten
werden vorher gesichert und danach zurueckgelegt.
Und er hinterlaesst den Zustand, den er vorgefunden hat: war das Programm
vorher installiert, ist es danach wieder installiert. Beim ersten Lauf war es
das NICHT - danach stand ein deinstalliertes Programm da, und der naechste
Schritt lief ins Leere. Ein Pruefstand, der aufraeumt, muss auch sich selbst
aufraeumen.
"""
import os
import shutil
import subprocess
import sys
import tempfile
import time
HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
import version # noqa: E402
from pruefhelfer import fehler, pruefe
LOKAL = os.environ.get("LOCALAPPDATA", "")
RAVOKK = os.path.join(LOKAL, "ravokk")
NAME = os.path.splitext(version.EXE_NAME)[0]
PROGRAMMORDNER = os.path.join(RAVOKK, NAME)
def setup_datei():
ordner = os.path.join(HIER, "release")
if not os.path.isdir(ordner):
return ""
for d in sorted(os.listdir(ordner)):
if d.startswith(NAME + "-Setup") and d.endswith(".exe"):
return os.path.join(ordner, d)
return ""
def beenden():
subprocess.run(["powershell", "-NoProfile", "-Command",
"Get-Process | Where-Object { $_.ProcessName -match "
f"'{NAME}|Updater' " + "} | Stop-Process -Force "
"-ErrorAction SilentlyContinue"], capture_output=True)
time.sleep(1.5)
def inhalt(ordner):
if not os.path.isdir(ordner):
return []
gefunden = []
for wurzel, _, dateien in os.walk(ordner):
for d in dateien:
gefunden.append(os.path.relpath(os.path.join(wurzel, d), ordner))
return sorted(gefunden)
setup = setup_datei()
if not setup:
print(f"UEBERSPRUNGEN | kein Setup in release/ (vorher: python bauen.py)")
sys.exit(0)
if not LOKAL:
print("UEBERSPRUNGEN | kein LOCALAPPDATA")
sys.exit(0)
# ---- Vorhandene Daten in Sicherheit bringen -----------------------------
# Hier laeuft echte Software auf einem echten Rechner. Was schon da ist, wird
# beiseitegelegt und hinterher zurueckgestellt - ein Pruefstand darf niemandem
# seine Buchhaltung anfassen.
sicher = tempfile.mkdtemp(prefix="installpruef_")
gesichert = []
if os.path.isdir(RAVOKK):
for d in os.listdir(RAVOKK):
quelle = os.path.join(RAVOKK, d)
if os.path.isfile(quelle):
shutil.copy2(quelle, os.path.join(sicher, d))
gesichert.append(d)
print(f" ({len(gesichert)} vorhandene Datei(en) gesichert)")
beenden()
war_installiert = os.path.exists(os.path.join(PROGRAMMORDNER, "unins000.exe"))
alter_uninstaller = os.path.join(PROGRAMMORDNER, "unins000.exe")
if os.path.exists(alter_uninstaller):
subprocess.run([alter_uninstaller, "/VERYSILENT", "/SUPPRESSMSGBOXES",
"/NORESTART"], capture_output=True)
time.sleep(6)
# ---- 1. Installieren ----------------------------------------------------
e = subprocess.run([setup, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"],
capture_output=True)
time.sleep(5)
pruefe(e.returncode == 0, f"das Setup laeuft durch (Rueckgabe {e.returncode})")
pruefe(os.path.isdir(PROGRAMMORDNER),
f"das Programm landet unter ravokk ({PROGRAMMORDNER})")
dabei = inhalt(PROGRAMMORDNER)
pruefe(any(d.startswith(NAME + "-") for d in dabei),
f"die Programmdatei liegt dort ({dabei})")
pruefe("Updater.exe" in dabei, "und der Updater daneben")
# ---- 2. Eine Datei anlegen, wie der Updater es taete ---------------------
# So sieht der Ordner aus, nachdem das Programm ein paar Fassungen lang
# gelaufen ist. Genau diese Dateien blieben frueher liegen.
geerbt = os.path.join(PROGRAMMORDNER, f"{NAME}-9.9.9.exe")
with open(geerbt, "wb") as f:
f.write(b"als haette der Updater sie geladen")
with open(os.path.join(PROGRAMMORDNER, "installiert.json"), "w",
encoding="utf-8") as f:
f.write("{}")
# ---- 3. Eine Datendatei daneben, die ueberleben MUSS ---------------------
probe = os.path.join(RAVOKK, "pruefstand-beleg.txt")
with open(probe, "w", encoding="utf-8") as f:
f.write("steht fuer Rechnungen, Journal und Nummernbuch")
# ---- 4. Deinstallieren --------------------------------------------------
beenden()
u = os.path.join(PROGRAMMORDNER, "unins000.exe")
pruefe(os.path.exists(u), "es gibt einen Uninstaller")
if os.path.exists(u):
subprocess.run([u, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"],
capture_output=True)
time.sleep(7)
uebrig = inhalt(PROGRAMMORDNER)
pruefe(not os.path.isdir(PROGRAMMORDNER),
f"der Programmordner ist danach weg ({uebrig})")
pruefe(not uebrig,
f"und darin liegt nichts mehr - auch keine geladene Fassung ({uebrig})")
pruefe(os.path.exists(probe),
"die Daten daneben sind unangetastet (Rechnungen, Journal, Nummernbuch)")
pruefe(os.path.isdir(RAVOKK),
"und der Ordner der Firma steht noch")
# ---- Aufraeumen ---------------------------------------------------------
if os.path.exists(probe):
os.remove(probe)
if war_installiert:
# So hinterlassen, wie es vorgefunden wurde.
subprocess.run([setup, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"],
capture_output=True)
time.sleep(5)
print(f" (war vorher installiert - wieder eingerichtet: "
f"{os.path.isdir(PROGRAMMORDNER)})")
for d in gesichert:
ziel = os.path.join(RAVOKK, d)
if not os.path.exists(ziel):
os.makedirs(RAVOKK, exist_ok=True)
shutil.copy2(os.path.join(sicher, d), ziel)
shutil.rmtree(sicher, ignore_errors=True)
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Deinstallieren laesst den Programmordner leer - und die Daten stehen")

184
pruef_menueleiste.py Normal file
View file

@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-
"""Pruefstand: die eigene Menueleiste - Wechsel mit EINEM Klick, und am Rand.
WARUM ES DIESE LEISTE GIBT
--------------------------
`tk.Menu` ist unter Windows ein Menue des Betriebssystems. Es nimmt Farben
entgegen und zeichnet trotzdem im Systemgrau - ein weisser Balken ueber einem
dunklen Fenster. Sein Befund am 07.09.2026: *"nur die einstellunden hilfe
leiste die ist noch hell"*. Deshalb ist die Leiste aus gewoehnlichen Labels
gebaut.
WAS HIER GEPRUEFT WIRD
1. Ein Klick auf einen Titel klappt seine Liste auf, ein zweiter auf denselben
Titel klappt sie wieder zu.
2. Steht eine Liste offen und man klickt auf einen ANDEREN Titel, klappt
dessen Liste auf - mit EINEM Klick.
Das ging bis zum 07.09.2026 nicht. Solange eine Liste offen ist, haelt ihr
`grab_set` alle Mausklicks der Anwendung fest; die `<Button-1>`-Bindung des
Menuetitels feuert dann gar nicht. Der Klick landete beim Zuklappen und war
damit verbraucht - man musste ein zweites Mal klicken, um von "Datei" zu
"Hilfe" zu kommen. Das native tk.Menu wechselte schon beim blossen
Ueberfahren; zwei Klicks waren also eine Verschlechterung gegenueber dem,
was ersetzt wurde.
Geprueft wird das ueber ein echtes Klickereignis auf der offenen Liste, mit
den Bildschirmkoordinaten des anderen Titels - also genau der Weg, den der
grab_set erzwingt. Ein direkter Aufruf von aufklappen() wuerde am Problem
vorbeipruefen.
3. Die Liste bleibt auf dem Bildschirm. Steht das Fenster am unteren Rand,
lief sie vorher darueber hinaus und war nicht mehr zu treffen: die Position
wurde ungeprueft gesetzt, waehrend jede andere neue Position im Programm
ueber theme.auf_bildschirm() laeuft.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber - sonst steht in zwei Programmen zweierlei.
"""
import os
import sys
import time
import tkinter as tk
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
import menueleiste # noqa: E402
import theme # noqa: E402
from pruefhelfer import fehler, pruefe # noqa: E402
class Fenster(theme.ThemeMixin, tk.Tk):
"""Ein nacktes Fenster mit Farben - mehr braucht die Leiste nicht."""
def __init__(self):
super().__init__()
self.cfg = {} # theme.modus() will etwas mit .get()
self._apply_theme()
self.title("Pruefstand Menueleiste")
self.geometry("560x320" + theme.pruefplatz())
f = Fenster()
gerufen = []
f.menue = menueleiste.Menueleiste(f, {
"Datei": [("Neu", lambda: gerufen.append("neu")), ("-", None),
("Beenden", lambda: gerufen.append("beenden"))],
"Bearbeiten": [("Rueckgaengig", lambda: gerufen.append("rueck"))],
"Hilfe": [("Info", lambda: gerufen.append("info"))],
})
def pumpen(sekunden=0.4):
ende = time.time() + sekunden
while time.time() < ende:
f.update()
time.sleep(0.02)
pumpen(1.0)
titel = {str(w.cget("text")): w for w in f.menue.winfo_children()
if isinstance(w, tk.Label)}
pruefe(set(titel) == {"Datei", "Bearbeiten", "Hilfe"},
f"alle drei Titel stehen in der Leiste ({sorted(titel)})")
def klick_auf_titel(name):
"""Auf einen Menuetitel klicken - so, wie es die Maus tut.
Steht schon eine Liste offen, MUSS das Ereignis an sie gehen: ihr grab_set
faengt jeden Klick der Anwendung ab. Genau das ist der Fall, um den es
unter Punkt 2 geht.
"""
w = titel[name]
x = w.winfo_rootx() + w.winfo_width() // 2
y = w.winfo_rooty() + w.winfo_height() // 2
ziel = f.menue._offen or w
if ziel is w:
# Achtung: Tk nennt die Bildschirmkoordinaten beim Erzeugen -rootx
# und -rooty; im Ereignis heissen sie dann x_root und y_root.
w.event_generate("<Button-1>", x=w.winfo_width() // 2,
y=w.winfo_height() // 2, rootx=x, rooty=y)
else:
ziel.event_generate("<Button-1>", x=-1, y=-1, rootx=x, rooty=y)
pumpen(0.4)
def offener_titel():
"""Welcher Titel gerade offen ist - oder None."""
anker = f.menue._anker
if anker is None or not f.menue._offen:
return None
return next((t for t, w in titel.items() if w is anker), "?")
# ---- 1. Aufklappen und wieder zuklappen ---------------------------------
klick_auf_titel("Datei")
pruefe(offener_titel() == "Datei", f"'Datei' klappt auf ({offener_titel()})")
klick_auf_titel("Datei")
pruefe(offener_titel() is None,
f"und derselbe Titel klappt wieder zu ({offener_titel()})")
# ---- 2. Der Wechsel: EIN Klick ------------------------------------------
klick_auf_titel("Datei")
pruefe(offener_titel() == "Datei", "'Datei' steht offen")
klick_auf_titel("Hilfe")
pruefe(offener_titel() == "Hilfe",
f"ein Klick auf 'Hilfe' wechselt hinueber ({offener_titel()})")
klick_auf_titel("Bearbeiten")
pruefe(offener_titel() == "Bearbeiten",
f"und weiter auf 'Bearbeiten' ({offener_titel()})")
# Ein Klick irgendwo neben die Leiste schliesst - und oeffnet nichts.
pop = f.menue._offen
pop.event_generate("<Button-1>", x=-1, y=-1,
rootx=f.winfo_rootx() + 300,
rooty=f.winfo_rooty() + 250)
pumpen(0.4)
pruefe(offener_titel() is None,
f"ein Klick daneben schliesst und oeffnet nichts ({offener_titel()})")
# ---- 3. Ein Eintrag fuehrt seinen Befehl aus ----------------------------
klick_auf_titel("Hilfe")
eintrag = None
for kind in f.menue._offen.winfo_children():
for enkel in kind.winfo_children():
if isinstance(enkel, tk.Label) and str(enkel.cget("text")) == "Info":
eintrag = enkel
eintrag.event_generate("<Button-1>", x=2, y=2)
pumpen(0.4)
pruefe(gerufen == ["info"], f"der Eintrag 'Info' fuehrt seinen Befehl aus ({gerufen})")
pruefe(offener_titel() is None, "und die Liste ist danach zu")
# ---- 4. Am unteren Bildschirmrand bleibt die Liste sichtbar -------------
# Das Fenster so weit nach unten schieben, dass unter der Leiste kein Platz
# mehr fuer die Klappliste ist.
links, oben = theme.auf_bildschirm(f.winfo_rootx(), f.winfo_rooty())
unten = oben + f.winfo_screenheight()
f.geometry(f"560x320+{links}+{unten - 120}")
pumpen(0.6)
klick_auf_titel("Datei")
pop = f.menue._offen
pruefe(pop is not None, "die Liste geht auch am unteren Rand auf")
if pop is not None:
x, y = pop.winfo_rootx(), pop.winfo_rooty()
hoehe, breite = pop.winfo_height(), pop.winfo_width()
grenze_x, grenze_y = theme.auf_bildschirm(x, y, breite, hoehe)
pruefe((x, y) == (grenze_x, grenze_y),
f"und steht vollstaendig auf dem Bildschirm "
f"(Liste {x},{y} bis {x + breite},{y + hoehe})")
f.destroy()
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Die Menueleiste wechselt mit einem Klick und bleibt im Bild")

View file

@ -39,16 +39,7 @@ sys.path.insert(0, HIER)
import updater # noqa: E402
import version # noqa: E402
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kod = sys.stdout.encoding or "utf-8"
print(zeile.encode(kod, "replace").decode(kod))
if not bedingung:
fehler.append(text)
from pruefhelfer import fehler, pruefe
try:
@ -64,7 +55,15 @@ if dieses is None:
print("ROT | ohne Release ist der Rest nicht pruefbar")
sys.exit(1)
anhaenge = {a.get("name"): a for a in dieses.get("assets") or []}
roh = dieses.get("assets") or []
anhaenge = {a.get("name"): a for a in roh}
# Doppelte Namen: Forgejo laesst zwei Anhaenge mit demselben Namen zu. Beim
# Ersetzen einer Datei blieb die alte einmal stehen (07.09.2026), und welche
# von beiden der Updater dann laedt, ist Glueckssache - unter Umstaenden die
# alte, mit der falschen Pruefsumme daneben.
doppelt = sorted({a["name"] for a in roh
if sum(1 for b in roh if b["name"] == a["name"]) > 1})
pruefe(not doppelt, f"kein Anhang haengt doppelt am Release ({doppelt})")
print(f" (Anhaenge: {', '.join(sorted(anhaenge)) or 'keine'})")
pruefe(version.EXE_NAME in anhaenge,

View file

@ -36,16 +36,7 @@ HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
import version # noqa: E402
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kod = sys.stdout.encoding or "utf-8"
print(zeile.encode(kod, "replace").decode(kod))
if not bedingung:
fehler.append(text)
from pruefhelfer import fehler, pruefe
stamm, endung = os.path.splitext(version.EXE_NAME)
@ -57,7 +48,12 @@ if not os.path.exists(EXE):
sys.exit(0)
u = ctypes.windll.user32
p = subprocess.Popen([EXE])
# Mit --test: dann geht das Fenster auf dem ZWEITEN Monitor auf und nicht
# mitten auf dem Hauptbildschirm, wo gerade jemand arbeitet. Seine Ansage am
# 07.09.2026 - und dieser Pruefstand war der eine, der sie noch nicht befolgte,
# weil er die gebaute EXE ganz normal startet. Sichtbar bleibt das Fenster,
# und genau das wird hier ja geprueft.
p = subprocess.Popen([EXE, "--test"])
print(f" (gestartet: {os.path.basename(EXE)}, PID {p.pid})")
RUECKRUF = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)

View file

@ -24,16 +24,6 @@ TOOL = r"C:\claude\rechnungstool"
JOURNAL = r"C:\claude\beherbergungssteuer"
sys.path.insert(0, TOOL)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
@ -62,6 +52,7 @@ os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
# Erprobungshinweis und Freigabe vor dem Beleg werden hier bejaht - geprueft
# werden sie eigens in pruef_hinweis.py und pruef_freigabe.py des Rechnungstools.

View file

@ -30,17 +30,6 @@ from datetime import date
HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
fehler = 0
def pruefe(bedingung, text):
global fehler
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler += 1
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
@ -84,6 +73,7 @@ messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None
from gui_monat import SPALTEN # noqa: E402
from pruefhelfer import fehler, pruefe
pruefe(app_mod.BASE_DIR == tmp, f"Pruefstand laeuft im Testordner ({app_mod.BASE_DIR})")
@ -216,5 +206,5 @@ fenster.destroy()
_wecker.cancel()
print("---")
print("alles gruen" if not fehler else f"{fehler} FEHLER")
print("alles gruen" if not fehler else f"{len(fehler)} FEHLER")
sys.exit(1 if fehler else 0)

101
pruef_temp_merkzettel.py Normal file
View file

@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""Pruefstand: ein Merkzettel in den Temp-Ordner wird verworfen.
DER FALL, DER DAZU GEFUEHRT HAT (07.09.2026)
--------------------------------------------
Die installierte Fassung fuehrte ihr Journal in
`%TEMP%/abnahme_bst/Daten` - haengengeblieben aus einem Abnahmelauf vom 03.09.
Windows raeumt den Temp-Ordner von selbst auf (Datentraegerbereinigung,
Speicheroptimierung); das Journal waere ohne Vorwarnung verschwunden. Es geht
um aufbewahrungspflichtige Aufzeichnungen, zehn Jahre (§ 147 AO).
Ich habe daraufhin `_ist_temp()` geschrieben, ausfuehrlich begruendet - und
NICHT ANGESCHLOSSEN. Die Funktion stand da und wurde von niemandem gerufen;
der Schutz war ein Kommentar, keine Massnahme. Zwei Pruefagenten haben das
unabhaengig voneinander gefunden, einer davon mit einer Messung, die zeigte,
dass das Journal weiterhin im Temp landet.
⚠️ Das ist die schlimmste Sorte Fehler: ein Kommentar, der eine Schutzmassnahme
zusichert, die es nicht gibt. Wer die Datei liest, haelt die Falle fuer
geschlossen. Deshalb dieser Pruefstand - er prueft nicht, ob die FUNKTION
existiert, sondern ob sie WIRKT.
Angefasst wird nichts Echtes: LOCALAPPDATA zeigt waehrend des Laufs auf einen
eigenen Ordner, der danach wieder verschwindet.
⚠️ Dieser Ordner liegt AUSSERHALB des Temp-Ordners - im ersten Anlauf hatte
ich ihn mit tempfile.mkdtemp() angelegt, und der liegt selbst im Temp. Dann
erkennt _ist_temp auch die Vergleichspfade als Temp und der Pruefstand meldet
Fehler, die es nicht gibt. Wer das Verhalten IM Temp-Ordner prueft, darf seine
Spielwiese nicht dort aufbauen.
"""
import importlib
import os
import shutil
import sys
import tempfile
HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
echtes_lad = os.environ.get("LOCALAPPDATA", "")
spielwiese = os.path.join(os.path.expanduser("~"), ".pruef_merkzettel")
shutil.rmtree(spielwiese, ignore_errors=True)
os.makedirs(spielwiese, exist_ok=True)
os.environ["LOCALAPPDATA"] = spielwiese
# Als EXE tun, sonst nimmt ablage.py den Quellordner und der Merkzettel
# spielt gar keine Rolle.
sys.frozen = True
sys.argv = [os.path.join(spielwiese, "Steuerrechnungstool.exe")]
import ablage # noqa: E402
from pruefhelfer import fehler, pruefe
importlib.reload(ablage)
# ---- 1. Die Funktion erkennt einen Temp-Pfad ---------------------------
temp = tempfile.gettempdir()
pruefe(ablage._ist_temp(os.path.join(temp, "abnahme_bst", "Daten")),
"ein Pfad im Temp-Ordner wird als solcher erkannt")
pruefe(not ablage._ist_temp(os.path.join(spielwiese, "ravokk")),
"ein normaler Pfad nicht")
# ---- 2. Und sie WIRKT - das ist der Punkt ------------------------------
# Genau so sah der Merkzettel bei ihm aus.
falscher = os.path.join(temp, "abnahme_bst_pruefstand", "Daten")
os.makedirs(falscher, exist_ok=True)
zeiger = ablage._zeiger_datei()
os.makedirs(os.path.dirname(zeiger), exist_ok=True)
with open(zeiger, "w", encoding="utf-8") as f:
f.write(falscher)
gewaehlt = ablage.daten_ordner()
print(f" (Merkzettel zeigte auf {falscher})")
print(f" (gewaehlt wurde {gewaehlt})")
pruefe(not ablage._ist_temp(gewaehlt),
f"der Datenordner liegt NICHT im Temp-Ordner ({gewaehlt})")
pruefe(os.path.normcase(gewaehlt) != os.path.normcase(falscher),
"dem Merkzettel wurde nicht gefolgt")
pruefe("ravokk" in gewaehlt.lower(),
f"stattdessen der Ordner der Firma ({gewaehlt})")
# ---- 3. Ein GUELTIGER Merkzettel wird weiter befolgt -------------------
# Der Schutz darf nicht dazu fuehren, dass jemand seinen bewusst gewaehlten
# Ordner verliert - etwa ein Netzlaufwerk.
gewollt = os.path.join(spielwiese, "woanders", "Daten")
os.makedirs(gewollt, exist_ok=True)
with open(zeiger, "w", encoding="utf-8") as f:
f.write(gewollt)
pruefe(os.path.normcase(ablage.daten_ordner()) == os.path.normcase(gewollt),
"ein gueltiger Merkzettel wird weiter befolgt")
os.environ["LOCALAPPDATA"] = echtes_lad
shutil.rmtree(spielwiese, ignore_errors=True)
shutil.rmtree(os.path.dirname(falscher), ignore_errors=True)
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Der Temp-Schutz ist angeschlossen und wirkt")

View file

@ -25,16 +25,6 @@ import time
JOURNAL = r"C:\claude\beherbergungssteuer"
TOOL = r"C:\claude\rechnungstool"
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
@ -66,6 +56,7 @@ del sys.modules["app"]
sys.path.remove(JOURNAL)
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None

47
pruefhelfer.py Normal file
View file

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
"""Das Gemeinsame aller Pruefstaende: eine Zeile schreiben und sie merken.
WARUM ES DIESE DATEI GIBT (07.09.2026)
--------------------------------------
Die Funktion pruefe() stand 35-mal im Quelltext, in sechs leicht verschiedenen
Fassungen. Keine davon war falsch - aber sie sahen unterschiedlich aus, und
wer 37 Laeufe hintereinander durchsieht, liest dann dreierlei Formen fuer
dieselbe Aussage. Wird an der Ausgabe je etwas geaendert, sind es 35 Stellen.
BENUTZUNG
from pruefhelfer import fehler, pruefe
pruefe(a == b, "beide nennen denselben Ordner")
...
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | ...")
`fehler` ist eine Liste und wird beim Importieren geteilt - jeder Pruefstand
laeuft in einem eigenen Prozess, sie gehoert also ihm allein.
Den Abschluss schreibt jeder Pruefstand selbst. Er sieht nur aehnlich aus:
manche raeumen dort noch einen Temp-Ordner ab, manche zaehlen die
fehlgeschlagenen Punkte einzeln auf. Das ist Inhalt, keine Wiederholung.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber - sonst steht in zwei Programmen zweierlei.
"""
import sys
# Alles, was nicht erfuellt war - der Text, nicht nur die Anzahl. Wer am Ende
# nur eine Zahl hat, muss den Lauf noch einmal lesen, um zu wissen, was fehlte.
fehler = []
def pruefe(bedingung, text):
"""Eine Zusage pruefen, das Ergebnis melden und Fehlschlaege merken."""
zeile = (" OK | " if bedingung else "FEHLER | ") + text
# Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf
# nicht mit einem UnicodeEncodeError abbrechen.
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)

238
theme.py
View file

@ -46,7 +46,6 @@ def titelleiste_faerben(fenster, dunkel):
if not sys.platform.startswith("win"):
return False
try:
import ctypes
fenster.update_idletasks()
hwnd = ctypes.windll.user32.GetParent(fenster.winfo_id()) or int(fenster.frame(), 16)
wert = ctypes.c_int(1 if dunkel else 0)
@ -66,6 +65,114 @@ def titelleiste_faerben(fenster, dunkel):
return False
def pruefplatz():
"""Wo ein Pruefstand seine Fenster aufmachen soll: "+x+y" oder "".
Seine Ansage am 07.09.2026: *"teste links auf dem 1080p monitor nicht mehr
auf dem main ... weil da schau ich nun video"*.
Vorher lag der Platz bei `Bildschirmbreite + 20` - also RECHTS neben dem
Hauptmonitor. Bei ihm steht der zweite Monitor aber LINKS (x = -1920), und
rechts ist gar nichts: die Fenster gingen ins Leere. Sie liefen zwar, waren
aber nirgends zu sehen - das hat beim Bildermachen einige Zeit gekostet.
Gesucht wird deshalb der nicht-primaere Monitor. Gibt es keinen, bleibt es
beim alten Verhalten (neben den Bildschirm), denn im Weg sein soll ein
Pruefstand auch dann nicht.
"""
if not sys.platform.startswith("win"):
return ""
try:
class RECT(ctypes.Structure):
_fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long),
("right", ctypes.c_long), ("bottom", ctypes.c_long)]
class MONITORINFO(ctypes.Structure):
_fields_ = [("cbSize", ctypes.wintypes.DWORD), ("rcMonitor", RECT),
("rcWork", RECT), ("dwFlags", ctypes.wintypes.DWORD)]
gefunden = []
def je_monitor(hmon, hdc, lprect, daten):
info = MONITORINFO()
info.cbSize = ctypes.sizeof(MONITORINFO)
if ctypes.windll.user32.GetMonitorInfoW(hmon, ctypes.byref(info)):
r = info.rcWork
gefunden.append((bool(info.dwFlags & 1), # 1 = MONITORINFOF_PRIMARY
r.left, r.top, r.right - r.left))
return 1
RUECKRUF = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.wintypes.HMONITOR,
ctypes.wintypes.HDC, ctypes.POINTER(RECT),
ctypes.wintypes.LPARAM)
ctypes.windll.user32.EnumDisplayMonitors(0, 0, RUECKRUF(je_monitor), 0)
# Der breiteste Nebenmonitor - bei mehreren der linkeste.
neben = sorted((m for m in gefunden if not m[0]),
key=lambda m: (-m[3], m[1]))
if neben:
_, x, y, _ = neben[0]
return f"+{x + 30}+{y + 40}"
except Exception: # noqa: BLE001 - dann eben der alte Weg
pass
return ""
def auf_bildschirm(x, y, breite=0, hoehe=0):
"""Eine Fensterposition auf den sichtbaren Bereich begrenzen.
Frueher stand an diesen Stellen `max(0, x)`. Das sollte verhindern, dass
ein Dialog ausserhalb des Bildschirms aufgeht - klemmt aber jede NEGATIVE
Koordinate auf null. Steht der zweite Monitor links (bei ihm x = -1920),
sprang damit jedes Popup zurueck auf den Hauptbildschirm, obwohl das
Programm nebenan lief. Sein Befund am 07.09.2026: *"da gehen noch die
popups auf dem video monitor auf"*.
Begrenzt wird deshalb auf den GESAMTEN Desktop ueber alle Monitore
(SM_*VIRTUALSCREEN), nicht auf den Hauptmonitor.
"""
if not sys.platform.startswith("win"):
return int(x), int(y)
try:
hole = ctypes.windll.user32.GetSystemMetrics
links, oben = hole(76), hole(77)
rechts, unten = links + hole(78), oben + hole(79)
if breite:
x = min(x, rechts - breite)
if hoehe:
y = min(y, unten - hoehe)
return int(max(links, x)), int(max(oben, y))
except Exception: # noqa: BLE001 - dann eben ungebremst
return int(x), int(y)
def mittig(dialog, ueber):
"""Einen Dialog mittig ueber sein Elternfenster setzen.
Seine Ansage am 07.09.2026: *"einfach so programmieren das die popup auf
dem fenster mittig vom main fenster auf gehen"*.
Das ist zugleich die Antwort auf die Monitorfrage: steht das Programm auf
dem zweiten Bildschirm, geht der Dialog dort auf - ohne dass irgendwo ein
Monitor gesucht werden muesste. Vorher standen an den Dialogen feste
Versaetze ("+120+140") und ein `max(0, x)`, das jede negative Koordinate
auf null klemmte und die Fenster damit auf den Hauptmonitor zurueckriss.
Etwas oberhalb der Mitte (ein Drittel statt der Haelfte): so sitzt der
Dialog dort, wo der Blick ohnehin ist, und verdeckt nicht die Mitte des
Formulars darunter.
"""
try:
dialog.update_idletasks()
breite = dialog.winfo_reqwidth() or dialog.winfo_width()
hoehe = dialog.winfo_reqheight() or dialog.winfo_height()
x = ueber.winfo_rootx() + (ueber.winfo_width() - breite) // 2
y = ueber.winfo_rooty() + (ueber.winfo_height() - hoehe) // 3
x, y = auf_bildschirm(x, y, breite, hoehe)
dialog.geometry(f"+{x}+{y}")
except tk.TclError:
pass # Fenster schon zu - dann ist die Position gleichgueltig
def testlauf():
"""Faehrt gerade ein Pruefstand das Programm?
@ -95,7 +202,6 @@ def schrift_vorbereiten():
_schrift_geladen = True
if not sys.platform.startswith("win"):
return SCHRIFT_NAME
import ctypes
FR_PRIVATE = 0x10
geladen = 0
for basis in _ordner():
@ -477,11 +583,12 @@ class ThemeMixin:
und Tastatur fest, waehrend nebenan jemand schreibt.
"""
d.configure(bg=self.col_bg)
# Mittig ueber dem Programmfenster - und damit automatisch auf dem
# Bildschirm, auf dem das Programm gerade steht.
d.after(1, lambda: mittig(d, self))
if testlauf():
# Wie das Hauptfenster: neben den Bildschirm, und ohne grab_set -
# ein Dialog, der Maus und Tastatur festhaelt, blockiert den
# Menschen, der nebenher schreibt.
d.geometry(f"+{d.winfo_screenwidth() + 20}+40")
# Ohne grab_set: ein Dialog, der Maus und Tastatur festhaelt,
# blockiert den Menschen, der nebenher arbeitet.
d.grab_set = lambda *a, **k: None
try:
d.attributes("-toolwindow", True) # nicht in die Taskleiste
@ -502,6 +609,120 @@ class ThemeMixin:
"""
titelleiste_faerben(win, bool(getattr(self, "_dark_mode", False)))
# ------------------------------------------------ Hell / dunkel umstellen
# Der Hinweistext gehoert dem einzelnen Programm - er sagt etwas ueber
# DIESES Programm. Hier steht nur, was dasteht, wenn ihn niemand setzt.
DARSTELLUNG_HINWEIS = "Gilt nur für das Programmfenster."
def _cfg_speichern(self):
"""Die Einstellungen wegschreiben - falls das noetig ist.
Im Rechnungstool ist die Konfiguration ein gewoehnliches Woerterbuch
und muss ausdruecklich gespeichert werden. Im Steuerrechnungstool ist
sie eine Ablage, die jeden Schreibzugriff sofort in die
Journaltabelle durchreicht - dort ist hier nichts zu tun.
"""
return
def variablen_sichern(self):
"""Alle Tk-Variablen des Fensters als {Name: Wert}.
WARUM ALLE UND NICHT EINE AUSWAHL
---------------------------------
Die _build-Methoden legen ihre Variablen selbst an
(`self.k_name = tk.StringVar()`). Nach dem Neubau stehen also neue,
leere da. Beim ersten Versuch waren Kundenname und Rechnungsnummer
weg, waehrend die Mengen blieben - die hatte ich einzeln gesichert.
Durchgegangen wird deshalb, was das Objekt an Tk-Variablen traegt.
Wer spaeter ein Feld hinzufuegt, muss nichts eintragen; eine Liste zum
Pflegen waere genau die Sorte, die man beim naechsten Feld vergisst.
"""
gesichert = {}
for name in dir(self):
if name.startswith("__"):
continue
try:
wert = getattr(self, name)
except Exception: # noqa: BLE001 - Eigenschaften koennen werfen
continue
if isinstance(wert, (tk.StringVar, tk.IntVar, tk.DoubleVar,
tk.BooleanVar)):
try:
gesichert[name] = wert.get()
except tk.TclError:
pass
return gesichert
def variablen_zuruecksetzen(self, gesichert):
"""Die gesicherten Werte in die neu gebauten Variablen zurueckgeben."""
for name, wert in gesichert.items():
neu = getattr(self, name, None)
if isinstance(neu, (tk.StringVar, tk.IntVar, tk.DoubleVar,
tk.BooleanVar)):
try:
neu.set(wert)
except tk.TclError:
pass
def kinder_loeschen(self):
"""Die Oberflaeche abraeumen - aber keine fremden Fenster.
Ein tk.Toplevel IST ein Kind des Hauptfensters. Der offene
Darstellungsdialog wurde hier bisher mitgeloescht, und die Zeile
danach fasste eine Leiche an ("invalid command name .!toplevel").
Gefunden am 07.09.2026 von der Pruefkette, mit dem echten Bedienweg
gemessen: Klick auf "Dunkel" -> Farbe wechselt, Fenster ist weg,
Traceback im Log.
Der Pruefstand hatte es nicht gefunden, weil er cfg["darstellung"] von
Hand setzte und _oberflaeche_neu() direkt rief - also alles ausser dem
Weg, den ein Mensch geht. Er klickt jetzt.
"""
for kind in self.winfo_children():
if not isinstance(kind, tk.Toplevel):
kind.destroy()
def dlg_darstellung(self):
"""Hell, dunkel oder wie Windows - und sofort sichtbar."""
d = tk.Toplevel(self)
d.title("Darstellung")
d.transient(self)
d.resizable(False, False)
self._style_dialog(d)
rahmen = ttk.Frame(d, padding=18)
rahmen.pack(fill="both", expand=True)
ttk.Label(rahmen, text="Darstellung",
font=(SCHRIFT_NAME, 11, "bold"),
foreground=self.col_accent).pack(anchor="w")
ttk.Label(rahmen, justify="left", wraplength=340,
foreground=self.col_muted,
text=self.DARSTELLUNG_HINWEIS).pack(anchor="w", pady=(6, 12))
gewaehlt = tk.StringVar(value=modus(self.cfg))
def uebernehmen():
neu = gewaehlt.get()
if neu == modus(self.cfg):
return
self.cfg["darstellung"] = neu
self._cfg_speichern()
self._oberflaeche_neu()
# Der Dialog gehoert noch zur alten Darstellung - er wird
# mitgefaerbt, sonst steht ein helles Fenster vor einem dunklen.
self._style_dialog(d)
for wert in MODI:
ttk.Radiobutton(rahmen, text=MODUS_NAMEN[wert], value=wert,
variable=gewaehlt, command=uebernehmen).pack(
anchor="w", pady=3)
ttk.Separator(rahmen, orient="horizontal").pack(fill="x", pady=(12, 10))
ttk.Button(rahmen, text="Schließen", width=14,
command=d.destroy).pack(anchor="e")
mittig(d, self)
return d
class MeldungMixin:
"""Eigene Meldungs- und Ja/Nein-Fenster im Theme des Programms.
@ -584,10 +805,7 @@ class MeldungMixin:
d.bind("<Escape>", lambda _e: schliessen(False))
d.protocol("WM_DELETE_WINDOW", lambda: schliessen(False))
d.update_idletasks()
x = self.winfo_rootx() + max(0, (self.winfo_width() - d.winfo_reqwidth()) // 2)
y = self.winfo_rooty() + 140
d.geometry(f"+{max(0, x)}+{max(0, y)}")
mittig(d, self)
# Nach vorn holen und den Tastaturfokus nehmen. Ohne das kann der Dialog
# beim Start hinter dem Hauptfenster liegen - der Nutzer sieht nur ein
# Fenster, das nicht reagiert, und haelt es fuer haengen geblieben.

View file

@ -245,6 +245,11 @@ def aufraeumen(ordner, exe_name, behalten=2):
os.remove(pfad)
weg.append(os.path.basename(pfad))
except OSError:
# Laeuft die alte Fassung noch oder haelt ein Virenscanner die
# Datei, sperrt Windows sie. Das ist kein Grund zum Abbruch: die
# Datei stoert nur, sie schadet nicht, und beim naechsten Mal
# klappt es. Was WEG ist, steht in der Rueckgabe - was nicht weg
# ist, taucht dort nicht auf.
pass
return weg

View file

@ -10,11 +10,11 @@ und NUR hier; alles andere (Info-Dialog, Updater, Release) liest sie von hier.
Regel
-----
Die Nummer wird beim Ausliefern erhoeht und entspricht dem Release-Tag ohne das
"v": Tag `v1.9.0` -> VERSION "1.9.0". Wer das eine ohne das andere aendert,
"v": Tag `v1.9.2` -> VERSION "1.9.2". Wer das eine ohne das andere aendert,
baut dem Updater eine Falle.
"""
VERSION = "1.9.0"
VERSION = "1.9.2"
# Zu welchem Repository dieses Programm gehoert - der Updater sucht dort.
REPOSITORY = "TheMockTv/Steuerrechnungstool"

View file

@ -45,51 +45,99 @@ def pfad(name):
return ""
def symbol_datei_dunkel():
"""Pfad zur aufgehellten ICO - wird angelegt, wenn sie fehlt.
def kachel(bild, kante, rand=0.16):
"""Das Logo auf eine helle Kachel mit runden Ecken setzen.
Sein Befund am 07.09.2026: *"icon musst wenn es dunkel ist mit ändern auf
das hellere geht sonst mit unter"*. Die dunkle Haelfte des Zeichens liegt
genau auf der Farbe der dunklen Titelleiste.
Seine Ansage am 07.09.2026: *"bei den windows icons kannst du einen
weissen bg setzen hinter die icons, mit abgerundeten ecken, und da das svg
drauf - das geht auf dem desktop unter"*.
`iconbitmap` braucht eine DATEI - ein Bild im Speicher genuegt nicht.
Deshalb wird sie einmal neben die normale gelegt. Gelingt das nicht (kein
Schreibrecht im Programmordner), landet sie im Temp-Ordner; und wenn auch
das nicht geht, bleibt es beim normalen Symbol.
Ein freigestelltes Zeichen ohne Flaeche verschwindet auf einem dunklen
Hintergrundbild - und welchen Hintergrund jemand eingestellt hat, weiss
das Programm nicht. Eine helle Kachel ist auf jedem sichtbar; so machen es
die meisten Programme, und deshalb sieht es auch nicht fremd aus.
`rand` ist der Abstand zum Kachelrand, als Anteil der Kantenlaenge - so
sieht es in jeder Groesse gleich aus. Die Rundung liegt fest bei 22 %,
etwa dem, was Windows fuer Kacheln verwendet.
"""
from PIL import Image, ImageDraw
kante = int(kante)
# In vierfacher Groesse zeichnen und herunterrechnen: sonst werden die
# runden Ecken bei 32 oder 48 Pixeln treppig.
fein = kante * 4
maske = Image.new("L", (fein, fein), 0)
ImageDraw.Draw(maske).rounded_rectangle(
(0, 0, fein - 1, fein - 1), radius=int(fein * 0.22), fill=255)
flaeche = Image.new("RGBA", (fein, fein), (0xFF, 0xFF, 0xFF, 255))
flaeche.putalpha(maske)
innen = max(1, int(fein * (1 - 2 * rand)))
zeichen = bild.convert("RGBA").resize((innen, innen), Image.LANCZOS)
versatz = (fein - innen) // 2
flaeche.paste(zeichen, (versatz, versatz), zeichen)
return flaeche.resize((kante, kante), Image.LANCZOS)
# Groessen, die eine Windows-ICO enthalten soll. 16 und 24 fuer Titelleiste
# und Listen, 32/48 fuer den Desktop, 256 fuer die grosse Ansicht im Explorer.
ICO_GROESSEN = (16, 24, 32, 48, 64, 128, 256)
def symbol_bauen(ziel):
"""Die Windows-ICO aus dem Logo erzeugen. Gibt den Pfad zurueck oder ''.
JEDE Groesse wird als Kachel gebaut: helle Flaeche, runde Ecken, Zeichen
darauf - *"so wie das rustdesk, nur halt alle ecken in der rundung"*. Ein
freigestelltes Logo verschwindet sonst auf einem dunklen
Desktop-Hintergrund.
Auch die 16 Pixel der Titelleiste: dort ist die Kachel zwar winzig, aber
sichtbar. Ein freigestelltes Zeichen war dort je nach Darstellung mal da
und mal nicht - und ein Symbol, das manchmal fehlt, ist schlechter als
eines, das immer klein ist.
Bei den kleinsten Groessen wird der Rand knapper gefasst: bliebe der
Abstand anteilig gleich, waere vom Zeichen bei 16 Pixeln fast nichts mehr
uebrig.
"""
fertig = pfad("programm_dunkel.ico")
if fertig:
return fertig
quelle = pfad("programm_logo.png")
if not quelle:
return ""
ziel = os.path.join(os.path.dirname(quelle), "programm_dunkel.ico")
try:
from PIL import Image
bild = _fuer_dunkel(Image.open(quelle).convert("RGBA"))
groessen = [(g, g) for g in (16, 24, 32, 48, 64, 128, 256)]
try:
bild.save(ziel, sizes=groessen)
except OSError:
import tempfile
ziel = os.path.join(tempfile.gettempdir(), "programm_dunkel.ico")
bild.save(ziel, sizes=groessen)
roh = Image.open(quelle).convert("RGBA")
# Dieselben Farben wie IM Programm: Tuerkis fuer die Wolke, Dunkelgrau
# fuer das Zeichen daneben. Vorher stand hier das rohe Logo mit seinem
# Petrol #004860 - sein Befund am 07.09.2026: "das schaut blau aus".
# Die Kachel ist weiss, also gilt die HELLE Fassung (dunkel=False).
zeichen = _umfaerben(roh, dunkel=False)
bilder = []
for g in ICO_GROESSEN:
bilder.append(kachel(zeichen, g, rand=0.10 if g <= 24 else 0.16))
# Pillow schreibt die uebrigen Groessen selbst aus dem groessten Bild -
# deshalb wird das groesste uebergeben und der Rest angehaengt.
bilder[-1].save(ziel, format="ICO",
sizes=[(g, g) for g in ICO_GROESSEN],
append_images=bilder[:-1])
return ziel
except Exception: # noqa: BLE001 - ohne helles Symbol laeuft es weiter
except Exception as e: # noqa: BLE001 - ohne eigenes Symbol laeuft es weiter
print("[symbol] konnte nicht gebaut werden:", e)
return ""
def symbol_setzen(fenster, melden=print, dunkel=False):
def symbol_setzen(fenster, melden=print):
"""Das Programmsymbol auf ein Fenster legen. True bei Erfolg.
Die EXE traegt es ohnehin - das Fenster aber nicht von selbst. Ohne das
steht in der Titelleiste das nackte Tk-Symbol, obwohl die Datei ein eigenes
hat.
In der dunklen Darstellung wird die aufgehellte Fassung genommen, sonst
verschwindet die dunkle Haelfte des Zeichens in der dunklen Titelleiste.
Frueher gab es dafuer eine zweite, aufgehellte ICO und einen Schalter
`dunkel`. Seit das Symbol eine helle Kachel ist, ist es in jeder
Darstellung sichtbar - beides ist damit weggefallen.
"""
p = (symbol_datei_dunkel() if dunkel else "") or pfad("programm.ico")
p = pfad("programm.ico")
if not p:
melden("[symbol] assets/programm.ico nicht gefunden")
return False
@ -101,39 +149,45 @@ def symbol_setzen(fenster, melden=print, dunkel=False):
return False
def _fuer_dunkel(bild):
"""Das Logo fuer die dunkle Darstellung aufhellen.
# Die Farben des Logos - gemessen, nicht geraten:
# #004860 das dunkle Blaugruen (Wolke, Pfeile)
# #181818 das fast schwarze Grau (Dokument bzw. Paragraphenzeichen)
#
# Seine Ansage am 07.09.2026: *"das dunkel blaue machen wir in unserm tuerkis
# gruen und die andere seite in weiss ... wenn es dunkel ist, und beim hellen
# machen wir weiss in dunkel grau"*. Das Zeichen traegt damit dieselbe Farbe
# wie das Programm - und die zweite Haelfte steht in beiden Darstellungen im
# Kontrast statt unterzugehen.
TUERKIS = (0x06, 0xC6, 0xA4) # die Programmfarbe, wie col_accent
NEUTRAL_DUNKEL = (0xF2, 0xF3, 0xF5) # fast weiss - auf dunklem Grund
NEUTRAL_HELL = (0x33, 0x36, 0x3B) # dunkelgrau - auf hellem Grund
Sein Befund am 07.09.2026: *"wir muessen mal schauen das wir die svg fuer
das dunkle heller machen weil die geht nun unter"*.
Gemessen sind es zwei Farben: das Petrol #004860 (Helligkeit 53) und ein
fast schwarzes #181818 (Helligkeit 24). Der zweite Wert liegt genau auf der
Kachelfarbe #15171b - deshalb verschwindet die Haelfte des Zeichens.
def _umfaerben(bild, dunkel):
"""Das Logo auf die Programmfarben umsetzen.
Umgestellt wird nur die HELLIGKEIT, Farbton und Saettigung bleiben: sonst
waere es nicht mehr sein Logo, sondern ein anderes. Aus dunkel wird hell,
aus hell dunkel (L -> 1-L), und weil eine reine Umkehr das kraeftige Petrol
fast weiss werden liesse, wird sie auf 0.78 gedeckelt.
Unterschieden wird nach SAETTIGUNG, nicht nach dem genauen Farbwert: das
Blaugruen ist kraeftig gesaettigt, das Grau gar nicht. So bleibt die
Zuordnung auch dann richtig, wenn die Vorlage einmal in einem anderen Ton
gezeichnet wird - und die weichen Kanten aus dem Skalieren gehen denselben
Weg wie die Flaeche, zu der sie gehoeren.
Die Kanten (halbdurchsichtige Punkte aus dem Skalieren) gehen denselben Weg
- deshalb Pixel fuer Pixel und nicht ein Farbtausch mit Toleranz.
Die Deckkraft bleibt unangetastet; die Form kommt aus dem Alphakanal.
"""
import colorsys
bild = bild.convert("RGBA")
neutral = NEUTRAL_DUNKEL if dunkel else NEUTRAL_HELL
punkte = list(bild.getdata())
neu = []
zwischen = {}
neu = []
for r, g, b, a in punkte:
if a == 0:
neu.append((r, g, b, a))
continue
schluessel = (r, g, b)
if schluessel not in zwischen:
h, l, s2 = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
hell = min(1.0 - l, 0.78)
r2, g2, b2 = colorsys.hls_to_rgb(h, hell, s2)
zwischen[schluessel] = (int(r2 * 255), int(g2 * 255), int(b2 * 255))
_, _, saettigung = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
zwischen[schluessel] = TUERKIS if saettigung > 0.25 else neutral
neu.append(zwischen[schluessel] + (a,))
bild.putdata(neu)
return bild
@ -162,8 +216,7 @@ def hole(groesse=240, deckkraft=0.06, master=None, melden=print, dunkel=False):
try:
from PIL import Image, ImageTk
bild = Image.open(p).convert("RGBA").resize((groesse, groesse), Image.LANCZOS)
if dunkel:
bild = _fuer_dunkel(bild)
bild = _umfaerben(bild, dunkel)
bild.putalpha(bild.getchannel("A").point(lambda w: int(w * deckkraft)))
fertig = ImageTk.PhotoImage(bild, master=fenster)
except Exception as e: # noqa: BLE001 - ein Wasserzeichen darf nichts blockieren