rechnungstool/flaeche.py
TheMockTv c1e328c087 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
2026-09-05 19:53:42 +02:00

282 lines
11 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):
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)