Kaesten verschiebbar wie bei OPNsense: Knopf, schieben, speichern

Seine Ansage: "rechnungsdaten oben links, darunter rechnung an kunde, den
tabellen kasten rechts - oder noch besser dnd einbauen" und dann genauer: "wie
bei opnsense hast so einen kleinen knopf, dann drueckst drauf, ab da kannst du
schieben, dann hast du einen save button, und dann ist die fest".

- Seine Anordnung ist die Vorgabe (flaeche.STANDARD).
- Verschieben muss man EINSCHALTEN. Ohne den Knopf sitzt alles fest, damit beim
  taeglichen Arbeiten nichts verrutscht. Im Modus stehen "Speichern" und
  "Verwerfen" daneben; erst Speichern schreibt in die config.
- Waehrend des Ziehens haengt ein Etikett am Zeiger und der gegriffene Kasten
  wird blass - vorher sah man nur das Ziel und es wirkte "statisch" (sein Fund).
- Getauscht statt frei abgelegt: freies Verschieben auf Pixel braucht Regeln
  fuer Ueberlappung, Groesse und leere Zellen, und am Ende steht das Fenster
  schief. Ein Tausch ist in einem Griff erledigt.
- Der freie Platz folgt dem Kasten mit der langen Liste. Feste Gewichte gingen
  nicht: die Liste wandert, und der Kundenkasten wurde unten abgeschnitten.
- Eine unbrauchbare Anordnung in der config faellt auf die Vorgabe zurueck.

Icons: die Material Symbols vom Hub (M:/projekte/material-symbols), einmal nach
PNG gewandelt und zur Laufzeit eingefaerbt (icons.py) - eine Datei je Icon,
nicht drei Fassungen fuer hell/dunkel/Akzent.

⚠️ Ein PhotoImage gehoert GENAU EINEM Tk-Fenster. Der Icon-Cache reichte Bilder
an das naechste weiter, sobald ein Pruefstand Journal und Rechnungstool
nacheinander faehrt: 'image "pyimage1" doesn't exist'. Das Fenster wird jetzt
mitgegeben, tk._default_root reicht dafuer nicht.

Neu: pruef_flaeche.py. Alle 15 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
This commit is contained in:
TheMockTv 2026-09-05 19:53:42 +02:00
parent 5403e01dd5
commit c1e328c087
10 changed files with 561 additions and 17 deletions

82
app.py
View file

@ -39,6 +39,8 @@ from bestand import (hoechster_zaehler_im_ordner, naechste_freie_nummer,
vorhandene_rechnungen)
from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn,
laufende_pid_lesen)
import flaeche
import icons
import reiter
import theme
from theme import MeldungMixin, ThemeMixin
@ -102,9 +104,11 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
self._apply_theme() # Farben nach Windows hell/dunkel
self._build_menu()
self._build_kopf()
self._build_summen() # untere Leiste (Summen + Buttons) zuerst -> bleibt immer sichtbar
self._build_positionen() # Positionsliste fuellt den Rest und scrollt
self._build_flaeche() # Werkzeugleiste + verschiebbare Kaesten
self._build_kopf() # Rechnungsdaten + Kunde als Kaesten
self._build_positionen() # Leistungen als Kasten, fuellt den Rest
self.flaeche.anordnen()
self._neue_nummer_vorschlagen()
self.recalc()
@ -211,15 +215,62 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
pass
self._open_pop = None
# ------------------------------------------------------- Arbeitsflaeche
def _build_flaeche(self):
"""Die Kaesten und der Knopf, mit dem man sie verschieben darf.
Wie bei OPNsense: erst "Anordnen" druecken, dann schieben, dann
speichern. Ohne diesen Schritt sitzt alles fest - beim taeglichen
Arbeiten soll nichts verrutschen.
"""
leiste = ttk.Frame(self, padding=(16, 8, 16, 0))
leiste.pack(side="top", fill="x")
self.werkzeuge = ttk.Frame(leiste)
self.werkzeuge.pack(side="right")
self._ic_anordnen = icons.hole("dashboard_customize", 16, self.col_muted, master=self)
self._ic_speichern = icons.hole("check", 16, self.col_accent_hell, master=self)
self._ic_verwerfen = icons.hole("close", 16, self.col_muted, master=self)
self.knopf_anordnen = ttk.Button(self.werkzeuge, text=" Anordnen",
image=self._ic_anordnen, compound="left",
command=self._anordnen_umschalten)
self.knopf_anordnen.pack(side="left")
self.knopf_speichern = ttk.Button(self.werkzeuge, text=" Speichern",
image=self._ic_speichern, compound="left",
style="Haupt.TButton",
command=self._anordnen_speichern)
self.knopf_verwerfen = ttk.Button(self.werkzeuge, text=" Verwerfen",
image=self._ic_verwerfen, compound="left",
command=self._anordnen_verwerfen)
self.flaeche = flaeche.Arbeitsflaeche(self, farben=self)
self.flaeche.pack(side="top", fill="both", expand=True, padx=10, pady=(4, 0))
def _anordnen_umschalten(self):
self.flaeche.anordnen_starten()
self.knopf_anordnen.pack_forget()
self.knopf_verwerfen.pack(side="left", padx=(0, 6))
self.knopf_speichern.pack(side="left")
def _anordnen_fertig(self):
self.knopf_speichern.pack_forget()
self.knopf_verwerfen.pack_forget()
self.knopf_anordnen.pack(side="left")
def _anordnen_speichern(self):
self.flaeche.anordnen_speichern()
self._anordnen_fertig()
def _anordnen_verwerfen(self):
self.flaeche.anordnen_verwerfen()
self._anordnen_fertig()
# ---------------------------------------------------------------- Kopf
def _build_kopf(self):
top = ttk.Frame(self, padding=(16, 14, 16, 6))
top.pack(fill="x")
# Kunde (links)
kunde = ttk.LabelFrame(top, text="RECHNUNG AN (KUNDE)", padding=14,
style="Karte.TLabelframe")
kunde.grid(row=0, column=0, sticky="nsew", padx=(0, 14))
# Beide Bloecke sind Kaesten der Arbeitsflaeche - sie lassen sich
# verschieben, deshalb platziert sie niemand mehr von Hand.
kunde = self.flaeche.kasten("kunde", "Rechnung an (Kunde)")
self.k_anrede = tk.StringVar()
self.k_vorname = tk.StringVar()
self.k_name = tk.StringVar() # Nachname oder Firma – die Zuordnung im Steuerjournal
@ -249,10 +300,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
row=len(felder), column=0, columnspan=2, sticky="w", pady=(6, 0))
kunde.columnconfigure(1, weight=1)
# Rechnungsinfos (rechts)
info = ttk.LabelFrame(top, text="RECHNUNGSDATEN", padding=14,
style="Karte.TLabelframe")
info.grid(row=0, column=1, sticky="nsew")
info = self.flaeche.kasten("daten", "Rechnungsdaten")
self.r_nummer = tk.StringVar()
self.r_datum = tk.StringVar(value=datetime.date.today().strftime("%d.%m.%Y"))
self.r_anreise = tk.StringVar()
@ -296,8 +344,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
self.r_anreise.trace_add("write", lambda *a: self._update_naechte())
self.r_abreise.trace_add("write", lambda *a: self._update_naechte())
top.columnconfigure(0, weight=1)
top.columnconfigure(1, weight=0)
def _auto_datum_evt(self, event):
"""Punkte automatisch setzen, Cursor ans Ende. Nach Tag+Monat das aktuelle
@ -378,8 +425,9 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
# Eigene Reiterleiste statt ttk.Notebook: unter clam liess sich der
# ruhende Reiter nicht dunkel bekommen, er sah aus wie der offene.
# Nach aussen verhaelt sie sich wie ein Notebook (siehe reiter.py).
self.blaetter = reiter.Reiterleiste(self, farben=self)
self.blaetter.pack(side="top", fill="both", expand=True, padx=16, pady=(4, 6))
leistungen = self.flaeche.kasten("leistungen", "Leistungen")
self.blaetter = reiter.Reiterleiste(leistungen, farben=self)
self.blaetter.pack(side="top", fill="both", expand=True)
self.blaetter.bind("<<NotebookTabChanged>>", lambda _e: self._blatt_gewechselt())
camping = ttk.Frame(self.blaetter.bereich, padding=(2, 6))

BIN
assets/icons/check.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

BIN
assets/icons/close.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 B

BIN
assets/icons/save.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

282
flaeche.py Normal file
View file

@ -0,0 +1,282 @@
# -*- coding: utf-8 -*-
"""Arbeitsflaeche: die Kaesten des Fensters lassen sich verschieben.
Seine Ansage: *"rechnungsdaten oben links und darunter rechnung an kunde und den
tabellen kasten rechts - oder noch besser dnd einbauen, das man die kaesten
selber schieben kann"*. Beides ist hier drin: die genannte Anordnung ist die
Vorgabe, und wer sie anders haben will, zieht die Kaesten um.
WIE ES FUNKTIONIERT
-------------------
Wie bei OPNsense: normalerweise sitzt alles fest. Ein kleiner Knopf oben rechts
schaltet das ANORDNEN ein - dann zeigen die Kaesten einen Griff, lassen sich
ziehen, und daneben stehen "Speichern" und "Verwerfen". Erst mit Speichern
steht die neue Anordnung; Verwerfen holt die alte zurueck.
So kann beim taeglichen Arbeiten nichts verrutschen - man muss das Verschieben
erst absichtlich einschalten.
Die Flaeche ist ein Raster aus zwei Spalten. Jeder Kasten sitzt in einer Zelle
und kann sich ueber beide Zeilen strecken. Gezogen wird am KOPF des Kastens;
der Kasten unter dem Zeiger wird hervorgehoben, beim Loslassen tauschen die
beiden ihre Plaetze.
Getauscht wird, nicht "irgendwo abgelegt": ein freies Verschieben auf Pixel
braucht Regeln fuer Ueberlappung, Groesse und leere Zellen - und am Ende steht
das Fenster schief. Ein Tausch ist in einem Griff erledigt und kann nichts
kaputt machen.
Die Anordnung steht in der config (`fenster_layout`) und ueberlebt den Neustart.
"""
import tkinter as tk
from tkinter import ttk
import config
# Vorgabe: seine Anordnung. Je Kasten (Spalte, Zeile, Zeilen hoch).
STANDARD = {
"daten": (0, 0, 1),
"kunde": (0, 1, 1),
"leistungen": (1, 0, 2),
}
class Arbeitsflaeche(ttk.Frame):
def __init__(self, master, farben=None, beim_umbauen=None):
super().__init__(master)
self.f = farben if farben is not None else master
self.beim_umbauen = beim_umbauen # wird nach jedem Tausch gerufen
self.kaesten = {} # name -> dict(rahmen, kopf, titel)
self.layout = {}
self._vorher = {} # Stand vor dem Anordnen
self.anordnen_an = False # Verschieben eingeschaltet?
self._zieht = None
self._ziel = None
# Die Gewichte setzt `anordnen()` - sie haengen davon ab, WO die lange
# Liste gerade steht.
# ---- Aufbau -----------------------------------------------------------
def kasten(self, name, titel):
"""Legt einen Kasten an und gibt seinen Inhaltsrahmen zurueck."""
aussen = ttk.Frame(self, style="Karte.TFrame")
kopf = tk.Label(aussen, text=titel.upper(), anchor="w", cursor="fleur",
bg=self.f.col_card, fg=self.f.col_accent,
font=(self._schrift(), 9, "bold"), padx=12, pady=8)
kopf.pack(side="top", fill="x")
inhalt = ttk.Frame(aussen, style="Karte.TFrame", padding=(12, 0, 12, 12))
inhalt.pack(side="top", fill="both", expand=True)
kopf.bind("<Button-1>", lambda e, n=name: self._greifen(n))
kopf.bind("<B1-Motion>", lambda e, n=name: self._ziehen(e, n))
kopf.bind("<ButtonRelease-1>", lambda e, n=name: self._loslassen(n))
self.kaesten[name] = {"aussen": aussen, "kopf": kopf, "inhalt": inhalt,
"titel": titel}
return inhalt
def anordnen(self, layout=None):
"""Kaesten ins Raster setzen. Ohne Angabe: was in der config steht."""
self.layout = dict(layout or self._gespeichert())
for name, kasten in self.kaesten.items():
spalte, zeile, hoch = self.layout.get(name, STANDARD.get(name, (0, 0, 1)))
kasten["aussen"].grid(row=zeile, column=spalte, rowspan=hoch,
sticky="nsew", padx=6, pady=6)
self._platz_verteilen()
def _platz_verteilen(self):
"""Der freie Platz gehoert dem Kasten mit der langen Liste.
Feste Gewichte gehen nicht: die Liste wandert beim Verschieben, und wer
das Gewicht an einer festen Zelle laesst, schneidet danach den
Kundenkasten unten ab - genau das ist beim ersten Versuch passiert.
"""
for z in range(2):
self.rowconfigure(z, weight=0, minsize=0)
for sp in range(2):
self.columnconfigure(sp, weight=1, uniform="spalten")
lang = self.layout.get("leistungen")
if not lang:
return
spalte, zeile, hoch = lang
for z in range(zeile, zeile + hoch):
self.rowconfigure(z, weight=1)
# Die Spalte mit der Liste bekommt mehr Breite als die andere.
self.columnconfigure(spalte, weight=4, uniform="spalten")
self.columnconfigure(1 - spalte, weight=3, uniform="spalten")
# Steht die Liste nur in EINER Zeile, braucht die andere trotzdem Luft
# fuer ihren Inhalt - sonst wird sie zusammengequetscht.
if hoch == 1:
self.rowconfigure(1 - zeile, weight=1, minsize=170)
def zuruecksetzen(self):
self.anordnen(STANDARD)
self._speichern()
self._koepfe_zeigen()
# ---- Ziehen -----------------------------------------------------------
# ---- Anordnen ein- und ausschalten ------------------------------------
def anordnen_starten(self):
self._vorher = dict(self.layout)
self.anordnen_an = True
self._koepfe_zeigen()
def anordnen_speichern(self):
self.anordnen_an = False
self._speichern()
self._koepfe_zeigen()
def anordnen_verwerfen(self):
self.anordnen_an = False
if self._vorher:
self.anordnen(self._vorher)
self._koepfe_zeigen()
def _koepfe_zeigen(self):
"""Im Anordnen-Modus tragen die Koepfe einen Griff und einen Zeiger."""
import icons
for kasten in self.kaesten.values():
kopf = kasten["kopf"]
if self.anordnen_an:
bild = icons.hole("drag_indicator", 16, self.f.col_accent, master=self)
kopf.configure(image=bild, compound="left", cursor="fleur",
bg=self.f.col_sel)
kopf.bild = bild # sonst raeumt Python das Bild weg
else:
kopf.configure(image="", compound="none", cursor="",
bg=self.f.col_card)
kopf.bild = None
def _greifen(self, name):
if not self.anordnen_an:
return
self._zieht = name
self.kaesten[name]["kopf"].configure(bg=self.f.col_sel)
# Der gegriffene Kasten wird blass - man sieht, WAS man in der Hand hat.
self.kaesten[name]["aussen"].configure(style="Gegriffen.TFrame")
self._etikett_zeigen(name)
def _ziehen(self, ereignis, name):
if self._zieht is None or not self.anordnen_an:
return
self._etikett_bewegen()
ziel = self._kasten_unter(ereignis)
if ziel == self._ziel:
return
self._marke(self._ziel, False)
self._ziel = ziel if ziel != name else None
self._marke(self._ziel, True)
def _loslassen(self, name):
if self._zieht is None:
return
self._etikett_weg()
self.kaesten[name]["aussen"].configure(style="Karte.TFrame")
self.kaesten[name]["kopf"].configure(bg=self.f.col_sel)
self._marke(self._ziel, False)
ziel, self._zieht, self._ziel = self._ziel, None, None
if ziel and ziel != name:
# Getauscht wird sofort sichtbar - gespeichert erst mit dem Knopf.
self.layout[name], self.layout[ziel] = self.layout[ziel], self.layout[name]
self.anordnen(self.layout)
self._koepfe_zeigen()
if self.beim_umbauen:
self.beim_umbauen()
# ---- Das Etikett am Zeiger -------------------------------------------
def _etikett_zeigen(self, name):
"""Ein kleines Schild haengt am Zeiger, solange gezogen wird.
Ohne das sieht man nur, WOHIN es geht, aber nicht, dass man ueberhaupt
etwas in der Hand hat - es wirkte "statisch". Ein Widget selbst kann in
Tk nicht mitwandern; ein randloses Fensterchen kann es.
"""
import icons
self._etikett_weg()
try:
schild = tk.Toplevel(self)
schild.overrideredirect(True)
schild.attributes("-topmost", True)
try:
schild.attributes("-alpha", 0.85)
except tk.TclError:
pass
rahmen = tk.Frame(schild, bg=self.f.col_accent, padx=1, pady=1)
rahmen.pack()
bild = icons.hole("drag_indicator", 16, self.f.col_accent, master=self)
inhalt = tk.Label(rahmen, text=" " + self.kaesten[name]["titel"],
image=bild, compound="left",
bg=self.f.col_card, fg=self.f.col_accent,
font=(self._schrift(), 10, "bold"), padx=12, pady=8)
inhalt.bild = bild
inhalt.pack()
self._etikett = schild
self._etikett_bewegen()
except tk.TclError as e:
print("[flaeche] Etikett nicht moeglich:", e)
self._etikett = None
def _etikett_bewegen(self):
schild = getattr(self, "_etikett", None)
if schild is None:
return
try:
schild.geometry(f"+{self.winfo_pointerx() + 14}+{self.winfo_pointery() + 14}")
except tk.TclError:
self._etikett = None
def _etikett_weg(self):
schild = getattr(self, "_etikett", None)
if schild is not None:
try:
schild.destroy()
except tk.TclError:
pass
self._etikett = None
def _marke(self, name, an):
if not name:
return
self.kaesten[name]["kopf"].configure(
fg=self.f.col_accent_hell if an else self.f.col_accent)
self.kaesten[name]["aussen"].configure(
style="Ziel.TFrame" if an else "Karte.TFrame")
def _kasten_unter(self, ereignis):
"""Welcher Kasten liegt unter dem Zeiger?"""
x = self.winfo_pointerx() - self.winfo_rootx()
y = self.winfo_pointery() - self.winfo_rooty()
for name, kasten in self.kaesten.items():
w = kasten["aussen"]
lx, ly = w.winfo_x(), w.winfo_y()
if lx <= x <= lx + w.winfo_width() and ly <= y <= ly + w.winfo_height():
return name
return None
# ---- Merken -----------------------------------------------------------
def _schrift(self):
import theme
return theme.SCHRIFT_NAME
def _gespeichert(self):
cfg = getattr(self.f, "cfg", {}) or {}
roh = cfg.get("fenster_layout") or {}
aus = {}
for name in self.kaesten:
wert = roh.get(name)
# Nur uebernehmen, was auch wirklich passt - eine von Hand
# verstellte config darf das Fenster nicht zerlegen.
if (isinstance(wert, (list, tuple)) and len(wert) == 3
and all(isinstance(z, int) for z in wert)):
aus[name] = tuple(wert)
else:
aus[name] = STANDARD.get(name, (0, 0, 1))
return aus
def _speichern(self):
cfg = getattr(self.f, "cfg", None)
if cfg is None:
return
cfg["fenster_layout"] = {n: list(v) for n, v in self.layout.items()}
try:
config.save_config(cfg)
except OSError as e:
print("[flaeche] Anordnung nicht gespeichert:", e)

62
icons.py Normal file
View file

@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
"""Icons aus dem Hub-Vorrat (Material Symbols), eingefaerbt zur Laufzeit.
Die SVGs liegen auf dem Hub (`M:/projekte/material-symbols`) und wurden einmal
nach PNG gewandelt - als weisse Silhouette mit Transparenz. Hier bekommen sie
ihre Farbe: dieselbe Datei taugt fuer die Akzentfarbe, fuer gedaempfte Schrift
und fuer das helle Thema, ohne dass jemand drei Fassungen pflegt.
Ohne Pillow oder ohne Datei gibt es einfach kein Bild zurueck - der Aufrufer
zeigt dann seinen Text. Ein fehlendes Icon darf kein Fenster verhindern.
"""
import os
import tkinter as tk
import config
# ⚠️ Ein PhotoImage gehoert zu GENAU EINEM Tk-Fenster. Wird ein zweites
# aufgemacht (ein Pruefstand faehrt Journal und Rechnungstool nacheinander),
# zeigt ein weitergereichtes Bild ins Leere: 'image "pyimage1" doesn't exist'.
# Deshalb steht das Fenster mit im Schluessel.
_zwischenlager = {} # (fenster, name, groesse, farbe) -> PhotoImage
def _pfad(name):
for basis in (config.BASE_DIR, config.RESOURCE_DIR,
os.path.dirname(os.path.abspath(__file__))):
p = os.path.join(basis, "assets", "icons", name + ".png")
if os.path.exists(p):
return p
return ""
def hole(name, groesse=18, farbe="#e8e8ea", master=None):
"""Icon als PhotoImage - oder None, wenn es nicht geht.
`master` ist das Widget, in dem das Bild landet. Es MUSS mitgegeben werden,
wenn mehr als ein Tk-Fenster laeuft: `tk._default_root` zeigt dann auf das
zuerst geoeffnete, und das Bild gehoert dem falschen Fenster.
"""
fenster = master if master is not None else tk._default_root
if fenster is None:
return None
schluessel = (id(fenster), name, groesse, farbe)
if schluessel in _zwischenlager:
return _zwischenlager[schluessel]
pfad = _pfad(name)
if not pfad:
print(f"[icons] nicht gefunden: {name}")
return None
try:
from PIL import Image, ImageTk
bild = Image.open(pfad).convert("RGBA")
bild = bild.resize((groesse, groesse), Image.LANCZOS)
# Die Silhouette steckt im Alphakanal - die Farbe kommt hier drueber.
flaeche = Image.new("RGBA", bild.size, farbe)
flaeche.putalpha(bild.getchannel("A"))
fertig = ImageTk.PhotoImage(flaeche, master=fenster)
except Exception as e: # noqa: BLE001 - ein Icon darf nichts blockieren
print(f"[icons] {name} nicht ladbar: {e}")
return None
_zwischenlager[schluessel] = fertig
return fertig

144
pruef_flaeche.py Normal file
View file

@ -0,0 +1,144 @@
# -*- coding: utf-8 -*-
"""Pruefstand: die verschiebbaren Kaesten (Arbeitsflaeche).
Seine Ansage: *"wie bei opnsense hast so einen kleinen knopf, dann drueckst
drauf, ab da kannst du schieben, dann hast du einen save button - und dann ist
die fest"*.
Geprueft wird genau diese Reihenfolge:
1. Solange nicht "Anordnen" gedrueckt ist, laesst sich NICHTS verschieben -
beim taeglichen Arbeiten darf nichts verrutschen.
2. Im Anordnen-Modus haengt ein Etikett am Zeiger (man soll sehen, dass man
etwas in der Hand hat) und zwei Kaesten tauschen ihre Plaetze.
3. "Verwerfen" holt die Anordnung von vorher zurueck.
4. "Speichern" schreibt sie in die config - erst dann steht sie fest.
5. Der freie Platz gehoert immer dem Kasten mit der langen Liste, egal wo er
gerade sitzt. Sonst wird der Kundenkasten unten abgeschnitten.
"""
import json
import os
import shutil
import sys
import tempfile
import threading
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
os.environ["CAMPINGHOF_TEST"] = "1"
tmp = tempfile.mkdtemp(prefix="flaeche_pruef_")
ordner = os.path.join(tmp, "rechnungen")
os.makedirs(ordner)
import config # noqa: E402
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
config.BASE_DIR = tmp
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["output_dir"] = ordner
roh["nummer_eingerichtet"] = True
roh.pop("fenster_layout", None) # ohne gespeicherte Anordnung starten
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False)
import app as A # noqa: E402
import flaeche as FL # noqa: E402
from tkinter import messagebox # noqa: E402
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
os._exit(2)
_wecker = threading.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
k = sys.stdout.encoding or "utf-8"
print(zeile.encode(k, "replace").decode(k))
if not bedingung:
fehler.append(text)
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None
messagebox.askyesno = lambda *a, **k: True
f = A.RechnungsApp()
f.update()
fl = f.flaeche
# ---- 1. Die Vorgabe ist seine Anordnung ----------------------------------
pruefe(fl.layout == FL.STANDARD,
f"Vorgabe: Rechnungsdaten oben links, Kunde darunter, Leistungen rechts ({fl.layout})")
# ---- 2. Ohne den Knopf laesst sich nichts schieben -----------------------
pruefe(not fl.anordnen_an, "beim Start ist das Verschieben AUS")
fl._greifen("kunde")
pruefe(fl._zieht is None, "ohne 'Anordnen' greift ein Zug ins Leere")
vorgabe = dict(fl.layout)
# ---- 3. Anordnen an: Etikett und Tausch ----------------------------------
f._anordnen_umschalten()
pruefe(fl.anordnen_an, "nach dem Knopf ist das Verschieben AN")
fl._greifen("kunde")
pruefe(fl._zieht == "kunde", "jetzt haelt man den Kasten")
pruefe(getattr(fl, "_etikett", None) is not None,
"und am Zeiger haengt ein Etikett - man SIEHT, dass man zieht")
fl._ziel = "daten"
fl._loslassen("kunde")
pruefe(getattr(fl, "_etikett", None) is None, "nach dem Loslassen ist das Etikett weg")
pruefe(fl.layout["kunde"] == vorgabe["daten"] and fl.layout["daten"] == vorgabe["kunde"],
f"die beiden haben die Plaetze getauscht ({fl.layout})")
# Gespeichert ist noch NICHTS - erst der Knopf schreibt.
in_datei = json.load(open(config.CONFIG_PATH, encoding="utf-8")).get("fenster_layout")
pruefe(in_datei is None, f"vor dem Speichern steht nichts in der config ({in_datei})")
# ---- 4. Verwerfen holt die alte Anordnung zurueck ------------------------
f._anordnen_verwerfen()
pruefe(fl.layout == vorgabe, f"Verwerfen stellt die Anordnung wieder her ({fl.layout})")
pruefe(not fl.anordnen_an, "und schaltet das Verschieben wieder aus")
# ---- 5. Speichern macht es fest ------------------------------------------
f._anordnen_umschalten()
fl._greifen("daten")
fl._ziel = "leistungen"
fl._loslassen("daten")
neu = dict(fl.layout)
f._anordnen_speichern()
in_datei = json.load(open(config.CONFIG_PATH, encoding="utf-8")).get("fenster_layout")
pruefe(in_datei == {n: list(v) for n, v in neu.items()},
f"Speichern schreibt die Anordnung in die config ({in_datei})")
pruefe(not fl.anordnen_an, "und schaltet das Verschieben aus")
# ---- 6. Der Platz folgt der langen Liste ---------------------------------
spalte, zeile, hoch = fl.layout["leistungen"]
gewicht_liste = fl.grid_rowconfigure(zeile)["weight"]
pruefe(gewicht_liste > 0,
f"die Zeile mit den Leistungen darf wachsen (Gewicht {gewicht_liste})")
breite_liste = fl.grid_columnconfigure(spalte)["weight"]
breite_andere = fl.grid_columnconfigure(1 - spalte)["weight"]
pruefe(breite_liste > breite_andere,
f"und ihre Spalte ist breiter ({breite_liste} zu {breite_andere})")
# Eine kaputte Anordnung in der config darf das Fenster nicht zerlegen.
f.cfg["fenster_layout"] = {"kunde": "kaputt", "daten": [0], "leistungen": None}
fl.anordnen()
pruefe(fl.layout == FL.STANDARD,
f"eine unbrauchbare Anordnung faellt auf die Vorgabe zurueck ({fl.layout})")
f.destroy()
print()
if fehler:
print(f"FEHLGESCHLAGEN: {len(fehler)}")
for x in fehler:
print(" -", x)
sys.exit(1)
shutil.rmtree(tmp, ignore_errors=True)
print("ALLES GRUEN")

View file

@ -167,6 +167,14 @@ class ThemeMixin:
# Ein Abschnitt ist eine Kachel: eigene Flaeche, feiner Rand, die
# Ueberschrift in Akzentfarbe - genau wie die Kacheln in den
# Web-Oberflaechen.
# Der Kasten, den man gerade in der Hand hat: blass, gestrichelt.
style.configure("Gegriffen.TFrame", background=self.col_bg,
bordercolor=self.col_muted, relief="solid", borderwidth=1,
lightcolor=self.col_muted, darkcolor=self.col_muted)
# Der Kasten, auf den beim Anordnen gerade gezielt wird.
style.configure("Ziel.TFrame", background=self.col_card,
bordercolor=accent, relief="solid", borderwidth=2,
lightcolor=accent, darkcolor=accent)
style.configure("Karte.TLabelframe", background=self.col_card,
bordercolor=self.col_line, relief="solid", borderwidth=1,
lightcolor=self.col_card, darkcolor=self.col_card)