"""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("", lambda e: self.configure(relief="sunken")) self.bind("", 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("", self._start) widget.bind("", self._ziehen) if maximieren: widget.bind("", 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("", 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("", self._cursor_zurueck) self.inhalt.bind("", self._cursor_zurueck) self.titelleiste.bind("", self._cursor_zurueck, add="+") self.aussen.bind("", self._resize_start) self.aussen.bind("", self._resize) root.bind_all("", 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("", lambda e: self._resize_start(e, "se")) g.bind("", 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("") self.root.overrideredirect(True) self.root.geometry(geo) taskleiste_anzeigen(self.root) self.root.bind("", 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