diff --git a/win/dpi_aware.py b/win/dpi_aware.py new file mode 100644 index 0000000..a3daac4 --- /dev/null +++ b/win/dpi_aware.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Per-Monitor DPI Awareness V2 bootstrap for the OpenSpan Windows side. + +Must run in EVERY OpenSpan process (control GUI, setup window, input portal) +before any Tk window or Win32 monitor/hook API is touched. The portal's edge +geometry, the monitor rectangles it / the GUI enumerate (written to +openspan_config.json) and the low-level mouse-hook coordinates all have to +live in ONE physical-pixel coordinate space. On this machine that means the +three real sizes 1600x2560 / 1920x1080 / 2560x1600 -- not the virtualized +logical sizes (800x1280 / 1536x864 / 1280x800) a DPI-unaware or mixed-DPI +process reports, which made the portal fire before the mouse reached the +real screen edge. + +Pure standard library (ctypes), matching the rest of the repo. +""" + +import ctypes + + +def _current(): + """Query the process's current DPI awareness via shcore. Returns a + label ('unaware'/'system'/'pm') or None when the API is unavailable.""" + try: + val = ctypes.c_int(-1) + ctypes.windll.shcore.GetProcessDpiAwareness( + ctypes.c_void_p(0), ctypes.byref(val)) + except (AttributeError, OSError): + return None + return {0: "unaware", 1: "system", 2: "pm"}.get(val.value) + + +def ensure(): + """Declare Per-Monitor DPI Awareness V2 if not already set. + + Prefers SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_ + AWARE_V2 = -4) (Windows 10 1703+). If the process is already aware -- + a manifest or an earlier setter -- the call fails with + ERROR_ACCESS_DENIED and the existing awareness wins, which is fine + because _current() reports what is actually in force. + + Fallbacks in order: shcore SetProcessDpiAwareness(PROCESS_PER_MONITOR_ + DPI_AWARE), user32 SetProcessDPIAware (system-aware). Returns the label + in force afterwards. + """ + try: + ctx = ctypes.c_void_p(-4) + if ctypes.windll.user32.SetProcessDpiAwarenessContext(ctx): + return _current() or "v2" + except (AttributeError, OSError): + pass + try: + if ctypes.windll.shcore.SetProcessDpiAwareness(2) == 0: + return _current() or "pm" + except (AttributeError, OSError): + pass + try: + if ctypes.windll.user32.SetProcessDPIAware(): + return "system" + except (AttributeError, OSError): + pass + return _current() or "none" diff --git a/win/openspan_launcher.py b/win/openspan_launcher.py index ad3016b..8390271 100644 --- a/win/openspan_launcher.py +++ b/win/openspan_launcher.py @@ -67,6 +67,8 @@ def _traytest(): if __name__ == "__main__": _ensure_std_streams() + import dpi_aware # noqa: E402 + dpi_aware.ensure() # physical-pixel space for every role (see dpi_aware.py) arg = sys.argv[1] if len(sys.argv) > 1 else "" if arg == "--portal": del sys.argv[1] diff --git a/win/openspan_portal.py b/win/openspan_portal.py index d1a12d7..c2a8c9f 100644 --- a/win/openspan_portal.py +++ b/win/openspan_portal.py @@ -24,6 +24,13 @@ import time import queue +# Physical-pixel coordinate space BEFORE any Win32 monitor / hook call: +# portal edges (openspan_config.json), hook coords (ms.pt) and monitor +# rectangles all have to be the SAME space, or the portal fires early on +# mixed-DPI desktops. +import dpi_aware # noqa: E402 +dpi_aware.ensure() # noqa: E402 + user32 = ctypes.windll.user32 kernel32 = ctypes.windll.kernel32 diff --git a/win/openspan_setup.py b/win/openspan_setup.py index f3dabc5..7113843 100644 --- a/win/openspan_setup.py +++ b/win/openspan_setup.py @@ -18,6 +18,11 @@ import tkinter as tk from tkinter import ttk, messagebox +# Same physical-pixel space for the GUI, the setup window and the portal: +# must be declared before any Tk window / GetMonitorInfo call. +import dpi_aware # noqa: E402 +dpi_aware.ensure() # noqa: E402 + if getattr(sys, "frozen", False): # frozen exe: data lives at the exe CONFIG_PATH = os.path.join( os.path.dirname(os.path.abspath(sys.executable)),