Source code for wolfhece.tablet_wintab

"""
wolfhece/tablet_wintab.py
=========================
Direct WinTab pressure reader via ``Wintab32.dll`` (ctypes).

``wx.glcanvas.GLCanvas`` is not registered as a WinTab context by wxPython,
so ``wx.MouseEvent.GetPressure()`` always returns 0.0 on OpenGL canvases even
with a working Wacom driver.  This module bypasses wx and opens its own WinTab
context, then polls the packet queue in EVT_MOTION handlers.

Usage::

    # In WolfMapViewer.__init__ (after super().__init__):
    try:
        from wolfhece.tablet_wintab import WinTabContext
        self._wintab = WinTabContext(self.GetHandle())
    except Exception as exc:
        logging.warning("WinTab unavailable: %s", exc)
        self._wintab = None

    # In _get_event_pressure:
    if self._wintab is not None:
        return self._wintab.get_pressure()

    # On window close:
    if self._wintab is not None:
        self._wintab.close()
"""

import ctypes
import logging
from typing import Optional


[docs] class DllNotFoundError(RuntimeError): """Raised when Wintab32.dll cannot be found on this machine."""
[docs] log = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # WinTab constants # ---------------------------------------------------------------------------
[docs] _WTI_DEFCONTEXT = 3 # default digitising context
[docs] _WTI_DEVICES = 100 # device info category
[docs] _DVC_NPRESSURE = 15 # normal-pressure axis (AXIS struct)
[docs] _CXO_MESSAGES = 0x0004 # send WT_PACKET messages to the window
[docs] _PK_NORMAL_PRESSURE = 0x0400 # packet field: normal (tip) pressure
# --------------------------------------------------------------------------- # ctypes structures matching wintab.h # ---------------------------------------------------------------------------
[docs] class _LOGCONTEXTA(ctypes.Structure): """LOGCONTEXTA — WinTab logical context (ASCII variant)."""
[docs] _fields_ = [ ("lcName", ctypes.c_char * 40), ("lcOptions", ctypes.c_uint), ("lcStatus", ctypes.c_uint), ("lcLocks", ctypes.c_uint), ("lcMsgBase", ctypes.c_uint), ("lcDevice", ctypes.c_uint), ("lcPktRate", ctypes.c_uint), ("lcPktData", ctypes.c_uint32), ("lcPktMode", ctypes.c_uint32), ("lcMoveMask", ctypes.c_uint32), ("lcBtnDnMask", ctypes.c_uint32), ("lcBtnUpMask", ctypes.c_uint32), ("lcInOrgX", ctypes.c_long), ("lcInOrgY", ctypes.c_long), ("lcInOrgZ", ctypes.c_long), ("lcInExtX", ctypes.c_long), ("lcInExtY", ctypes.c_long), ("lcInExtZ", ctypes.c_long), ("lcOutOrgX", ctypes.c_long), ("lcOutOrgY", ctypes.c_long), ("lcOutOrgZ", ctypes.c_long), ("lcOutExtX", ctypes.c_long), ("lcOutExtY", ctypes.c_long), ("lcOutExtZ", ctypes.c_long), ("lcSensX", ctypes.c_uint32), # FIX32 = DWORD ("lcSensY", ctypes.c_uint32), ("lcSensZ", ctypes.c_uint32), ("lcSysMode", ctypes.c_long), ("lcSysOrgX", ctypes.c_int), ("lcSysOrgY", ctypes.c_int), ("lcSysExtX", ctypes.c_int), ("lcSysExtY", ctypes.c_int), ("lcSysSensX", ctypes.c_uint32), ("lcSysSensY", ctypes.c_uint32), ]
[docs] class _AXIS(ctypes.Structure): """AXIS — range and resolution of one tablet axis."""
[docs] _fields_ = [ ("axMin", ctypes.c_long), ("axMax", ctypes.c_long), ("axUnits", ctypes.c_uint), ("axResolution", ctypes.c_uint32), # FIX32 ]
[docs] class _PACKET(ctypes.Structure): """Minimal WinTab packet containing only normal pressure. Must match the ``lcPktData`` mask set in the LOGCONTEXT (only ``_PK_NORMAL_PRESSURE`` is requested, so the packet is one UINT). """
[docs] _fields_ = [ ("pkNormalPressure", ctypes.c_uint), ]
# --------------------------------------------------------------------------- # Public class # ---------------------------------------------------------------------------
[docs] class WinTabContext: """Reads stylus pressure via ``Wintab32.dll`` (polling mode). Parameters ---------- hwnd: Native window handle, typically ``wx.Window.GetHandle()``. Raises ------ RuntimeError If ``Wintab32.dll`` is not found or the tablet context cannot be opened (driver absent, no tablet connected, etc.). """
[docs] _MAX_PACKETS = 32 # maximum packets drained per call to get_pressure()
def __init__(self, hwnd: int) -> None:
[docs] self._hctx: Optional[int] = None
[docs] self._enabled: bool = False
[docs] self._pressure: float = 1.0
[docs] self._pressure_max: int = 1023 # overwritten from WTInfo
try: self._dll = ctypes.WinDLL("Wintab32.dll") except OSError as exc: raise DllNotFoundError(f"Wintab32.dll introuvable : {exc}") from exc self._bind_functions() self._open_context(hwnd) # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------
[docs] def _bind_functions(self) -> None: """Set argtypes/restype on the WinTab functions we use.""" d = self._dll d.WTInfoA.restype = ctypes.c_uint d.WTInfoA.argtypes = [ctypes.c_uint, ctypes.c_uint, ctypes.c_void_p] d.WTOpenA.restype = ctypes.c_void_p d.WTOpenA.argtypes = [ctypes.c_void_p, ctypes.POINTER(_LOGCONTEXTA), ctypes.c_int] # WinAPI BOOL = int (4 bytes), not c_bool d.WTClose.restype = ctypes.c_int # WinAPI BOOL = int d.WTClose.argtypes = [ctypes.c_void_p] # WTEnable(HCTX hCtx, BOOL fEnable) — enables/disables an open context d.WTEnable.restype = ctypes.c_int d.WTEnable.argtypes = [ctypes.c_void_p, ctypes.c_int] d.WTPacketsGet.restype = ctypes.c_int d.WTPacketsGet.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p]
[docs] def _open_context(self, hwnd: int) -> None: d = self._dll # --- Retrieve default context template --- ctx = _LOGCONTEXTA() if d.WTInfoA(_WTI_DEFCONTEXT, 0, ctypes.byref(ctx)) == 0: raise RuntimeError( "WTInfo(WTI_DEFCONTEXT) a retourné 0 — " "pilote tablette absent ou désactivé." ) # --- Query the pressure axis to get the raw maximum --- axis = _AXIS() d.WTInfoA(_WTI_DEVICES, _DVC_NPRESSURE, ctypes.byref(axis)) if axis.axMax > 0: self._pressure_max = int(axis.axMax) log.debug("WinTab : axe pression max = %d", self._pressure_max) # --- Configure context : only normal-pressure packets, no WM --- ctx.lcPktData = _PK_NORMAL_PRESSURE ctx.lcPktMode = 0 # absolute values ctx.lcMoveMask = _PK_NORMAL_PRESSURE ctx.lcOptions &= ~_CXO_MESSAGES # polling, not messages # Open with fEnable=0 (disabled). Certain Wacom driver versions # (wintab32.dll) corrupt the Windows process heap when WTOpenA is # called with fEnable=1 (or when WTEnable is called immediately after) # after a heavily-initialised UI. The driver's internal activation # path overflows its own heap block in specific heap layouts. # Deferring WTEnable() to after the wx event loop starts (via # wx.CallAfter) avoids this because the heap is in a different, # non-pathological state by then. hctx = d.WTOpenA(ctypes.c_void_p(hwnd), ctypes.byref(ctx), 0) # fEnable=0 — open disabled if not hctx: raise RuntimeError( "WTOpen a échoué — vérifier Wacom Desktop Center " "(pilote WinTab actif ?)." ) self._hctx = hctx self._enabled = False log.info( "WinTab : contexte ouvert sur HWND=0x%X (pressure_max=%d) — " "activation différée à la boucle événementielle", hwnd, self._pressure_max, )
# ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------
[docs] def enable(self) -> None: """Enable the WinTab context so it starts delivering packets. Called via ``wx.CallAfter`` from ``_init_wintab_context`` so that activation happens after the wx event loop has started, avoiding the Wacom driver heap-corruption bug triggered during app initialisation. """ if self._hctx is not None and not self._enabled: self._dll.WTEnable(self._hctx, 1) self._enabled = True log.debug("WinTab : contexte activé.")
[docs] def get_pressure(self) -> float: """Drain the WinTab packet queue and return the latest pressure. Returns a float in ``[0.0, 1.0]``. Returns the last known value when no new packets are available, and ``1.0`` if the context is closed. """ if self._hctx is None: return 1.0 PktArray = _PACKET * self._MAX_PACKETS pkts = PktArray() n: int = self._dll.WTPacketsGet( self._hctx, self._MAX_PACKETS, ctypes.byref(pkts) ) if n > 0: # Use the most recent packet (last in the array) raw = pkts[n - 1].pkNormalPressure self._pressure = min(1.0, raw / self._pressure_max) return self._pressure
[docs] def close(self) -> None: """Close the WinTab context and release the DLL handle.""" if self._hctx is not None: try: self._dll.WTClose(self._hctx) except Exception: pass self._hctx = None log.debug("WinTab : contexte fermé.")
def __del__(self) -> None: self.close()