rechnungstool/flaeche.py
TheMockTv 42e459ad26 Layout: die Formularseite nimmt nur, was sie braucht
Sein Befund
  "die namen und datums bloecke koennen schmaler werden sind zu breit und
  verdrengen die posten block"

  Gemessen stimmte das genau: die sieben Spalten der Leistungstabelle
  brauchten 743 Pixel, sichtbar waren 618 - "E.-Preis" und "Gesamt" standen
  ausserhalb des Bildes.

Wo die Breite wirklich herkam
  An den Feldbreiten zu drehen half NICHTS. Die Arbeitsflaeche verteilte
  ueber eine uniform-Gruppe streng im Verhaeltnis 4:3; die Formularspalte war
  so breit, weil das Gewicht es vorschrieb, nicht weil der Inhalt es
  brauchte - 433 Pixel, ob die Felder nun 38 oder 22 Zeichen breit waren.

  Jetzt bekommt die Formularseite eine feste Mindestbreite und kein Gewicht,
  die Liste allen uebrigen Platz. Wird das Fenster groesser, waechst allein
  die Tabelle - genau dort nuetzt Breite, waehrend ein 600 Pixel breites Feld
  fuer eine Postleitzahl nichts besser macht. 433 -> 329.

Dazu passend
  - Startbreite 1060 -> 1180 und Mindestbreite 900 -> 1080. Unterhalb von
    1080 passen die sieben Spalten selbst dann nicht nebeneinander, wenn die
    Textspalte auf ihrer Mindestbreite steht.
  - Die Eingabefelder links auf das Mass gekuerzt, das sie wirklich brauchen
    (22 statt 38 Zeichen; Datum 12 statt 18). Das senkt die Mindestbreite der
    Formularseite - erst dadurch ist sie so klein moeglich.
  - "Anreise (TT.MM.JJJJ)" -> "Anreise". Die Form steht jetzt einmal als
    graue Zeile unter dem Block. Als Platzhalter IM Feld waere sie
    gefaehrlich: ein nicht geloeschter Platzhalter stuende als Anreisedatum
    auf der Rechnung.
  - Der Textumbruch der Leistungsspalte folgt jetzt ihrer echten Breite. Fest
    eingetragen schnitt er den Text ab, sobald die Spalte schmaler wurde.

pruef_spaltenbreiten.py
  Prueft ueber die ganze erlaubte Spanne, dass alle Spalten hineinpassen, die
  Formularseite lesbar bleibt und NICHT mitwaechst, und der Umbruch zur
  Spalte passt. Kein anderer Pruefstand haette das gefunden: es stuerzt
  nichts ab und rechnet nichts falsch - man sieht die Preise nur nicht.

  Er hat sofort einen zweiten Fehler gefunden: beim Aendern der
  Fenstergroesse lief der Umbruch nicht hinterher, weil ich abgebrochen
  hatte, sobald sich mein eigener Wert nicht mehr aenderte. Die Spalte kann
  aber breiter werden, ohne dass ich etwas aendere. Abbruch jetzt, wenn zwei
  Messungen dieselbe Spaltenbreite ergeben.

Fassung 1.7.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-06 23:41:24 +02:00

308 lines
13 KiB
Python

# -*- 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):
# Wie breit die beiden Spalten mindestens sein muessen. Die Formularseite
# ist gemessen, nicht geschaetzt: das breiteste, was dort steht, ist die
# Beschriftung "Nachname / Firma *" neben einem 22 Zeichen breiten Feld.
MIN_FORMULAR = 330
MIN_LISTE = 520
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 an festen Zellen 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.
WARUM NICHT MEHR `uniform` (06.09.2026)
---------------------------------------
Vorher standen beide Spalten in einer uniform-Gruppe mit den Gewichten
4 und 3. Das teilt die Breite STRENG in diesem Verhaeltnis auf - bei
1040 Pixeln also 594 zu 446, ganz gleich, was in den Kaesten steht.
Sein Befund: *"die namen und datums bloecke koennen schmaler werden
sind zu breit und verdrengen die posten block"*. Er hatte recht, aber
an den Feldbreiten zu drehen half nichts: die Spalte war so breit, weil
das Gewicht es vorschrieb, nicht weil der Inhalt es brauchte.
Jetzt bekommen die Formularkaesten nur, was sie brauchen (feste
Mindestbreite, kein Gewicht), und der ganze uebrige Platz geht an die
Liste. Wird das Fenster groesser, waechst allein die Tabelle - genau
dort nuetzt die Breite, waehrend ein Adressfeld von 600 Pixeln nichts
besser macht.
"""
for z in range(2):
self.rowconfigure(z, weight=0, minsize=0)
for sp in range(2):
self.columnconfigure(sp, weight=0, uniform="", minsize=0)
lang = self.layout.get("leistungen")
if not lang:
for sp in range(2):
self.columnconfigure(sp, weight=1)
return
spalte, zeile, hoch = lang
for z in range(zeile, zeile + hoch):
self.rowconfigure(z, weight=1)
# Die Liste nimmt allen freien Platz ...
self.columnconfigure(spalte, weight=1, minsize=self.MIN_LISTE)
# ... die Formularseite genau so viel, wie ihr breitestes Feld braucht.
self.columnconfigure(1 - spalte, weight=0, minsize=self.MIN_FORMULAR)
# 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)