Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions core/mouse_hook_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Shared mouse hook behavior used by platform implementations.
"""

import queue
import time

try:
Expand Down Expand Up @@ -41,6 +42,33 @@ def __init__(self):
self._gesture_cooldown_until = 0.0
self._gesture_input_source = None
self._connected_device = None
self._dispatch_queue = None

def _init_dispatch_queue(self, maxsize=0):
"""Initialize dispatch queue storage for subclasses with event threads."""
self._dispatch_queue = queue.Queue(maxsize=max(0, int(maxsize)))

def _enqueue_dispatch_event(self, event):
"""Best-effort enqueue that bounds memory when queue has a max size."""
q = self._dispatch_queue
if q is None:
return
if q.maxsize <= 0:
q.put(event)
return
try:
q.put_nowait(event)
return
except queue.Full:
pass
try:
q.get_nowait()
except queue.Empty:
pass
try:
q.put_nowait(event)
except queue.Full:
self._emit_debug(f"Dropped event due to full dispatch queue: {event.event_type}")

def register(self, event_type, callback):
self._callbacks.setdefault(event_type, []).append(callback)
Expand Down Expand Up @@ -254,3 +282,24 @@ def _on_hid_connect(self):
def _on_hid_disconnect(self):
self._connected_device = None
self._set_device_connected(False)

def _on_hid_gesture_down(self):
self._dispatch(MouseEvent(MouseEvent.GESTURE_DOWN))

def _on_hid_gesture_up(self):
self._dispatch(MouseEvent(MouseEvent.GESTURE_UP))

def _on_hid_gesture_move(self, dx, dy):
self._accumulate_gesture_delta(dx, dy, "hid_rawxy")

def _on_hid_mode_shift_down(self):
self._dispatch(MouseEvent(MouseEvent.MODE_SHIFT_DOWN))

def _on_hid_mode_shift_up(self):
self._dispatch(MouseEvent(MouseEvent.MODE_SHIFT_UP))

def _on_hid_dpi_switch_down(self):
self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_DOWN))

def _on_hid_dpi_switch_up(self):
self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_UP))
8 changes: 4 additions & 4 deletions core/mouse_hook_macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def __init__(self):
self._wake_observer = None
self._session_resign_observer = None
self._session_activate_observer = None
self._dispatch_queue = queue.Queue()
self._init_dispatch_queue(maxsize=512)
self._dispatch_thread = None
self._first_event_logged = False

Expand Down Expand Up @@ -219,7 +219,7 @@ def _accumulate_gesture_delta(self, delta_x, delta_y, source):
"dy": self._gesture_delta_y,
}
)
self._dispatch_queue.put(
self._enqueue_dispatch_event(
MouseEvent(
gesture_event,
{
Expand Down Expand Up @@ -400,7 +400,7 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon):
mouse_event = MouseEvent(MouseEvent.HSCROLL_LEFT, abs(h_delta))
should_block = MouseEvent.HSCROLL_LEFT in self._blocked_events
if mouse_event:
self._dispatch_queue.put(mouse_event)
self._enqueue_dispatch_event(mouse_event)
mouse_event = None
if should_block:
return None
Expand All @@ -409,7 +409,7 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon):
return None

if mouse_event:
self._dispatch_queue.put(mouse_event)
self._enqueue_dispatch_event(mouse_event)

if should_block:
return None
Expand Down
4 changes: 2 additions & 2 deletions core/mouse_hook_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ def __init__(self):
self._startup_ok = False
self._prev_raw_buttons = {}
self._last_rehook_time = 0
self._dispatch_queue = queue.Queue()
self._init_dispatch_queue(maxsize=512)
self._dispatch_worker_thread = None

def _accumulate_gesture_delta(self, delta_x, delta_y, source):
Expand Down Expand Up @@ -466,7 +466,7 @@ def _low_level_handler_inner(self, nCode, wParam, lParam):
)

if event:
self._dispatch_queue.put(event)
self._enqueue_dispatch_event(event)
if should_block:
return 1

Expand Down
32 changes: 32 additions & 0 deletions tests/test_mouse_hook.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import importlib
import queue
import sys
import unittest
from types import SimpleNamespace
Expand Down Expand Up @@ -83,6 +84,37 @@ def test_runtime_state_projects_hid_identity(self):
self.assertIs(state.connected_device, device)



class BaseMouseHookDispatchQueueTests(unittest.TestCase):
def test_enqueue_keeps_queue_bounded_and_drops_oldest(self):
hook = BaseMouseHook()
hook._init_dispatch_queue(maxsize=2)

hook._enqueue_dispatch_event(SimpleNamespace(event_type="e1", raw_data=None))
hook._enqueue_dispatch_event(SimpleNamespace(event_type="e2", raw_data=None))
hook._enqueue_dispatch_event(SimpleNamespace(event_type="e3", raw_data=None))

self.assertEqual(hook._dispatch_queue.qsize(), 2)
first = hook._dispatch_queue.get_nowait()
second = hook._dispatch_queue.get_nowait()
self.assertEqual(first.event_type, "e2")
self.assertEqual(second.event_type, "e3")

def test_enqueue_drops_and_emits_debug_when_still_full(self):
hook = BaseMouseHook()
hook._init_dispatch_queue(maxsize=1)
hook.debug_mode = True
hook.set_debug_callback(Mock())

hook._dispatch_queue.put_nowait(SimpleNamespace(event_type="old", raw_data=None))
with patch.object(hook._dispatch_queue, "get_nowait", side_effect=queue.Empty):
with patch.object(hook._dispatch_queue, "put_nowait", side_effect=[queue.Full, queue.Full]):
hook._enqueue_dispatch_event(SimpleNamespace(event_type="new", raw_data=None))

hook._debug_callback.assert_called_once()
self.assertIn("Dropped event due to full dispatch queue", hook._debug_callback.call_args[0][0])


class LinuxMouseHookReconnectTests(unittest.TestCase):
def _reload_for_linux(self):
fake_evdev = SimpleNamespace(
Expand Down