Files
provisionsmanager95/provtool/gui/rahmen.py
T
adminandClaude Opus 5.5 dcb1d0378c Eigene Kennungen ausblenden, Win95-Menüs, verschiebbares Hauptfenster bei offenem Dialog u. a.
Neu:
- Eigene Kennungen (eigene Filiale) ausblenden: nicht angezeigt, nicht mitgerechnet, nicht
  exportiert; per Rechtsklick, Händler-Menü oder Import-Bericht; Händler-Zuordnung hat Vorrang
- Menüs im Win95-Stil: wechseln beim Überfahren der Menüleiste, Tastatur, Kontextmenü gleich
- Dialoge sperren nur den Inhalt: Hauptfenster bleibt verschiebbar/maximierbar; Dialoge haben
  eigenen Taskleisten-Eintrag; Klick auf gesperrten Inhalt holt den Dialog nach vorn (blinkt)
- Trennlinie zwischen Händler- und Positionsliste sichtbar und ziehbar, Position wird gemerkt
- Strg+Rücktaste / Strg+Entf löschen ganze Wörter in allen Eingabefeldern

Behoben:
- Gedrückte Knöpfe zeigten keine Beschriftung (activeForeground gehört in Tk zur Klasse Background)
- Tipp des Tages: Text lief aus dem sichtbaren Bereich
- Größenänderung: breiterer Griffbereich mit Ecken, Zeiger wird zuverlässig zurückgesetzt,
  flüssiger; Verschieben über Windows selbst (inkl. Andocken), Minimieren über Windows
- Zeichen, die MS Sans Serif nicht hat (…, ▸, →, •), durch Win95-übliche ersetzt
- Menü/Tooltip/Dialog: Größe+Position zusammen setzen (Tk ignoriert sonst die Position)
- Suche reagiert erst nach kurzer Tipp-Pause (flüssigeres Tippen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:58:18 +01:00

461 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Selbst gezeichneter Windows-95-Fensterrahmen.
Windows erlaubt Tk nicht, die echte Titelleiste umzufärben. Deshalb wird sie ausgeblendet
(overrideredirect) und hier nachgebaut: blaue Titelleiste mit Icon und _ □ x, Verschieben,
Doppelklick = Maximieren, Größenänderung über den Rand und den Anfasser unten rechts,
graue Menüleiste. Unter Windows sorgen ein paar Win32-Aufrufe dafür, dass das Fenster
trotzdem in der Taskleiste erscheint, per Alt+Tab erreichbar ist und minimiert werden kann.
"""
from __future__ import annotations
import sys
import tkinter as tk
from provtool.gui import win95 as w
IS_WIN = sys.platform.startswith("win")
# --------------------------------------------------------------------------- Glyphen
GLYPHEN = {
"min": """
........
........
........
........
........
........
kkkkkk..
kkkkkk..""",
"max": """
kkkkkkkkk
kkkkkkkkk
k.......k
k.......k
k.......k
k.......k
k.......k
kkkkkkkkk""",
"restore": """
..kkkkkk
..kkkkkk
..k....k
kkkkkk.k
kkkkkk.k
k....kkk
k....k..
kkkkkk..""",
"close": """
kk....kk
.kk..kk.
..kkkk..
...kk...
..kkkk..
.kk..kk.
kk....kk""",
}
def glyph(master, name: str) -> tk.PhotoImage:
rows = [r.strip() for r in GLYPHEN[name].strip("\n").splitlines() if r.strip()]
breite = max(len(r) for r in rows)
img = tk.PhotoImage(master=master, width=breite, height=len(rows))
for y, row in enumerate(rows):
for x, ch in enumerate(row):
if ch != ".":
img.put(w.PALETTE[ch], (x, y))
return img
def griff_bild(master, groesse: int = 12) -> tk.PhotoImage:
"""Der typische schraffierte Größen-Anfasser unten rechts."""
img = tk.PhotoImage(master=master, width=groesse, height=groesse)
for y in range(groesse):
for x in range(groesse):
d = x + y - (groesse - 1)
if d >= 0:
farbe = (w.HILITE, w.SHADOW, w.SHADOW, None)[d % 4]
if farbe:
img.put(farbe, (x, y))
return img
# --------------------------------------------------------------------------- Win32-Hilfen
def _hwnd(fenster: tk.Misc) -> int:
import ctypes
return ctypes.windll.user32.GetParent(fenster.winfo_id())
def taskleiste_anzeigen(fenster: tk.Tk) -> None:
"""Ein overrideredirect-Fenster erscheint unter Windows sonst nicht in Taskleiste/Alt+Tab."""
if not IS_WIN:
return
try:
import ctypes
GWL_EXSTYLE, WS_EX_APPWINDOW, WS_EX_TOOLWINDOW = -20, 0x00040000, 0x00000080
hwnd = _hwnd(fenster)
stil = ctypes.windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
stil = (stil & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW
ctypes.windll.user32.SetWindowLongW(hwnd, GWL_EXSTYLE, stil)
fenster.wm_withdraw()
fenster.after(10, lambda: (fenster.wm_deiconify(), fenster.focus_force()))
except Exception:
pass
def arbeitsbereich(fenster: tk.Misc) -> tuple[int, int, int, int]:
"""Bildschirmbereich ohne Taskleiste (des Monitors, auf dem das Fenster steht): x, y, breite, höhe."""
if IS_WIN:
try:
import ctypes
from ctypes import wintypes
class MONITORINFO(ctypes.Structure):
_fields_ = [("cbSize", wintypes.DWORD), ("rcMonitor", wintypes.RECT),
("rcWork", wintypes.RECT), ("dwFlags", wintypes.DWORD)]
mon = ctypes.windll.user32.MonitorFromWindow(_hwnd(fenster), 2) # MONITOR_DEFAULTTONEAREST
mi = MONITORINFO()
mi.cbSize = ctypes.sizeof(MONITORINFO)
if ctypes.windll.user32.GetMonitorInfoW(mon, ctypes.byref(mi)):
r = mi.rcWork
return r.left, r.top, r.right - r.left, r.bottom - r.top
except Exception:
pass
return 0, 0, fenster.winfo_screenwidth(), fenster.winfo_screenheight()
# --------------------------------------------------------------------------- Bausteine
class TitelKnopf(tk.Label):
def __init__(self, master, bild: tk.PhotoImage, command):
super().__init__(master, image=bild, bg=w.FACE, bd=2, relief="raised", width=14, height=12)
self.command = command
self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken"))
self.bind("<ButtonRelease-1>", self._los)
def _los(self, e):
self.configure(relief="raised")
if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height():
self.command()
class Titelleiste(tk.Frame):
"""Blaue Win95-Titelleiste. knoepfe: Teilmenge von ("min", "max", "close")."""
def __init__(self, master, fenster: tk.Misc, titel: str, icon: tk.PhotoImage | None, knoepfe=("close",),
schliessen=None, minimieren=None, maximieren=None):
super().__init__(master, bg=w.NAVY, height=20)
self.fenster = fenster
self._bilder = {n: glyph(self, n) for n in ("min", "max", "restore", "close")}
if icon is not None:
tk.Label(self, image=icon, bg=w.NAVY).pack(side="left", padx=(2, 0))
self.titel_var = tk.StringVar(value=titel)
self.titel_lbl = tk.Label(self, textvariable=self.titel_var, bg=w.NAVY, fg=w.WHITE, font=w.FONT_BOLD,
anchor="w")
self.titel_lbl.pack(side="left", fill="x", expand=True, padx=3)
self.knoepfe: dict[str, TitelKnopf] = {}
if "close" in knoepfe:
self.knoepfe["close"] = TitelKnopf(self, self._bilder["close"], schliessen)
self.knoepfe["close"].pack(side="right", padx=(2, 2), pady=2)
if "max" in knoepfe:
self.knoepfe["max"] = TitelKnopf(self, self._bilder["max"], maximieren)
self.knoepfe["max"].pack(side="right", pady=2)
if "min" in knoepfe:
self.knoepfe["min"] = TitelKnopf(self, self._bilder["min"], minimieren)
self.knoepfe["min"].pack(side="right", pady=2)
self._maximieren = maximieren
for widget in (self, self.titel_lbl):
widget.bind("<ButtonPress-1>", self._start)
widget.bind("<B1-Motion>", self._ziehen)
if maximieren:
widget.bind("<Double-Button-1>", lambda e: maximieren())
self._ab = (0, 0)
self.ziehen_erlaubt = lambda: True
def set_titel(self, t: str):
self.titel_var.set(t)
def max_symbol(self, maximiert: bool):
if "max" in self.knoepfe:
self.knoepfe["max"].configure(image=self._bilder["restore" if maximiert else "max"])
def _start(self, e):
self._ab = (e.x_root - self.fenster.winfo_rootx(), e.y_root - self.fenster.winfo_rooty())
self._nativ = False
if NATIV_VERSCHIEBEN and IS_WIN and self.ziehen_erlaubt():
# Windows übernimmt das Ziehen: flüssig und mit Andocken am Bildschirmrand
from provtool.gui import win32
self._nativ = win32.nativ_verschieben(self.fenster)
if self._nativ:
return "break"
def _ziehen(self, e):
if self._nativ or not self.ziehen_erlaubt():
return
self.fenster.geometry(f"+{e.x_root - self._ab[0]}+{e.y_root - self._ab[1]}")
def blinken(self, mal: int = 3):
"""Titelleiste kurz grau/blau blinken lassen (wie Windows bei gesperrtem Fenster)."""
farben = [w.SHADOW, w.NAVY] * mal
def schritt(i=0):
if i >= len(farben) or not self.winfo_exists():
return
for widget in (self, self.titel_lbl):
widget.configure(bg=farben[i])
self.after(70, lambda: schritt(i + 1))
schritt()
NATIV_VERSCHIEBEN = True # Tests können das abschalten, um den Tk-Weg zu prüfen
# --------------------------------------------------------------------------- Hauptfenster
class Hauptrahmen:
"""Umgibt das Hauptfenster. Im klassischen Modus mit eigener Titelleiste, sonst nur ein Container."""
RAND = 6 # Pixel am Rand, an denen man die Größe ziehen kann (= sichtbarer Rahmen)
ECKE = 18 # so weit vom Eck entfernt gilt der Rand noch als Ecke (diagonal ziehen)
def __init__(self, root: tk.Tk, titel: str, icon: tk.PhotoImage, schliessen, klassisch: bool = True,
minbreite: int = 900, minhoehe: int = 520):
self.root, self.klassisch = root, klassisch
self.minbreite, self.minhoehe = minbreite, minhoehe
self.maximiert = False
self._normal_geo: str | None = None
self._schliessen = schliessen
root.minsize(minbreite, minhoehe)
if not klassisch:
self.aussen = tk.Frame(root, bg=w.FACE)
self.aussen.pack(fill="both", expand=True)
self.inhalt = self.aussen
self.titelleiste = None
root.sperrbereich = lambda: [self.inhalt] # type: ignore[attr-defined] # für modal.py
return
root.overrideredirect(True)
self.aussen = tk.Frame(root, bg=w.FACE, bd=2, relief="raised", padx=self.RAND - 2, pady=self.RAND - 2)
self.aussen.pack(fill="both", expand=True)
self.titelleiste = Titelleiste(self.aussen, root, titel, icon, ("min", "max", "close"),
schliessen=schliessen, minimieren=self.minimieren,
maximieren=self.umschalten)
self.titelleiste.pack(fill="x")
self.titelleiste.ziehen_erlaubt = lambda: not self.maximiert
self.inhalt = tk.Frame(self.aussen, bg=w.FACE)
self.inhalt.pack(fill="both", expand=True)
# Gesperrt wird bei offenem Dialog nur der Inhalt – Titelleiste und Rand bleiben bedienbar
root.sperrbereich = lambda: [self.inhalt] # type: ignore[attr-defined]
self._rand: str = ""
self._resize_geplant = None
self.aussen.bind("<Motion>", self._rand_cursor)
# Mauszeiger zurücksetzen, sobald der Rand verlassen wird – Kindfenster erben sonst den
# Größenänderungs-Zeiger, wenn das letzte Motion-Ereignis am Rand verpasst wurde.
self.aussen.bind("<Leave>", self._cursor_zurueck)
self.inhalt.bind("<Enter>", self._cursor_zurueck)
self.titelleiste.bind("<Enter>", self._cursor_zurueck, add="+")
self.aussen.bind("<ButtonPress-1>", self._resize_start)
self.aussen.bind("<B1-Motion>", self._resize)
root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget))
root.after(50, lambda: taskleiste_anzeigen(root))
def alt_f4(self, widget) -> str:
"""Alt+F4 wie unter Windows: schließt nur das Fenster, in dem man gerade ist."""
try:
# Ist ein modaler Dialog offen, gilt Alt+F4 immer ihm – egal wo der Tastaturfokus liegt
# (unter Windows liefert Tk Tastendrücke an das Fenster mit Fokus, das kann noch das
# Hauptfenster sein).
from provtool.gui import modal
sperre = self.root.grab_current() or modal.oberstes()
popup = self._offenes_popup()
if sperre is not None and sperre.winfo_toplevel() is not self.root:
fenster = sperre.winfo_toplevel()
elif popup is not None: # Sperre (noch) nicht gesetzt – trotzdem gilt Alt+F4 dem Popup
fenster = popup
else:
fenster = widget.winfo_toplevel() if isinstance(widget, tk.Misc) else self.root
except tk.TclError:
fenster = self.root
if fenster is self.root:
from provtool.gui import modal
offen = modal.oberstes()
if offen is not None: # Alt+F4 bei offenem Dialog schließt den Dialog
offen.abbrechen()
return "break"
self._schliessen()
elif hasattr(fenster, "abbrechen"):
fenster.abbrechen()
else:
fenster.destroy()
return "break"
def _offenes_popup(self):
"""Innerstes sichtbares Popup mit abbrechen() (Dialog, Rückfrage, Rundgang-Sprechblase)."""
gefunden = []
def suche(widget):
for kind in widget.winfo_children():
# Fensterstatus statt winfo_ismapped(): direkt nach dem Einblenden ist der Status schon
# „normal“, gezeichnet ist das Fenster aber evtl. noch nicht (langsamer PC, Wine).
if isinstance(kind, tk.Toplevel) and hasattr(kind, "abbrechen"):
try:
offen = kind.winfo_exists() and kind.wm_state() != "withdrawn"
except tk.TclError:
offen = False
if offen:
gefunden.append(kind)
suche(kind)
suche(self.root)
return gefunden[-1] if gefunden else None
# ---- öffentliche Helfer
def set_titel(self, text: str):
self.root.title(text)
if self.titelleiste:
self.titelleiste.set_titel(text)
def griff(self, master) -> tk.Label | None:
"""Anfasser für die Statusleiste (nur im klassischen Modus)."""
if not self.klassisch:
return None
self._griff_bild = griff_bild(master)
g = tk.Label(master, image=self._griff_bild, bg=w.FACE, cursor="sizing" if not IS_WIN else "size_nw_se")
g.bind("<ButtonPress-1>", lambda e: self._resize_start(e, "se"))
g.bind("<B1-Motion>", self._resize)
return g
def geometrie_setzen(self, geo: str | None, maximiert: bool = False):
self.root.update_idletasks()
if not geo or "+" not in geo:
breite, hoehe = 1200, 720
if geo and "x" in geo:
try:
breite, hoehe = (int(v) for v in geo.split("+")[0].split("x"))
except ValueError:
pass
x0, y0, bw, bh = arbeitsbereich(self.root)
breite, hoehe = min(breite, bw), min(hoehe, bh)
geo = f"{breite}x{hoehe}+{x0 + (bw - breite) // 2}+{y0 + (bh - hoehe) // 2}"
self.root.geometry(geo)
if maximiert and self.klassisch:
self.root.after(100, self.maximieren)
def normale_geometrie(self) -> str:
return self._normal_geo if self.maximiert and self._normal_geo else self.root.geometry()
# ---- Fensterfunktionen
def minimieren(self):
if IS_WIN:
# Direkt über Windows: offene Dialoge (gehören dem Hauptfenster) werden mit aus- und
# wieder eingeblendet. NICHT overrideredirect umschalten – Tk baut dabei das Fenster neu
# und Windows würde die zugehörigen Dialoge zerstören.
from provtool.gui import win32
if win32.minimieren(self.root):
return
# Tk minimiert keine overrideredirect-Fenster -> kurz normalen Rahmen aktivieren
geo = self.root.geometry()
self.root.overrideredirect(False)
self.root.iconify()
def wieder_da(e):
if e.widget is self.root and self.root.state() == "normal":
self.root.unbind("<Map>")
self.root.overrideredirect(True)
self.root.geometry(geo)
taskleiste_anzeigen(self.root)
self.root.bind("<Map>", wieder_da)
def maximieren(self):
if self.maximiert:
return
self._normal_geo = self.root.geometry()
x, y, bw, bh = arbeitsbereich(self.root)
self.root.geometry(f"{bw}x{bh}+{x}+{y}")
self.maximiert = True
self.titelleiste.max_symbol(True)
def wiederherstellen(self):
if not self.maximiert:
return
self.maximiert = False
if self._normal_geo:
self.root.geometry(self._normal_geo)
self.titelleiste.max_symbol(False)
def umschalten(self):
self.wiederherstellen() if self.maximiert else self.maximieren()
# ---- Größe ändern
def _welcher_rand(self, x, y) -> str:
bw, bh, r, k = self.aussen.winfo_width(), self.aussen.winfo_height(), self.RAND, self.ECKE
ns = "n" if y < r else "s" if y >= bh - r else ""
we = "w" if x < r else "e" if x >= bw - r else ""
# an einer Kante nahe am Eck -> Ecke (diagonal), wie bei Windows
if ns and not we:
we = "w" if x < k else "e" if x >= bw - k else ""
elif we and not ns:
ns = "n" if y < k else "s" if y >= bh - k else ""
return ns + we
def _cursor_zurueck(self, _e=None):
if self._rand_zeiger:
self._rand_zeiger = False
try:
self.aussen.configure(cursor="")
except tk.TclError:
pass
_rand_zeiger = False
CURSOR_WIN = {"n": "size_ns", "s": "size_ns", "e": "size_we", "w": "size_we",
"nw": "size_nw_se", "se": "size_nw_se", "ne": "size_ne_sw", "sw": "size_ne_sw"}
CURSOR_X11 = {"n": "top_side", "s": "bottom_side", "e": "right_side", "w": "left_side",
"nw": "top_left_corner", "se": "bottom_right_corner", "ne": "top_right_corner",
"sw": "bottom_left_corner"}
def _rand_cursor(self, e):
if self.maximiert:
return
rand = self._welcher_rand(e.x, e.y)
zeiger = (self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "")
try:
self.aussen.configure(cursor=zeiger)
self._rand_zeiger = bool(zeiger)
except tk.TclError:
pass
def _resize_start(self, e, rand: str | None = None):
self._rand = rand or self._welcher_rand(e.x, e.y)
self._start = (e.x_root, e.y_root, self.root.winfo_x(), self.root.winfo_y(),
self.root.winfo_width(), self.root.winfo_height())
def _resize(self, e):
if not self._rand or self.maximiert:
return
sx, sy, x, y, bw, bh = self._start
dx, dy = e.x_root - sx, e.y_root - sy
if "e" in self._rand:
bw = max(self.minbreite, bw + dx)
if "s" in self._rand:
bh = max(self.minhoehe, bh + dy)
if "w" in self._rand:
neu = max(self.minbreite, bw - dx)
x, bw = x + (bw - neu), neu
if "n" in self._rand:
neu = max(self.minhoehe, bh - dy)
y, bh = y + (bh - neu), neu
# Mausbewegungen bündeln: nur die jeweils letzte Größe anwenden (flüssiger unter Windows)
self._resize_ziel = f"{bw}x{bh}+{x}+{y}"
if self._resize_geplant is None:
self._resize_geplant = self.root.after(15, self._resize_anwenden)
def _resize_anwenden(self):
self._resize_geplant = None
try:
self.root.geometry(self._resize_ziel)
except tk.TclError:
pass