Windows-Build / windows-exe (push) Successful in 5m7s
- Airtime: Auszahlung in % je Händler (0 % = keine). Im Export als eine Summenzeile, die Einzelposten in einer eigenen Airtime-Liste (zweite Excel-Datei). - Jahresbonus Neuaktivierung: Betrag je Karte nach Staffel (Standard 1-9: 5 € ... ab 40: voll), Standard-Staffel änderbar, eigene Staffel je Händler möglich. - Provisionsarten: Airtime/Jahresbonus werden erkannt, Behandlung je Provisionsart umstellbar. - Export: freenet und Vodafone standardmäßig in einer Datei, Positionen mit 0,00 € weglassen. - Händler-Dialog: GROSSBUCHSTABEN-Regel als Häkchen (ging verloren, wenn „Hinzufügen“ vergessen wurde). - Windows: Hauptfenster ist ein echtes Programmfenster mit unsichtbarem Rahmen – Andocken am Bildschirmrand, Win+Pfeiltasten und natives Größe-Ziehen; Trennsteg baut erst beim Loslassen neu auf. - DB-Schema v3 (Migration), Tests für alles Neue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
312 lines
14 KiB
Python
312 lines
14 KiB
Python
"""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: Am Rand meldet das Fenster per WM_NCHITTEST „linker Rand“ usw. -> Windows zeigt den
|
||
passenden Mauszeiger und zieht die Größe selbst (flüssig, kein hängender Zeiger).
|
||
|
||
WICHTIG: Die Fensterprozeduren hier laufen mitten in Tk. Sie dürfen Tk NICHT aufrufen (Tk-Sperre ->
|
||
Verklemmung), nur Win32-Funktionen.
|
||
"""
|
||
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, WM_SETCURSOR = 0x00A1, 0x0112, 0x0020
|
||
HTCLIENT, HTCAPTION, HTTRANSPARENT = 1, 2, -1
|
||
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
|
||
ZEIGER = {"n": 32645, "s": 32645, "w": 32644, "e": 32644, "nw": 32642, "se": 32642, "ne": 32643, "sw": 32643}
|
||
HT_RAND = {v: k for k, v in HT.items()}
|
||
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),
|
||
("LoadCursorW", [wintypes.HINSTANCE, ctypes.c_void_p], wintypes.HANDLE),
|
||
("SetCursor", [wintypes.HANDLE], wintypes.HANDLE)):
|
||
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.
|
||
|
||
rand_widgets: Tk-Widgets, die am Fensterrand liegen (deren Randbereich wird für die Maus
|
||
„durchsichtig“, damit Windows dort den Größenrahmen erkennt).
|
||
"""
|
||
|
||
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
|
||
self._rand_hwnds: set[int] = set()
|
||
|
||
# ------------------------------------------------------------------ Einrichten
|
||
def einrichten(self, rand_widgets: list[tk.Misc]) -> bool:
|
||
"""Nach dem ersten Anzeigen aufrufen (vorher gibt es das äußere Windows-Fenster noch nicht)."""
|
||
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
|
||
for wdg in rand_widgets:
|
||
hw = wdg.winfo_id()
|
||
if self._unterklasse(hw, self._rand_proc):
|
||
self._rand_hwnds.add(hw)
|
||
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, rand_widgets: list[tk.Misc]) -> bool:
|
||
self.aktiv = False
|
||
self._rand_hwnds.clear()
|
||
return self.einrichten(rand_widgets)
|
||
|
||
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_SETCURSOR and (lparam & 0xFFFF) in HT_RAND:
|
||
u = _api()[0]
|
||
u.SetCursor(u.LoadCursorW(None, ZEIGER[HT_RAND[lparam & 0xFFFF]]))
|
||
return 1
|
||
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 _rand_proc(self, hwnd, msg, wparam, lparam, _id, _ref):
|
||
_, c = _api()
|
||
try:
|
||
if msg == WM_NCHITTEST and self.randbereich(*_xy(lparam)):
|
||
return HTTRANSPARENT # Maus „fällt durch“ zum äußeren Fenster -> Größenrahmen
|
||
if msg == WM_NCDESTROY:
|
||
self._entfernen(hwnd)
|
||
except Exception:
|
||
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)."""
|
||
u, _ = _api()
|
||
u.ReleaseCapture()
|
||
u.SendMessageW(self.hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0)
|
||
|
||
def groesse_ziehen(self, rand: str) -> None:
|
||
u, _ = _api()
|
||
u.ReleaseCapture()
|
||
u.SendMessageW(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 fenster_bei(self, x: int, y: int) -> int | None:
|
||
"""Für Tests: Welches Fenster bekommt die Maus am Bildschirmpunkt (berücksichtigt HTTRANSPARENT)?"""
|
||
return _api()[0].WindowFromPoint(wintypes.POINT(x, y))
|
||
|
||
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 = []
|