"""Windows: echtes Programmfenster mit selbst gezeichnetem Win95-Rahmen. Das Hauptfenster ist für Tk rahmenlos („overrideredirect“). Für Windows war es damit ein Popup ohne Größenrahmen – deshalb gingen Andocken (Fenster an den Rand ziehen), Win+Hoch/Runter/Links/Rechts und das native Ziehen der Fenstergröße nicht. Jetzt bekommt das äußere Windows-Fenster die Stile eines normalen Programmfensters (Titelleiste, Größenrahmen, Min/Max, Taskleiste), deren Rahmen aber *unsichtbar* bleibt: Bei WM_NCCALCSIZE melden wir „der Innenbereich ist das ganze Fenster“. Für Tk bleibt es rahmenlos – Tk rechnet bei Größenänderungen also weiter ohne Rahmenaufschlag (sonst würde das Fenster bei jeder internen Größenanpassung um die Rahmenbreite wachsen). Windows erledigt alles Weitere selbst: - Ziehen an der Titelleiste (WM_NCLBUTTONDOWN/HTCAPTION) mit Andocken links/rechts/oben (Maximieren) - Win+Pfeiltasten, Doppelklick, Systemmenü (Alt+Leertaste), Taskleiste, Alt+Tab - Größe ändern: Den Größen-Mauszeiger am Rand zeigt Tk (rahmen.py) – so weiß Tk immer, welcher Zeiger gilt, und setzt ihn beim Zurückfahren in das Fenster wieder richtig. Beim Drücken auf den Rand zieht Windows die Größe (flüssig). WICHTIG: - Die Fensterprozeduren hier laufen mitten in Tk. Sie dürfen Tk NICHT aufrufen (Tk-Sperre -> Verklemmung), nur Win32-Funktionen. - Verschieben/Größe ziehen NIE synchron (SendMessage) aus einem Tk-Ereignis heraus starten: Windows läuft dann in einer eigenen Schleife, in der Tk Python-Ereignisse (z. B. ) ausführt, während Python das nicht erwartet -> harter Absturz („PyEval_RestoreThread … thread state is NULL“). Deshalb PostMessage: Die Schleife startet, sobald Tk wieder in seiner Ereignisschleife ist. """ from __future__ import annotations import ctypes import sys import tkinter as tk from ctypes import wintypes IS_WIN = sys.platform.startswith("win") WM_NCDESTROY, WM_NCCALCSIZE, WM_NCHITTEST, WM_NCACTIVATE = 0x0082, 0x0083, 0x0084, 0x0086 WM_NCLBUTTONDOWN, WM_SYSCOMMAND = 0x00A1, 0x0112 HTCLIENT, HTCAPTION = 1, 2 HT = {"w": 10, "e": 11, "n": 12, "nw": 13, "ne": 14, "s": 15, "sw": 16, "se": 17} GWL_STYLE, GWL_EXSTYLE = -16, -20 WS_CAPTION, WS_THICKFRAME, WS_SYSMENU = 0x00C00000, 0x00040000, 0x00080000 WS_MINIMIZEBOX, WS_MAXIMIZEBOX = 0x00020000, 0x00010000 WS_EX_TOOLWINDOW, WS_EX_APPWINDOW = 0x00000080, 0x00040000 SWP_NOSIZE, SWP_NOMOVE, SWP_NOZORDER, SWP_NOACTIVATE, SWP_FRAMECHANGED = 0x1, 0x2, 0x4, 0x10, 0x20 SC_MAXIMIZE, SC_RESTORE, SC_MINIMIZE = 0xF030, 0xF120, 0xF020 LRESULT = ctypes.c_ssize_t SUBCLASSPROC = ctypes.WINFUNCTYPE(LRESULT, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM, ctypes.c_size_t, ctypes.c_size_t) if IS_WIN else None class NCCALCSIZE_PARAMS(ctypes.Structure): _fields_ = [("rgrc", wintypes.RECT * 3), ("lppos", ctypes.c_void_p)] class MONITORINFO(ctypes.Structure): _fields_ = [("cbSize", wintypes.DWORD), ("rcMonitor", wintypes.RECT), ("rcWork", wintypes.RECT), ("dwFlags", wintypes.DWORD)] _dll = {} def _api(): if _dll: return _dll["u"], _dll["c"] u = ctypes.WinDLL("user32", use_last_error=True) c = ctypes.WinDLL("comctl32", use_last_error=True) H, U, W, L = wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM for name, args, res in ( ("GetAncestor", [H, U], H), ("GetWindowRect", [H, ctypes.POINTER(wintypes.RECT)], wintypes.BOOL), ("GetClientRect", [H, ctypes.POINTER(wintypes.RECT)], wintypes.BOOL), ("IsZoomed", [H], wintypes.BOOL), ("IsIconic", [H], wintypes.BOOL), ("GetWindowLongW", [H, ctypes.c_int], ctypes.c_long), ("SetWindowLongW", [H, ctypes.c_int, ctypes.c_long], ctypes.c_long), ("SetWindowPos", [H, H, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, U], wintypes.BOOL), ("MonitorFromRect", [ctypes.POINTER(wintypes.RECT), wintypes.DWORD], wintypes.HMONITOR), ("GetMonitorInfoW", [wintypes.HMONITOR, ctypes.POINTER(MONITORINFO)], wintypes.BOOL), ("ReleaseCapture", [], wintypes.BOOL), ("SendMessageW", [H, U, W, L], LRESULT), ("PostMessageW", [H, U, W, L], wintypes.BOOL), ("ShowWindow", [H, ctypes.c_int], wintypes.BOOL), ("WindowFromPoint", [wintypes.POINT], H)): f = getattr(u, name) f.argtypes, f.restype = args, res c.SetWindowSubclass.argtypes = [H, SUBCLASSPROC, ctypes.c_size_t, ctypes.c_size_t] c.SetWindowSubclass.restype = wintypes.BOOL c.RemoveWindowSubclass.argtypes = [H, SUBCLASSPROC, ctypes.c_size_t] c.RemoveWindowSubclass.restype = wintypes.BOOL c.DefSubclassProc.argtypes = [H, U, W, L] c.DefSubclassProc.restype = LRESULT _dll.update(u=u, c=c) return u, c def _rect(h) -> wintypes.RECT: r = wintypes.RECT() _api()[0].GetWindowRect(h, ctypes.byref(r)) return r def _xy(lparam: int) -> tuple[int, int]: """Bildschirmkoordinaten aus lParam (vorzeichenbehaftet, mehrere Monitore).""" x, y = lparam & 0xFFFF, (lparam >> 16) & 0xFFFF return (x - 0x10000 if x & 0x8000 else x), (y - 0x10000 if y & 0x8000 else y) def arbeitsbereich_von(rect: wintypes.RECT) -> wintypes.RECT | None: u, _ = _api() mon = u.MonitorFromRect(ctypes.byref(rect), 2) # MONITOR_DEFAULTTONEAREST mi = MONITORINFO() mi.cbSize = ctypes.sizeof(MONITORINFO) return mi.rcWork if mon and u.GetMonitorInfoW(mon, ctypes.byref(mi)) else None class NativerRahmen: """Macht das Tk-Hauptfenster zu einem echten Windows-Fenster ohne sichtbaren Windows-Rahmen.""" def __init__(self, root: tk.Tk, rand: int, ecke: int): self.root = root self.rand, self.ecke = rand, ecke self.hwnd = None self.aktiv = False self._procs: dict[int, object] = {} # Referenzen halten, sonst räumt Python die Callbacks weg # ------------------------------------------------------------------ Einrichten def einrichten(self) -> bool: """Sobald Tk das äußere Windows-Fenster angelegt hat (möglichst vor dem ersten Anzeigen).""" if self.aktiv: return True try: u, c = _api() self.root.update_idletasks() h = self.aeusseres_fenster() if not h: return False self.hwnd = h stil = u.GetWindowLongW(h, GWL_STYLE) u.SetWindowLongW(h, GWL_STYLE, stil | WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX) ex = u.GetWindowLongW(h, GWL_EXSTYLE) u.SetWindowLongW(h, GWL_EXSTYLE, (ex & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW) if not self._unterklasse(h, self._aussen_proc): return False self.aktiv = True # Rahmen neu berechnen lassen (jetzt: unsichtbar) u.SetWindowPos(h, None, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED) self._ecken_nicht_runden(h) return True except (OSError, AttributeError, tk.TclError): return False def aeusseres_fenster(self) -> int | None: """Das äußere Windows-Fenster („wm frame“) – None, solange Tk es noch nicht angelegt hat.""" try: h = int(self.root.wm_frame(), 16) except (tk.TclError, ValueError): return None return h if h and h != self.root.winfo_id() else None def veraltet(self) -> bool: """Hat Tk das äußere Fenster neu angelegt (z. B. nach „wm resizable“)? Dann neu einrichten.""" return self.aktiv and self.aeusseres_fenster() not in (None, self.hwnd) def neu_einrichten(self) -> bool: self.aktiv = False return self.einrichten() def _unterklasse(self, h: int, fn) -> bool: _, c = _api() proc = SUBCLASSPROC(fn) if not c.SetWindowSubclass(h, proc, 1, 0): return False self._procs[h] = proc return True @staticmethod def _ecken_nicht_runden(h): """Windows 11 rundet Fensterecken – passt nicht zum Win95-Look.""" try: wert = ctypes.c_int(1) # DWMWCP_DONOTROUND ctypes.windll.dwmapi.DwmSetWindowAttribute(wintypes.HWND(h), 33, ctypes.byref(wert), 4) except (OSError, AttributeError): pass # ------------------------------------------------------------------ Fensterprozeduren (KEIN Tk!) def randbereich(self, x: int, y: int) -> str: """Welcher Rand liegt am Bildschirmpunkt? '' = keiner (oder maximiert).""" u, _ = _api() h = self.hwnd if not h or u.IsZoomed(h) or u.IsIconic(h): return "" r = _rect(h) if not (r.left <= x < r.right and r.top <= y < r.bottom): return "" d, k = self.rand, self.ecke ns = "n" if y < r.top + d else "s" if y >= r.bottom - d else "" we = "w" if x < r.left + d else "e" if x >= r.right - d else "" if ns and not we: we = "w" if x < r.left + k else "e" if x >= r.right - k else "" elif we and not ns: ns = "n" if y < r.top + k else "s" if y >= r.bottom - k else "" return ns + we def _aussen_proc(self, hwnd, msg, wparam, lparam, _id, _ref): _, c = _api() try: if msg == WM_NCCALCSIZE: if wparam: # Innenbereich = ganzes Fenster. Maximiert legt Windows das Fenster um die (jetzt # unsichtbare) Rahmenbreite über den Bildschirmrand -> auf den Arbeitsbereich begrenzen. if _api()[0].IsZoomed(hwnd): p = ctypes.cast(lparam, ctypes.POINTER(NCCALCSIZE_PARAMS)).contents arbeit = arbeitsbereich_von(p.rgrc[0]) if arbeit is not None: p.rgrc[0] = arbeit return 0 if msg == WM_NCACTIVATE: # Nicht den (unsichtbaren) Windows-Rahmen neu zeichnen lassen (klassisches Design) return c.DefSubclassProc(hwnd, msg, wparam, -1) if msg == WM_NCHITTEST: rand = self.randbereich(*_xy(lparam)) if rand: return HT[rand] return HTCLIENT if msg == WM_NCDESTROY: self._entfernen(hwnd) except Exception: # niemals eine Ausnahme in die Fensterprozedur durchlassen pass return c.DefSubclassProc(hwnd, msg, wparam, lparam) def _entfernen(self, hwnd): proc = self._procs.pop(hwnd, None) if proc is not None: try: _api()[1].RemoveWindowSubclass(hwnd, proc, 1) except OSError: pass # Callback-Objekt bleibt bis Programmende referenziert (Windows könnte ihn noch einmal rufen) _BEHALTEN.append(proc) # ------------------------------------------------------------------ Bedienung def verschieben(self) -> None: """Titelleiste gedrückt: Windows zieht das Fenster (mit Andocken an den Bildschirmrand). PostMessage, nicht SendMessage – siehe Modulbeschreibung.""" u, _ = _api() u.ReleaseCapture() u.PostMessageW(self.hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0) def groesse_ziehen(self, rand: str) -> None: u, _ = _api() u.ReleaseCapture() u.PostMessageW(self.hwnd, WM_NCLBUTTONDOWN, HT[rand], 0) def maximiert(self) -> bool: return bool(self.hwnd and _api()[0].IsZoomed(self.hwnd)) def befehl(self, sc: int) -> None: """SC_MAXIMIZE / SC_RESTORE / SC_MINIMIZE – wie über das Systemmenü (mit Windows-Animation).""" _api()[0].SendMessageW(self.hwnd, WM_SYSCOMMAND, sc, 0) def aussenmass(self) -> tuple[int, int, int, int]: r = _rect(self.hwnd) return r.left, r.top, r.right - r.left, r.bottom - r.top def setzen(self, x: int, y: int, breite: int, hoehe: int) -> None: """Position und Größe des Fensters genau so setzen (ohne Tk-Rahmenaufschlag).""" _api()[0].SetWindowPos(self.hwnd, None, x, y, breite, hoehe, SWP_NOZORDER | SWP_NOACTIVATE) def innen_gleich_aussen(self) -> bool: """Für Tests: Ist der Windows-Rahmen wirklich unsichtbar?""" u, _ = _api() a, i = _rect(self.hwnd), wintypes.RECT() u.GetClientRect(self.hwnd, ctypes.byref(i)) return (i.right - i.left, i.bottom - i.top) == (a.right - a.left, a.bottom - a.top) def stil(self) -> tuple[int, int]: u, _ = _api() return u.GetWindowLongW(self.hwnd, GWL_STYLE), u.GetWindowLongW(self.hwnd, GWL_EXSTYLE) def trefferpruefung(self, hwnd: int, x: int, y: int) -> int: """Für Tests: Was meldet das Fenster `hwnd` für den Bildschirmpunkt (WM_NCHITTEST)?""" lp = ((y & 0xFFFF) << 16) | (x & 0xFFFF) return _api()[0].SendMessageW(hwnd, WM_NCHITTEST, 0, lp) _BEHALTEN: list = []