"""
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()