# Mouse reviewer for Orca # # Copyright 2008 Eitan Isaacson # Copyright 2016 Igalia, S.L. # Copyright 2026 SUSE LLC. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2.1 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, write to the # Free Software Foundation, Inc., Franklin Street, Fifth Floor, # Boston MA 02110-1301 USA. # pylint: disable=broad-exception-caught # pylint: disable=too-many-arguments # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-locals """Mouse review mode.""" from __future__ import annotations import math import os import time from collections import deque from typing import TYPE_CHECKING import gi gi.require_version("Atspi", "2.0") from gi.repository import Atspi, GLib try: if os.environ.get("XDG_SESSION_TYPE", "").lower() != "wayland": gi.require_version("Wnck", "3.0") from gi.repository import Wnck # pylint: disable=no-name-in-module except Exception: pass from . import ( ax_device_manager, cmdnames, command_manager, dbus_service, debug, focus_manager, gsettings_registry, guilabels, input_event, messages, preferences_grid_base, presentation_manager, script_manager, ) from .ax_component import AXComponent from .ax_object import AXObject from .ax_text import AXText from .ax_utilities import AXUtilities if TYPE_CHECKING: from .scripts import default class _StringContext: """The textual information associated with an _ItemContext.""" def __init__( self, obj: Atspi.Accessible, script: default.Script | None = None, string: str = "", start: int = 0, end: int = 0, ) -> None: self._obj = obj self._script = script self._string = string self._start = start self._end = end self._rect = AXText.get_range_rect(obj, start, end) def __eq__(self, other: object) -> bool: if not isinstance(other, _StringContext): return False return ( other is not None and self._obj == other._obj and self._string == other._string and self._start == other._start and self._end == other._end ) def __hash__(self) -> int: return hash((self._obj, self._string, self._start, self._end)) def is_substring_of(self, other: _StringContext | None) -> bool: """Returns True if this is a substring of other.""" if other is None or not (self._obj and other.get_object()): return False this_box = self.get_bounding_box() if this_box == (0, 0, 0, 0): return False other_box = other.get_bounding_box() if other_box == (0, 0, 0, 0): return False # We get various and sundry results for the bounding box if the implementor # included newline characters as part of the word or line at offset. Try to # detect this and adjust the bounding boxes before getting the intersection. if this_box[3] != other_box[3] and self._obj == other.get_object(): this_newline_count = self._string.count("\n") if this_newline_count and this_box[3] / this_newline_count == other_box[3]: this_box = *this_box[0:3], other_box[3] this_rect = Atspi.Rect() this_rect.x, this_rect.y, this_rect.width, this_rect.height = this_box other_rect = Atspi.Rect() other_rect.x, other_rect.y, other_rect.width, other_rect.height = other_box if AXUtilities.get_rect_intersection(this_rect, other_rect) != this_rect: return False if not (self._string and self._string.strip() in other.get_string()): return False msg = f"MOUSE REVIEW: '{self._string}' is substring of '{other.get_string()}'" debug.print_message(debug.LEVEL_INFO, msg, True) return True def get_bounding_box(self) -> tuple[int, int, int, int]: """Returns the bounding box associated with this context's range.""" return self._rect.x, self._rect.y, self._rect.width, self._rect.height def get_object(self) -> Atspi.Accessible: """Returns the accessible object associated with this context.""" return self._obj def get_string(self) -> str: """Returns the string associated with this context.""" return self._string def present(self) -> bool: """Presents this context to the user.""" if not self._script: msg = "MOUSE REVIEW: Not presenting due to lack of script" debug.print_message(debug.LEVEL_INFO, msg, True) return False if not self._string: msg = "MOUSE REVIEW: Not presenting due to lack of string" debug.print_message(debug.LEVEL_INFO, msg, True) return False focus_manager.get_manager().emit_region_changed( self._obj, self._start, self._end, focus_manager.MOUSE_REVIEW, ) presenter = presentation_manager.get_manager() presenter.speak_accessible_text(self._obj, self._string) presenter.present_braille_message(self._string) return True class _ItemContext: """Holds all the information of the item at a specified point.""" def __init__( self, x: int = 0, y: int = 0, obj: Atspi.Accessible | None = None, granularity=None, frame: Atspi.Accessible | None = None, script: default.Script | None = None, ) -> None: self._x = x self._y = y self._obj = obj self._granularity = granularity self._frame = frame self._script = script self._string = self._get_string_context() self._time = time.time() self._rect = AXComponent.get_rect(obj) def __eq__(self, other) -> bool: return ( other is not None and self._frame == other._frame and self._obj == other._obj and self._string == other._string ) def __hash__(self) -> int: return hash((self._frame, self._obj, self._string)) def _treat_as_duplicate(self, prior: _ItemContext) -> bool: if self._obj != prior.get_object() or self._frame != prior.get_frame(): msg = "MOUSE REVIEW: Not a duplicate: different objects" debug.print_message(debug.LEVEL_INFO, msg, True) return False if self.get_string() and prior.get_string() and not self._is_substring_of(prior): msg = "MOUSE REVIEW: Not a duplicate: not a substring of" debug.print_message(debug.LEVEL_INFO, msg, True) return False prior_x, prior_y = prior.get_bounding_box()[0:2] if self._x == prior_x and self._y == prior_y: msg = "MOUSE REVIEW: Treating as duplicate: mouse didn't move" debug.print_message(debug.LEVEL_INFO, msg, True) return True interval = self._time - prior.get_time() if interval > 0.5: msg = f"MOUSE REVIEW: Not a duplicate: was {interval:.2f}s ago" debug.print_message(debug.LEVEL_INFO, msg, True) return False msg = "MOUSE REVIEW: Treating as duplicate" debug.print_message(debug.LEVEL_INFO, msg, True) return True def _treat_as_single_object(self) -> bool: return AXUtilities.is_whitespace_or_empty(self._obj) def _get_string_context(self) -> _StringContext: """Returns the _StringContext associated with the specified point.""" if not (self._script and self._obj): return _StringContext(self._obj) if self._treat_as_single_object(): return _StringContext(self._obj, self._script) if self._granularity == Atspi.TextGranularity.WORD: string, start, end = AXUtilities.get_word_at_point(self._obj, self._x, self._y) else: string, start, end = AXUtilities.get_line_at_point(self._obj, self._x, self._y) if string: string = self._script.utilities.expand_eocs(self._obj, start, end) return _StringContext(self._obj, self._script, string, start, end) def _get_container(self): def is_container(x): return ( AXUtilities.is_dialog_or_window(x) or AXUtilities.is_layered_pane(x) or AXUtilities.is_menu(x) or AXUtilities.is_page_tab(x) or AXUtilities.is_tool_bar(x) ) return AXUtilities.find_ancestor(self._obj, is_container) def _is_substring_of(self, other: _ItemContext) -> bool: """Returns True if this is a substring of other.""" return self._string.is_substring_of(other.get_string_context()) def get_object(self) -> Atspi.Accessible | None: """Returns the accessible object associated with this context.""" return self._obj def get_frame(self) -> Atspi.Accessible | None: """Returns the frame associated with this context.""" return self._frame def get_bounding_box(self) -> tuple[int, int, int, int]: """Returns the bounding box associated with this context.""" x, y, width, height = self._string.get_bounding_box() if not (width or height): return self._rect.x, self._rect.y, self._rect.width, self._rect.height return x, y, width, height def get_string(self) -> str: """Returns the string associated with this context.""" return self._string.get_string() def get_string_context(self) -> _StringContext: """Returns the string context associated with this context.""" return self._string def get_time(self) -> float: """Returns the time associated with this context.""" return self._time def _is_inline_child(self, prior: _ItemContext) -> bool: prior_obj = prior.get_object() if not self._obj or not prior_obj: return False if AXObject.get_parent(prior_obj) != self._obj: return False if self._treat_as_single_object(): return False return AXUtilities.is_link(prior_obj) def present(self, prior: _ItemContext) -> bool: """Presents this context to the user.""" if self == prior or self._treat_as_duplicate(prior): msg = "MOUSE REVIEW: Not presenting due to no change" debug.print_message(debug.LEVEL_INFO, msg, True) return False prior_obj = prior.get_object() prior_x, prior_y = prior.get_bounding_box()[0:2] interrupt = (self._obj and self._obj != prior_obj) or math.sqrt( (self._x - prior_x) ** 2 + (self._y - prior_y) ** 2, ) > 25 assert self._script, "Script must not be None" if interrupt: presentation_manager.get_manager().interrupt_presentation() if self._frame and self._frame != prior.get_frame(): self._script.present_object( self._frame, alreadyFocused=True, inMouseReview=True, interrupt=True, ) if self._obj and self._obj != prior_obj and not self._is_inline_child(prior): prior_obj = prior_obj or self._get_container() focus_manager.get_manager().emit_region_changed( self._obj, mode=focus_manager.MOUSE_REVIEW, ) self._script.present_object(self._obj, priorObj=prior_obj, inMouseReview=True) if self._string.get_string() == AXObject.get_name(self._obj): return True if not (AXUtilities.is_editable(self._obj) or AXUtilities.is_terminal(self._obj)): return True if AXUtilities.is_table_cell(self._obj): text = AXText.get_all_text(self._obj) or AXObject.get_name(self._obj) if self._string.get_string() == text: return True if self._string.get_string() != prior.get_string() and self._string.present(): return True return True class MousePreferencesGrid(preferences_grid_base.AutoPreferencesGrid): """GtkGrid containing the Mouse preferences page.""" _gsettings_schema = "mouse-review" def __init__(self, reviewer: MouseReviewer) -> None: """Initialize the preferences grid.""" controls = [ preferences_grid_base.BooleanPreferenceControl( label=guilabels.GENERAL_PRESENT_TOOLTIPS, getter=reviewer.get_present_tooltips, setter=reviewer.set_present_tooltips, prefs_key=MouseReviewer.KEY_PRESENT_TOOLTIPS, ), preferences_grid_base.BooleanPreferenceControl( label=guilabels.GENERAL_SPEAK_OBJECT_UNDER_MOUSE, getter=reviewer.get_is_enabled, setter=reviewer.set_is_enabled, prefs_key=MouseReviewer.KEY_ENABLED, ), ] super().__init__(guilabels.MOUSE, controls, info_message=guilabels.MOUSE_WAYLAND_WARNING) @gsettings_registry.get_registry().gsettings_schema( "org.gnome.Orca.MouseReview", name="mouse-review", ) class MouseReviewer: """Main class for the mouse-review feature.""" _SCHEMA = "mouse-review" KEY_PRESENT_TOOLTIPS = "present-tooltips" KEY_ENABLED = "enabled" def _get_setting(self, key: str, default: bool) -> bool: """Returns the dconf value for key, or default if not in dconf.""" return gsettings_registry.get_registry().layered_lookup( self._SCHEMA, key, "b", default=default, ) def __init__(self) -> None: self._active: bool = False self._current_mouse_over: _ItemContext = _ItemContext() self._workspace = None self._windows: list[Wnck.Window] = [] self._all_windows: list[Wnck.Window] = [] self._handler_ids: dict[int, Wnck.Screen] = {} self._event_listener: Atspi.EventListener = Atspi.EventListener.new(self._listener) self._pointer_moved_id = None self._device: Atspi.Device = None self.in_mouse_event: bool = False self._event_queue: deque = deque() self._initialized: bool = False self._mouse_review_capable: bool = False self._use_atspi: bool = False atspi_version = Atspi.get_version() # pylint: disable=no-value-for-parameter if ( atspi_version[0] > 2 or atspi_version[1] >= 60 or (atspi_version[0] == 2 and atspi_version[1] == 59 and atspi_version[2] >= 90) ): self._use_atspi = True try: if self._use_atspi: ax_device_manager.get_manager().activate() self._device = ax_device_manager.get_manager().get_device() caps = self._device.get_capabilities() caps = self._device.set_capabilities(caps | Atspi.DeviceCapability.POINTER_MONITOR) if caps & Atspi.DeviceCapability.POINTER_MONITOR: self._mouse_review_capable = True else: self._mouse_review_capable = Wnck.Screen.get_default() is not None # pylint: disable=no-value-for-parameter except Exception: debug.print_exception(debug.LEVEL_WARNING) if not self._mouse_review_capable: if self._use_atspi: msg = "MOUSE REVIEW ERROR: Not supported by AT-SPI device" else: msg = "MOUSE REVIEW ERROR: Wnck or at-spi2-core >= 2.60 required" debug.print_message(debug.LEVEL_INFO, msg, True) return msg = "MOUSE REVIEW: Registering D-Bus commands." debug.print_message(debug.LEVEL_INFO, msg, True) controller = dbus_service.get_remote_controller() controller.register_decorated_module("MouseReviewer", self) def set_up_commands(self) -> None: """Sets up commands with CommandManager.""" if self._initialized: return self._initialized = True manager = command_manager.get_manager() group_label = guilabels.KB_GROUP_MOUSE_REVIEW manager.add_command( command_manager.KeyboardCommand( "toggleMouseReviewHandler", self.toggle, group_label, cmdnames.MOUSE_REVIEW_TOGGLE, desktop_keybinding=None, laptop_keybinding=None, ), ) msg = "MOUSE REVIEW: Commands set up." debug.print_message(debug.LEVEL_INFO, msg, True) def create_preferences_grid(self) -> MousePreferencesGrid: """Returns the GtkGrid containing the mouse preferences UI.""" return MousePreferencesGrid(self) def activate(self) -> None: """Activates mouse review.""" if not self._mouse_review_capable: if self._use_atspi: msg = "MOUSE REVIEW ERROR: Not supported by AT-SPI device" else: msg = "MOUSE REVIEW ERROR: Wnck or at-spi2-core >= 2.60 required" debug.print_message(debug.LEVEL_INFO, msg, True) self._active = False return # Set up the initial object as the one with the focus to avoid # presenting irrelevant info the first time. obj = focus_manager.get_manager().get_locus_of_focus() script = None frame = None if obj: script = script_manager.get_manager().get_script(AXUtilities.get_application(obj), obj) if script: frame = script.utilities.top_level_object(obj) self._current_mouse_over = _ItemContext(obj=obj, frame=frame, script=script) if self._use_atspi: self._pointer_moved_id = self._device.connect("pointer-moved", self._on_pointer_moved) else: self._event_listener.register("mouse:abs") screen = Wnck.Screen.get_default() # pylint: disable=no-value-for-parameter if screen is None: self._active = False return # On first startup windows and workspace are likely to be None, # but the signals we connect to will get emitted when proper values # become available; but in case we got disabled and re-enabled we # have to get the initial values manually. stacked = screen.get_windows_stacked() if stacked: stacked.reverse() self._all_windows = stacked self._workspace = screen.get_active_workspace() if self._workspace: self._update_workspace_windows() i = screen.connect("window-stacking-changed", self._on_stacking_changed) self._handler_ids[i] = screen i = screen.connect("active-workspace-changed", self._on_workspace_changed) self._handler_ids[i] = screen self._active = True def deactivate(self) -> None: """Deactivates mouse review.""" if self._use_atspi: if self._pointer_moved_id is not None: self._device.disconnect(self._pointer_moved_id) self._pointer_moved_id = None else: try: self._event_listener.deregister("mouse:abs") except GLib.GError as error: msg = f"MOUSE REVIEW: Exception deregistering 'mouse:abs' listener: {error}" debug.print_message(debug.LEVEL_INFO, msg, True) for key, value in self._handler_ids.items(): value.disconnect(key) self._handler_ids = {} self._workspace = None self._windows = [] self._all_windows = [] self._event_queue.clear() self._active = False def get_current_item(self): """Returns the accessible object being reviewed.""" if not self._mouse_review_capable: return None if not self._active: return None obj = self._current_mouse_over.get_object() if time.time() - self._current_mouse_over.get_time() > 0.1: tokens = ["MOUSE REVIEW: Treating", obj, "as stale"] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None return obj @gsettings_registry.get_registry().gsetting( key=KEY_PRESENT_TOOLTIPS, schema="mouse-review", gtype="b", default=False, summary="Present tooltips on mouse hover", migration_key="presentToolTips", ) @dbus_service.getter def get_present_tooltips(self) -> bool: """Returns whether tooltips displayed due to mouse hover are spoken (requires X11).""" return self._get_setting(self.KEY_PRESENT_TOOLTIPS, False) @dbus_service.setter def set_present_tooltips(self, value: bool) -> bool: """Sets whether tooltips displayed due to mouse hover are spoken (requires X11).""" msg = f"MOUSE REVIEW: Setting present tooltips to {value}." debug.print_message(debug.LEVEL_INFO, msg, True) gsettings_registry.get_registry().set_runtime_value( self._SCHEMA, self.KEY_PRESENT_TOOLTIPS, value ) return True @gsettings_registry.get_registry().gsetting( key=KEY_ENABLED, schema="mouse-review", gtype="b", default=False, summary="Enable mouse review", migration_key="enableMouseReview", ) @dbus_service.getter def get_is_enabled(self) -> bool: """Returns whether mouse review is enabled (requires Wnck).""" return self._get_setting(self.KEY_ENABLED, False) @dbus_service.setter def set_is_enabled(self, value: bool) -> bool: """Sets whether mouse review is enabled (requires Wnck).""" if not self._mouse_review_capable: msg = "MOUSE REVIEW ERROR: Wnck is not available" debug.print_message(debug.LEVEL_INFO, msg, True) return False msg = f"MOUSE REVIEW: Setting enable mouse review to {value}." debug.print_message(debug.LEVEL_INFO, msg, True) if value == self._active: return True gsettings_registry.get_registry().set_runtime_value(self._SCHEMA, self.KEY_ENABLED, value) if value: self.activate() else: self.deactivate() return True @dbus_service.command def toggle( self, script: default.Script, event: input_event.InputEvent | None = None, notify_user: bool = True, ) -> bool: """Toggle mouse reviewing on or off (requires Wnck).""" tokens = [ "MOUSE REVIEW: Toggle. Script:", script, "Event:", event, "notify_user:", notify_user, ] debug.print_tokens(debug.LEVEL_INFO, tokens, True) new_state = not self._active if not self.set_is_enabled(new_state): return False if notify_user: if new_state: msg = messages.MOUSE_REVIEW_ENABLED else: msg = messages.MOUSE_REVIEW_DISABLED presentation_manager.get_manager().present_message(msg) return True def _update_workspace_windows(self) -> None: self._windows = [w for w in self._all_windows if w.is_on_workspace(self._workspace)] def _on_stacking_changed(self, screen) -> None: """Callback for Wnck's window-stacking-changed signal.""" stacked = screen.get_windows_stacked() stacked.reverse() self._all_windows = stacked if self._workspace: self._update_workspace_windows() def _on_workspace_changed(self, screen, _prev_ws=None) -> None: """Callback for Wnck's active-workspace-changed signal.""" self._workspace = screen.get_active_workspace() self._update_workspace_windows() def _accessible_window_at_point_deprecated( self, point_x: int, point_y: int ) -> tuple[object, int, int]: """Returns the accessible window and window based coordinates for the screen coordinates.""" window = None for w in self._windows: if w.is_minimized(): continue x, y, width, height = w.get_client_window_geometry() if x <= point_x <= x + width and y <= point_y <= y + height: window = w break if not window: return None, -1, -1 window_app = window.get_application() app = AXUtilities.get_application_with_pid(window_app.get_pid()) if window_app else None if not app: return None, -1, -1 # Adjust the pointer screen coordinates to be relative to the window. This is # needed because we won't be able to get the screen coordinates in Wayland. relative_x = point_x - x relative_y = point_y - y candidates = list( AXObject.iter_children( app, lambda x: AXComponent.object_contains_point(x, relative_x, relative_y), ), ) if len(candidates) == 1: return candidates[0], relative_x, relative_y name = window.get_name() matches = [o for o in candidates if AXObject.get_name(o) == name] if len(matches) == 1: return matches[0], relative_x, relative_y matches = [o for o in matches if AXUtilities.is_active(o)] if len(matches) == 1: return matches[0], relative_x, relative_y return None, -1, -1 def _accessible_window_at_point( self, app: Atspi.Accessible, point_x: int, point_y: int ) -> tuple[object, int, int]: """Returns the accessible window and window based coordinates""" def get_tuple(obj, x, y): rect = AXComponent.get_rect(obj) return [obj, x - rect.x, y - rect.y] candidates = list( AXObject.iter_children( app, lambda obj: AXComponent.object_contains_point(obj, point_x, point_y) ) ) if len(candidates) == 1: return get_tuple(candidates[0], point_x, point_y) matches = [o for o in candidates if AXUtilities.is_active(o)] if len(matches) == 1: return get_tuple(matches[0], point_x, point_y) return None, -1, -1 def _is_multi_paragraph_object(self, obj) -> bool: """Returns True if obj has multiple paragraphs of text.""" string = AXText.get_all_text(obj) chunks = list(filter(lambda x: x.strip(), string.split("\n\n"))) return len(chunks) > 1 def _on_mouse_moved_deprecated(self, event) -> None: """Callback for mouse:abs events.""" point_x, point_y = event.detail1, event.detail2 window, window_x, window_y = self._accessible_window_at_point_deprecated(point_x, point_y) self._mouse_moved_common(window, window_x, window_y) def _on_mouse_moved(self, obj, point_x, point_y) -> None: """Callback for pointer-moved events.""" if AXObject.get_role(obj) == Atspi.Role.APPLICATION: window, window_x, window_y = self._accessible_window_at_point(obj, point_x, point_y) self._mouse_moved_common(window, window_x, window_y) else: self._mouse_moved_common(obj, point_x, point_y) def _mouse_moved_common(self, window, window_x, window_y) -> None: tokens = [f"MOUSE REVIEW: Window at ({window_x}, {window_y}) is", window] debug.print_tokens(debug.LEVEL_INFO, tokens, True) if not window: return script = script_manager.get_manager().get_script(AXUtilities.get_application(window)) if not script: return focus = focus_manager.get_manager().get_locus_of_focus() if AXObject.is_dead(focus): menu = None elif AXUtilities.is_menu(focus): menu = focus else: menu = AXUtilities.find_ancestor(focus, AXUtilities.is_menu) obj = None if menu: obj = AXUtilities.get_descendant_at_point(menu, window_x, window_y) tokens = ["MOUSE REVIEW: Object in", menu, f"at ({window_x}, {window_y}) is", obj] debug.print_tokens(debug.LEVEL_INFO, tokens, True) if obj is None: obj = AXUtilities.get_descendant_at_point(window, window_x, window_y) tokens = ["MOUSE REVIEW: Object in", window, f"at ({window_x}, {window_y}) is", obj] debug.print_tokens(debug.LEVEL_INFO, tokens, True) script = script_manager.get_manager().get_script(AXUtilities.get_application(window), obj) if menu and obj and not AXUtilities.find_ancestor(obj, AXUtilities.is_menu): if AXUtilities.objects_overlap(obj, menu): tokens = ["MOUSE REVIEW:", obj, "believed to be under", menu] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return granularity = Atspi.TextGranularity.LINE _x, y, _width, height = self._current_mouse_over.get_bounding_box() if y <= window_y <= y + height and self._current_mouse_over.get_string(): granularity = Atspi.TextGranularity.WORD if len(self._event_queue): msg = "MOUSE REVIEW: Mouse moved again." debug.print_message(debug.LEVEL_INFO, msg, True) return new = _ItemContext(window_x, window_y, obj, granularity, window, script) if new.present(self._current_mouse_over): self._current_mouse_over = new def _process_event_deprecated(self) -> None: if not self._event_queue: return event = self._event_queue.popleft() if len(self._event_queue): return start_time = time.time() tokens = ["\nvvvvv PROCESS OBJECT EVENT", event.type, "vvvvv"] debug.print_tokens(debug.LEVEL_INFO, tokens, False) self.in_mouse_event = True self._on_mouse_moved_deprecated(event) self.in_mouse_event = False msg = f"TOTAL PROCESSING TIME: {time.time() - start_time:.4f}\n" msg += f"^^^^^ PROCESS OBJECT EVENT {event.type} ^^^^^\n" debug.print_message(debug.LEVEL_INFO, msg, False) def _listener(self, event) -> None: """Generic listener for events of interest.""" if event.type.startswith("mouse:abs"): self._event_queue.append(event) GLib.timeout_add(50, self._process_event_deprecated) def _process_event(self) -> None: if not self._event_queue: return [obj, x, y] = self._event_queue.popleft() if len(self._event_queue): return start_time = time.time() tokens = ["\nvvvvv PROCESS POINTER-MOVED EVENT", "vvvvv"] debug.print_tokens(debug.LEVEL_INFO, tokens, False) self.in_mouse_event = True self._on_mouse_moved(obj, x, y) self.in_mouse_event = False msg = f"TOTAL PROCESSING TIME: {time.time() - start_time:.4f}\n" msg += "^^^^^ PROCESS POINTER-MOVED EVENT ^^^^^\n" debug.print_message(debug.LEVEL_INFO, msg, False) def _on_pointer_moved(self, _device, obj, x, y) -> None: """Listener for pointer-moved events from devices.""" self._event_queue.append([obj, x, y]) GLib.timeout_add(50, self._process_event) _reviewer = MouseReviewer() def get_reviewer() -> MouseReviewer: """Returns the Mouse Reviewer singleton.""" return _reviewer