Files
provisionsmanager95/provtool/gui/nativ.py
T
Kerim AtilganandClaude Opus 5.5 97c73b82c7 Absturz beim Ziehen des Fensters behoben, Zeiger am Rand wieder über Tk, Vodafone-Datum in Spalte A
- Verschieben/Größe ziehen per PostMessage statt SendMessage: Die Windows-Verschiebeschleife lief
  innerhalb eines Tk-Ereignisses; Tk führte darin Python-Ereignisse (<Configure>) aus -> harter
  Absturz „PyEval_RestoreThread … thread state is NULL“ (normal und aus maximiert).
- Größen-Mauszeiger am Rand wieder von Tk (blieb sonst im Fenster stehen); Windows zieht beim Drücken.
- Neuer Maustest im Build (xdotool, echte Maus): Titelleiste/Rand ziehen, maximieren, herausziehen.
- Export: Bei Vodafone steht in Spalte A das Aktivierungsdatum (statt Abrechnungslauf).

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

286 lines
13 KiB
Python
Raw Permalink 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.
"""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. <Configure>) 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 = []