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:
parent
5403e01dd5
commit
c1e328c087
10 changed files with 561 additions and 17 deletions
82
app.py
82
app.py
|
|
@ -39,6 +39,8 @@ from bestand import (hoechster_zaehler_im_ordner, naechste_freie_nummer,
|
||||||
vorhandene_rechnungen)
|
vorhandene_rechnungen)
|
||||||
from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn,
|
from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn,
|
||||||
laufende_pid_lesen)
|
laufende_pid_lesen)
|
||||||
|
import flaeche
|
||||||
|
import icons
|
||||||
import reiter
|
import reiter
|
||||||
import theme
|
import theme
|
||||||
from theme import MeldungMixin, ThemeMixin
|
from theme import MeldungMixin, ThemeMixin
|
||||||
|
|
@ -102,9 +104,11 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
||||||
|
|
||||||
self._apply_theme() # Farben nach Windows hell/dunkel
|
self._apply_theme() # Farben nach Windows hell/dunkel
|
||||||
self._build_menu()
|
self._build_menu()
|
||||||
self._build_kopf()
|
|
||||||
self._build_summen() # untere Leiste (Summen + Buttons) zuerst -> bleibt immer sichtbar
|
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._neue_nummer_vorschlagen()
|
||||||
self.recalc()
|
self.recalc()
|
||||||
|
|
@ -211,15 +215,62 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
||||||
pass
|
pass
|
||||||
self._open_pop = None
|
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
|
# ---------------------------------------------------------------- Kopf
|
||||||
def _build_kopf(self):
|
def _build_kopf(self):
|
||||||
top = ttk.Frame(self, padding=(16, 14, 16, 6))
|
# Beide Bloecke sind Kaesten der Arbeitsflaeche - sie lassen sich
|
||||||
top.pack(fill="x")
|
# verschieben, deshalb platziert sie niemand mehr von Hand.
|
||||||
|
kunde = self.flaeche.kasten("kunde", "Rechnung an (Kunde)")
|
||||||
# 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))
|
|
||||||
self.k_anrede = tk.StringVar()
|
self.k_anrede = tk.StringVar()
|
||||||
self.k_vorname = tk.StringVar()
|
self.k_vorname = tk.StringVar()
|
||||||
self.k_name = tk.StringVar() # Nachname oder Firma – die Zuordnung im Steuerjournal
|
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))
|
row=len(felder), column=0, columnspan=2, sticky="w", pady=(6, 0))
|
||||||
kunde.columnconfigure(1, weight=1)
|
kunde.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
# Rechnungsinfos (rechts)
|
info = self.flaeche.kasten("daten", "Rechnungsdaten")
|
||||||
info = ttk.LabelFrame(top, text="RECHNUNGSDATEN", padding=14,
|
|
||||||
style="Karte.TLabelframe")
|
|
||||||
info.grid(row=0, column=1, sticky="nsew")
|
|
||||||
self.r_nummer = tk.StringVar()
|
self.r_nummer = tk.StringVar()
|
||||||
self.r_datum = tk.StringVar(value=datetime.date.today().strftime("%d.%m.%Y"))
|
self.r_datum = tk.StringVar(value=datetime.date.today().strftime("%d.%m.%Y"))
|
||||||
self.r_anreise = tk.StringVar()
|
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_anreise.trace_add("write", lambda *a: self._update_naechte())
|
||||||
self.r_abreise.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):
|
def _auto_datum_evt(self, event):
|
||||||
"""Punkte automatisch setzen, Cursor ans Ende. Nach Tag+Monat das aktuelle
|
"""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
|
# Eigene Reiterleiste statt ttk.Notebook: unter clam liess sich der
|
||||||
# ruhende Reiter nicht dunkel bekommen, er sah aus wie der offene.
|
# ruhende Reiter nicht dunkel bekommen, er sah aus wie der offene.
|
||||||
# Nach aussen verhaelt sie sich wie ein Notebook (siehe reiter.py).
|
# Nach aussen verhaelt sie sich wie ein Notebook (siehe reiter.py).
|
||||||
self.blaetter = reiter.Reiterleiste(self, farben=self)
|
leistungen = self.flaeche.kasten("leistungen", "Leistungen")
|
||||||
self.blaetter.pack(side="top", fill="both", expand=True, padx=16, pady=(4, 6))
|
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())
|
self.blaetter.bind("<<NotebookTabChanged>>", lambda _e: self._blatt_gewechselt())
|
||||||
|
|
||||||
camping = ttk.Frame(self.blaetter.bereich, padding=(2, 6))
|
camping = ttk.Frame(self.blaetter.bereich, padding=(2, 6))
|
||||||
|
|
|
||||||
BIN
assets/icons/check.png
Normal file
BIN
assets/icons/check.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 327 B |
BIN
assets/icons/close.png
Normal file
BIN
assets/icons/close.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 326 B |
BIN
assets/icons/dashboard_customize.png
Normal file
BIN
assets/icons/dashboard_customize.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 372 B |
BIN
assets/icons/drag_indicator.png
Normal file
BIN
assets/icons/drag_indicator.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 522 B |
BIN
assets/icons/save.png
Normal file
BIN
assets/icons/save.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 470 B |
282
flaeche.py
Normal file
282
flaeche.py
Normal 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
62
icons.py
Normal 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
144
pruef_flaeche.py
Normal 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")
|
||||||
8
theme.py
8
theme.py
|
|
@ -167,6 +167,14 @@ class ThemeMixin:
|
||||||
# Ein Abschnitt ist eine Kachel: eigene Flaeche, feiner Rand, die
|
# Ein Abschnitt ist eine Kachel: eigene Flaeche, feiner Rand, die
|
||||||
# Ueberschrift in Akzentfarbe - genau wie die Kacheln in den
|
# Ueberschrift in Akzentfarbe - genau wie die Kacheln in den
|
||||||
# Web-Oberflaechen.
|
# 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,
|
style.configure("Karte.TLabelframe", background=self.col_card,
|
||||||
bordercolor=self.col_line, relief="solid", borderwidth=1,
|
bordercolor=self.col_line, relief="solid", borderwidth=1,
|
||||||
lightcolor=self.col_card, darkcolor=self.col_card)
|
lightcolor=self.col_card, darkcolor=self.col_card)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue