5 Commits

Author SHA1 Message Date
Storm Dragon 17f196e530 More web optimizations. 2026-07-22 13:26:18 -04:00
Storm Dragon 1249aa791c More web refactor. Tighten suspend terminal code. 2026-07-21 13:28:26 -04:00
Storm Dragon 5044455582 Had codex fix up tests. 2026-07-19 15:22:32 -04:00
Storm Dragon 3d44f6d50b Lots of work on the web side of things. More coming. 2026-07-19 15:00:25 -04:00
Storm Dragon 3e97c1ce33 Web fixes. 2026-07-18 15:10:43 -04:00
21 changed files with 3923 additions and 141 deletions
+2 -2
View File
@@ -254,6 +254,8 @@ class AXUtilitiesEvent:
reason = TextEventReason.SELECT_ALL reason = TextEventReason.SELECT_ALL
elif mgr.last_event_was_primary_click_or_release(): elif mgr.last_event_was_primary_click_or_release():
reason = TextEventReason.MOUSE_PRIMARY_BUTTON reason = TextEventReason.MOUSE_PRIMARY_BUTTON
elif mgr.last_event_was_tab_navigation():
reason = TextEventReason.FOCUS_CHANGE
elif AXUtilitiesState.is_editable(obj) or AXUtilitiesRole.is_terminal(obj): elif AXUtilitiesState.is_editable(obj) or AXUtilitiesRole.is_terminal(obj):
if mgr.last_event_was_backspace(): if mgr.last_event_was_backspace():
reason = TextEventReason.BACKSPACE reason = TextEventReason.BACKSPACE
@@ -273,8 +275,6 @@ class AXUtilitiesEvent:
reason = TextEventReason.UNSPECIFIED_COMMAND reason = TextEventReason.UNSPECIFIED_COMMAND
elif mgr.last_event_was_printable_key(): elif mgr.last_event_was_printable_key():
reason = TextEventReason.TYPING reason = TextEventReason.TYPING
elif mgr.last_event_was_tab_navigation():
reason = TextEventReason.FOCUS_CHANGE
elif AXObject.find_ancestor(obj, AXUtilitiesRole.children_are_presentational): elif AXObject.find_ancestor(obj, AXUtilitiesRole.children_are_presentational):
reason = TextEventReason.UI_UPDATE reason = TextEventReason.UI_UPDATE
return reason return reason
+1 -1
View File
@@ -24,4 +24,4 @@
# Cthulhu project: https://git.stormux.org/storm/cthulhu # Cthulhu project: https://git.stormux.org/storm/cthulhu
version = "2026.07.18" version = "2026.07.18"
codeName = "master" codeName = "testing"
+310 -12
View File
@@ -46,6 +46,7 @@ import gi
gi.require_version("Atspi", "2.0") gi.require_version("Atspi", "2.0")
from gi.repository import Atspi from gi.repository import Atspi
from gi.repository import GLib from gi.repository import GLib
import pyatspi
from . import debug from . import debug
from . import focus_manager from . import focus_manager
@@ -61,6 +62,44 @@ from .ax_utilities_application import AXUtilitiesApplication
if TYPE_CHECKING: if TYPE_CHECKING:
from . import keybindings from . import keybindings
_X11_SHIFT_MASK = 1 << int(Atspi.ModifierType.SHIFT)
_X11_SHIFT_LOCK_MASK = 1 << int(Atspi.ModifierType.SHIFTLOCK)
_X11_NUM_LOCK_MASK = 1 << int(Atspi.ModifierType.NUMLOCK)
_X11_CONSUMABLE_MODIFIER_MASKS = (
0,
_X11_SHIFT_MASK,
_X11_SHIFT_LOCK_MASK,
_X11_SHIFT_MASK | _X11_SHIFT_LOCK_MASK,
_X11_NUM_LOCK_MASK,
_X11_SHIFT_MASK | _X11_NUM_LOCK_MASK,
_X11_SHIFT_LOCK_MASK | _X11_NUM_LOCK_MASK,
_X11_SHIFT_MASK | _X11_SHIFT_LOCK_MASK | _X11_NUM_LOCK_MASK,
)
_X11_CONSUMABLE_KEYSYMS = (
"a", "b", "c", "d", "e", "f", "g", "h", "i", "k", "l", "m",
"o", "p", "q", "r", "s", "t", "u", "v", "x", "y",
"1", "2", "3", "4", "5", "6", "comma",
)
def _get_x11_consumable_key_set() -> List[Atspi.KeyDefinition]:
"""Returns AT-SPI definitions for unmodified structural-navigation keys."""
# Importing here avoids a module-level cycle: keybindings obtains this
# manager lazily when bindings install their AT-SPI grabs.
from . import keybindings # pylint: disable=import-outside-toplevel
keycodes = sorted({
keycode
for keysym in _X11_CONSUMABLE_KEYSYMS
if (keycode := keybindings.get_keycodes(keysym)[1])
})
keySet = []
for keycode in keycodes:
definition = Atspi.KeyDefinition()
definition.keycode = keycode
keySet.append(definition)
return keySet
class InputEventManager: class InputEventManager:
"""Provides utilities for managing input events.""" """Provides utilities for managing input events."""
@@ -68,6 +107,11 @@ class InputEventManager:
self._last_input_event: Optional[input_event.InputEvent] = None self._last_input_event: Optional[input_event.InputEvent] = None
self._last_non_modifier_key_event: Optional[input_event.KeyboardEvent] = None self._last_non_modifier_key_event: Optional[input_event.KeyboardEvent] = None
self._device: Optional[Atspi.Device] = None self._device: Optional[Atspi.Device] = None
self._key_pressed_id: int = 0
self._key_released_id: int = 0
self._selective_legacy_listener_active: bool = False
self._selective_legacy_key_set: List[Atspi.KeyDefinition] = []
self._selective_legacy_keycodes: set[int] = set()
self._pointer_moved_id: int = 0 self._pointer_moved_id: int = 0
self._mapped_keycodes: List[int] = [] self._mapped_keycodes: List[int] = []
self._mapped_keysyms: List[int] = [] self._mapped_keysyms: List[int] = []
@@ -77,6 +121,9 @@ class InputEventManager:
self._wnck = None self._wnck = None
self._did_attempt_wnck_load: bool = False self._did_attempt_wnck_load: bool = False
self._scriptWithSuspendedGrabsForXterm = None self._scriptWithSuspendedGrabsForXterm = None
self._xtermFocusScreen = None
self._xtermFocusHandlerId: int = 0
self._xtermRecoverySourceId: int = 0
def activate_device(self) -> Atspi.Device: def activate_device(self) -> Atspi.Device:
"""Creates and returns the AT-SPI device used by this manager.""" """Creates and returns the AT-SPI device used by this manager."""
@@ -101,14 +148,60 @@ class InputEventManager:
msg = "INPUT EVENT MANAGER: Starting key watcher." msg = "INPUT EVENT MANAGER: Starting key watcher."
debug.print_message(debug.LEVEL_INFO, msg, True) debug.print_message(debug.LEVEL_INFO, msg, True)
self.activate_device().add_key_watcher(self.process_keyboard_event) device = self.activate_device()
atspiVersion = Atspi.get_version()
if atspiVersion[0] > 2 or atspiVersion[1] >= 60:
msg = "INPUT EVENT MANAGER: Using AT-SPI key signals."
debug.print_message(debug.LEVEL_INFO, msg, True)
self._key_pressed_id = device.connect("key-pressed", self._on_key_pressed)
self._key_released_id = device.connect("key-released", self._on_key_released)
if os.environ.get("XDG_SESSION_TYPE", "").lower() == "x11":
self._selective_legacy_key_set = _get_x11_consumable_key_set()
if self._selective_legacy_key_set:
self._selective_legacy_keycodes = {
definition.keycode for definition in self._selective_legacy_key_set
}
msg = "INPUT EVENT MANAGER: Adding selective consumable X11 key listener."
debug.print_message(debug.LEVEL_INFO, msg, True)
pyatspi.Registry.registerKeystrokeListener(
self._process_selective_legacy_keyboard_event,
key_set=self._selective_legacy_key_set,
mask=list(_X11_CONSUMABLE_MODIFIER_MASKS),
kind=(Atspi.EventType.KEY_PRESSED_EVENT, Atspi.EventType.KEY_RELEASED_EVENT),
synchronous=True,
preemptive=True,
)
self._selective_legacy_listener_active = True
else:
msg = "INPUT EVENT MANAGER: Using legacy AT-SPI key watcher."
debug.print_message(debug.LEVEL_INFO, msg, True)
device.add_key_watcher(self.process_keyboard_event)
def stop_key_watcher(self) -> None: def stop_key_watcher(self) -> None:
"""Starts the watcher for keyboard input events.""" """Stops the watcher for keyboard input events."""
msg = "INPUT EVENT MANAGER: Stopping key watcher." msg = "INPUT EVENT MANAGER: Stopping key watcher."
debug.print_message(debug.LEVEL_INFO, msg, True) debug.print_message(debug.LEVEL_INFO, msg, True)
if self._selective_legacy_listener_active:
pyatspi.Registry.deregisterKeystrokeListener(
self._process_selective_legacy_keyboard_event,
key_set=self._selective_legacy_key_set,
mask=list(_X11_CONSUMABLE_MODIFIER_MASKS),
kind=(Atspi.EventType.KEY_PRESSED_EVENT, Atspi.EventType.KEY_RELEASED_EVENT),
)
self._selective_legacy_listener_active = False
self._selective_legacy_key_set = []
self._selective_legacy_keycodes.clear()
if self._device is not None:
for handlerId in (self._key_pressed_id, self._key_released_id):
if handlerId:
self._device.disconnect(handlerId)
self._key_pressed_id = 0
self._key_released_id = 0
self._device = None self._device = None
self._stop_xterm_focus_monitor()
self._stop_xterm_recovery_timer()
self._scriptWithSuspendedGrabsForXterm = None
def get_debug_snapshot(self) -> Dict[str, object]: def get_debug_snapshot(self) -> Dict[str, object]:
"""Returns read-only counters useful for long-uptime diagnostics.""" """Returns read-only counters useful for long-uptime diagnostics."""
@@ -228,12 +321,27 @@ class InputEventManager:
return [] return []
grab_ids = [] grab_ids = []
for kd in binding.key_definitions(): try:
grab_id = self._device.add_key_grab(kd, None) for kd in binding.key_definitions():
if grab_id == 0: definitionKeycode = kd.keycode or binding.keycode
continue if self._signal_event_uses_selective_legacy_listener(
grab_ids.append(grab_id) definitionKeycode, kd.modifiers
self._grabbed_bindings[grab_id] = binding ):
continue
grab_id = self._device.add_key_grab(kd, None)
if grab_id == 0:
continue
grab_ids.append(grab_id)
self._grabbed_bindings[grab_id] = binding
except Exception:
for grab_id in grab_ids:
try:
self._device.remove_key_grab(grab_id)
except Exception as error:
msg = f"INPUT EVENT MANAGER: Could not roll back key grab {grab_id}: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
self._grabbed_bindings.pop(grab_id, None)
raise
return grab_ids return grab_ids
@@ -427,6 +535,7 @@ class InputEventManager:
screen = wnck.Screen.get_default() screen = wnck.Screen.get_default()
if screen is None: if screen is None:
return None return None
self._ensure_xterm_focus_monitor(screen)
screen.force_update() screen.force_update()
return screen.get_active_window() return screen.get_active_window()
except Exception as error: except Exception as error:
@@ -496,10 +605,118 @@ class InputEventManager:
] ]
debug.print_tokens(debug.LEVEL_INFO, tokens, True) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
def _stop_xterm_focus_monitor(self) -> None:
"""Stops monitoring X11 focus changes used for XTerm grab recovery."""
screen = self._xtermFocusScreen
handlerId = self._xtermFocusHandlerId
self._xtermFocusScreen = None
self._xtermFocusHandlerId = 0
if screen is None or not handlerId:
return
try:
screen.disconnect(handlerId)
except Exception as error:
msg = f"INPUT EVENT MANAGER: Could not stop XTerm focus monitor: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
def _ensure_xterm_focus_monitor(self, screen: Any) -> None:
"""Monitors X11 focus so suspended grabs recover without another key event."""
if screen is self._xtermFocusScreen and self._xtermFocusHandlerId:
return
self._stop_xterm_focus_monitor()
try:
handlerId = screen.connect(
"active-window-changed",
self._on_active_x11_window_changed,
)
except Exception as error:
msg = f"INPUT EVENT MANAGER: Could not start XTerm focus monitor: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return
self._xtermFocusScreen = screen
self._xtermFocusHandlerId = handlerId
def _stop_xterm_recovery_timer(self) -> None:
"""Stops the fallback timer used to recover XTerm-suspended grabs."""
sourceId = self._xtermRecoverySourceId
self._xtermRecoverySourceId = 0
if not sourceId:
return
try:
GLib.source_remove(sourceId)
except GLib.GError as error:
msg = f"INPUT EVENT MANAGER: Could not stop XTerm recovery timer: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
def _ensure_xterm_recovery_timer(self) -> None:
"""Starts fallback recovery when X11 focus notifications are insufficient."""
if self._device is None or self._xtermRecoverySourceId:
return
self._xtermRecoverySourceId = GLib.timeout_add(
100,
self._poll_xterm_grab_recovery,
)
def _poll_xterm_grab_recovery(self) -> bool:
"""Checks whether XTerm-suspended grabs can now be restored."""
if self._scriptWithSuspendedGrabsForXterm is None:
self._xtermRecoverySourceId = 0
return False
match = self._active_x11_window_xterm_match()
tokens = ["INPUT EVENT MANAGER: XTerm recovery matcher returned", match]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
if match is False:
self._xtermRecoverySourceId = 0
self._restore_key_grabs_after_xterm()
return False
keepPolling = not self._xtermFocusHandlerId or match is None
if not keepPolling:
self._xtermRecoverySourceId = 0
return keepPolling
def _on_active_x11_window_changed(self, screen: Any, _previousWindow: Any) -> None:
"""Restores suspended grabs as soon as X11 focus leaves XTerm."""
if self._scriptWithSuspendedGrabsForXterm is None:
return
try:
window = screen.get_active_window()
except Exception as error:
msg = f"INPUT EVENT MANAGER: Could not check changed X11 focus: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
self._ensure_xterm_recovery_timer()
return
match = self._x11_window_xterm_match(window)
tokens = ["INPUT EVENT MANAGER: XTerm focus-change matcher returned", match]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
if match is False:
self._restore_key_grabs_after_xterm()
elif match is None:
self._ensure_xterm_recovery_timer()
def _active_x11_window_xterm_match(self) -> Optional[bool]: def _active_x11_window_xterm_match(self) -> Optional[bool]:
"""Returns whether the active X11 window is XTerm, or None when unknown.""" """Returns whether the active X11 window is XTerm, or None when unknown."""
window = self._get_active_x11_window() window = self._get_active_x11_window()
return self._x11_window_xterm_match(window)
def _x11_window_xterm_match(self, window: Any) -> Optional[bool]:
"""Returns whether window is XTerm, or None when it cannot be identified."""
if window is None: if window is None:
msg = "INPUT EVENT MANAGER: XTerm matcher cannot identify active X11 window." msg = "INPUT EVENT MANAGER: XTerm matcher cannot identify active X11 window."
debug.print_message(debug.LEVEL_INFO, msg, True) debug.print_message(debug.LEVEL_INFO, msg, True)
@@ -724,12 +941,13 @@ class InputEventManager:
debug.print_tokens(debug.LEVEL_INFO, tokens, True) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
removeGrabs() removeGrabs()
self._scriptWithSuspendedGrabsForXterm = script self._scriptWithSuspendedGrabsForXterm = script
if not self._xtermFocusHandlerId:
self._ensure_xterm_recovery_timer()
def _restore_key_grabs_after_xterm(self) -> None: def _restore_key_grabs_after_xterm(self) -> None:
"""Restores key grabs after leaving XTerm.""" """Restores key grabs after leaving XTerm."""
script = self._scriptWithSuspendedGrabsForXterm script = self._scriptWithSuspendedGrabsForXterm
self._scriptWithSuspendedGrabsForXterm = None
if script is None: if script is None:
return return
@@ -741,6 +959,8 @@ class InputEventManager:
activeScript, activeScript,
] ]
debug.print_tokens(debug.LEVEL_INFO, tokens, True) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
self._scriptWithSuspendedGrabsForXterm = None
self._stop_xterm_recovery_timer()
return return
addGrabs = getattr(script, "addKeyGrabs", None) addGrabs = getattr(script, "addKeyGrabs", None)
@@ -752,10 +972,88 @@ class InputEventManager:
script, script,
] ]
debug.print_tokens(debug.LEVEL_INFO, tokens, True) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
addGrabs() try:
addGrabs()
except Exception as error:
msg = f"INPUT EVENT MANAGER: Could not restore XTerm-suspended key grabs: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
removeGrabs = getattr(script, "removeKeyGrabs", None)
if callable(removeGrabs):
try:
removeGrabs()
except Exception as rollbackError:
msg = (
"INPUT EVENT MANAGER: Could not roll back partial XTerm grab "
f"restoration: {rollbackError}"
)
debug.print_message(debug.LEVEL_INFO, msg, True)
self._scriptWithSuspendedGrabsForXterm = None
self._stop_xterm_recovery_timer()
return
self._ensure_xterm_recovery_timer()
return
self._scriptWithSuspendedGrabsForXterm = None
self._stop_xterm_recovery_timer()
# pylint: disable=too-many-arguments # pylint: disable=too-many-arguments
# pylint: disable=too-many-positional-arguments # pylint: disable=too-many-positional-arguments
def _process_selective_legacy_keyboard_event(self, event: Atspi.DeviceEvent) -> bool:
"""Returns an X11 consume decision for unmodified browse-navigation keys."""
pressed = event.type == Atspi.EventType.KEY_PRESSED_EVENT
return self.process_keyboard_event(
self._device,
pressed,
event.hw_code,
event.id,
event.modifiers,
event.event_string or "",
)
def _signal_event_uses_selective_legacy_listener(self, keycode: int, modifiers: int) -> bool:
"""Returns True if the X11 compatibility listener owns this event."""
return (
self._selective_legacy_listener_active
and keycode in self._selective_legacy_keycodes
and modifiers in _X11_CONSUMABLE_MODIFIER_MASKS
)
def _on_key_pressed(
self,
device: Atspi.Device,
keycode: int,
keysym: int,
modifiers: int,
text: str,
) -> None:
"""Processes a key-pressed signal from AT-SPI 2.60 and later."""
if self._signal_event_uses_selective_legacy_listener(keycode, modifiers):
msg = "INPUT EVENT MANAGER: Deferring key-pressed signal to X11 consumable listener."
debug.print_message(debug.LEVEL_INFO, msg, True)
return
self.process_keyboard_event(device, True, keycode, keysym, modifiers, text)
def _on_key_released(
self,
device: Atspi.Device,
keycode: int,
keysym: int,
modifiers: int,
text: str,
) -> None:
"""Processes a key-released signal from AT-SPI 2.60 and later."""
if self._signal_event_uses_selective_legacy_listener(keycode, modifiers):
msg = "INPUT EVENT MANAGER: Deferring key-released signal to X11 consumable listener."
debug.print_message(debug.LEVEL_INFO, msg, True)
return
self.process_keyboard_event(device, False, keycode, keysym, modifiers, text)
def process_keyboard_event( def process_keyboard_event(
self, self,
_device: Atspi.Device, _device: Atspi.Device,
@@ -849,7 +1147,7 @@ class InputEventManager:
event._finalize_initialization() event._finalize_initialization()
event.set_click_count(self._determine_keyboard_event_click_count(event)) event.set_click_count(self._determine_keyboard_event_click_count(event))
event.process() consumed = event.process()
if event.is_modifier_key(): if event.is_modifier_key():
if self.is_release_for(event, self._last_input_event): if self.is_release_for(event, self._last_input_event):
@@ -859,7 +1157,7 @@ class InputEventManager:
else: else:
self._last_non_modifier_key_event = event self._last_non_modifier_key_event = event
self._last_input_event = event self._last_input_event = event
return True return consumed
# pylint: enable=too-many-arguments # pylint: enable=too-many-arguments
# pylint: enable=too-many-positional-arguments # pylint: enable=too-many-positional-arguments
@@ -34,6 +34,7 @@ __license__ = "LGPL"
from cthulhu import debug from cthulhu import debug
from cthulhu import cthulhu from cthulhu import cthulhu
from cthulhu import cthulhu_state from cthulhu import cthulhu_state
from cthulhu import keybindings
from cthulhu.ax_object import AXObject from cthulhu.ax_object import AXObject
from cthulhu.ax_utilities import AXUtilities from cthulhu.ax_utilities import AXUtilities
from cthulhu.scripts import default from cthulhu.scripts import default
@@ -49,6 +50,7 @@ class Script(web.Script):
super().__init__(app) super().__init__(app)
self.presentIfInactive = False self.presentIfInactive = False
self._lastAutocompletePopupItem = None
def getBrailleGenerator(self): def getBrailleGenerator(self):
"""Returns the braille generator for this script.""" """Returns the braille generator for this script."""
@@ -325,7 +327,9 @@ class Script(web.Script):
if AXUtilities.is_frame(parent) and not AXObject.get_name(parent): if AXUtilities.is_frame(parent) and not AXObject.get_name(parent):
msg = "CHROMIUM: Event source believed to be in autocomplete popup" msg = "CHROMIUM: Event source believed to be in autocomplete popup"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
cthulhu_state.locusOfFocus = event.source if event.source != self._lastAutocompletePopupItem:
self.presentObject(event.source, interrupt=True)
self._lastAutocompletePopupItem = event.source
return return
msg = "CHROMIUM: Passing along event to default script" msg = "CHROMIUM: Passing along event to default script"
@@ -416,6 +420,22 @@ class Script(web.Script):
if not self.utilities.canBeActiveWindow(event.source): if not self.utilities.canBeActiveWindow(event.source):
return return
autocomplete = self.utilities.autocompletePopupForFrame(event.source)
if autocomplete:
msg = "CHROMIUM: Treating nameless listbox frame as autocomplete popup"
debug.printMessage(debug.LEVEL_INFO, msg, True)
selected = self.utilities.selectedChildren(autocomplete)
activeItem = selected[0] if len(selected) == 1 else None
if activeItem is None:
activeItem = AXUtilities.get_focused_object(autocomplete)
activeItem = activeItem or autocomplete
tokens = ["CHROMIUM: Presenting active autocomplete popup item", activeItem]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
self.presentObject(activeItem, interrupt=True)
self._lastAutocompletePopupItem = activeItem
return
# If this is a frame for a popup menu, we don't want to treat # If this is a frame for a popup menu, we don't want to treat
# it like a proper window:activate event because it's not as # it like a proper window:activate event because it's not as
# far as the end-user experience is concerned. # far as the end-user experience is concerned.
@@ -460,6 +480,30 @@ class Script(web.Script):
def onWindowDeactivated(self, event): def onWindowDeactivated(self, event):
"""Callback for window:deactivate accessibility events.""" """Callback for window:deactivate accessibility events."""
if self.utilities.autocompletePopupForFrame(event.source):
msg = "CHROMIUM: Ignoring autocomplete popup deactivation"
debug.printMessage(debug.LEVEL_INFO, msg, True)
self._lastAutocompletePopupItem = None
return
focus = cthulhu_state.locusOfFocus
isAutocompleteCombo = AXUtilities.is_combo_box(focus) \
and AXUtilities.supports_autocompletion(focus)
if isAutocompleteCombo:
lastKey, mods = self.utilities.lastKeyAndModifiers()
if lastKey in ("Down", "Up") and mods & keybindings.ALT_MODIFIER_MASK:
msg = "CHROMIUM: Preserving state while opening autocomplete popup"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return
app = AXObject.get_application(event.source)
for child in AXObject.iter_children(app):
popup = self.utilities.autocompletePopupForFrame(child)
if popup and AXUtilities.is_showing(child):
msg = "CHROMIUM: Preserving state during autocomplete popup activation"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return
if super().onWindowDeactivated(event): if super().onWindowDeactivated(event):
return return
@@ -170,6 +170,20 @@ class Utilities(web.Utilities):
debug.printTokens(debug.LEVEL_INFO, tokens, True) debug.printTokens(debug.LEVEL_INFO, tokens, True)
return menu return menu
def autocompletePopupForFrame(self, obj):
"""Returns the listbox in a Chromium autocomplete popup frame."""
if not AXUtilities.is_frame(obj) or AXObject.get_name(obj):
return None
# Native datalist/autofill popups are exposed as a direct listbox child
# of a separate nameless frame, without a popup-for relation.
if AXObject.get_child_count(obj) != 1:
return None
child = AXObject.get_child(obj, 0)
return child if AXUtilities.is_list_box(child) else None
def topLevelObject(self, obj, useFallbackSearch=False): def topLevelObject(self, obj, useFallbackSearch=False):
if not obj: if not obj:
return None return None
+136 -35
View File
@@ -60,6 +60,7 @@ from cthulhu.scripts import default
from cthulhu.ax_object import AXObject from cthulhu.ax_object import AXObject
from cthulhu.ax_text import AXText from cthulhu.ax_text import AXText
from cthulhu.ax_utilities import AXUtilities from cthulhu.ax_utilities import AXUtilities
from cthulhu.ax_utilities_event import AXUtilitiesEvent, TextEventReason
from .bookmarks import Bookmarks from .bookmarks import Bookmarks
from .braille_generator import BrailleGenerator from .braille_generator import BrailleGenerator
@@ -172,8 +173,12 @@ class Script(default.Script):
obj, obj,
offset, offset,
focusOverride=None, focusOverride=None,
): isCaretSelection: bool | None = None,
if not self.utilities.lastInputEventWasCaretNavWithSelection(): ) -> bool:
if isCaretSelection is None:
isCaretSelection = self.utilities.lastInputEventWasCaretNavWithSelection()
if not isCaretSelection:
return False return False
if focusOverride is not None: if focusOverride is not None:
@@ -2104,6 +2109,7 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return False return False
reason = AXUtilitiesEvent.get_text_event_reason(event)
obj, offset = self.utilities.getCaretContext(document, False, False) obj, offset = self.utilities.getCaretContext(document, False, False)
tokens = ["WEB: Context: ", obj, ", ", offset, "(focus: ", cthulhu_state.locusOfFocus, ")"] tokens = ["WEB: Context: ", obj, ", ", offset, "(focus: ", cthulhu_state.locusOfFocus, ")"]
debug.printTokens(debug.LEVEL_INFO, tokens, True) debug.printTokens(debug.LEVEL_INFO, tokens, True)
@@ -2156,14 +2162,10 @@ class Script(default.Script):
self.updateBraille(event.source) self.updateBraille(event.source)
return True return True
if self.utilities.lastInputEventWasTab(): if reason == TextEventReason.FOCUS_CHANGE:
msg = "WEB: Last input event was Tab." msg = "WEB: Event ignored: Caret moved due to focus change."
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if self.utilities.isDocument(event.source):
msg = "WEB: Event ignored: Caret moved in document due to Tab."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if self.utilities.inFindContainer(): if self.utilities.inFindContainer():
msg = "WEB: Event handled: Presenting find results" msg = "WEB: Event handled: Presenting find results"
@@ -2177,14 +2179,18 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return True return True
if self.utilities.textEventIsDueToInsertion(event): typingReasons = (TextEventReason.TYPING, TextEventReason.TYPING_ECHOABLE)
msg = "WEB: Event handled: Updating position due to insertion" if reason in typingReasons \
and event.source == cthulhu_state.locusOfFocus \
and AXUtilities.is_editable(event.source):
msg = f"WEB: Event handled: Updating position due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
self._saveLastCursorPosition(event.source, event.detail1) self._saveLastCursorPosition(event.source, event.detail1)
return True return True
if self.utilities.textEventIsDueToDeletion(event): deletionReasons = (TextEventReason.BACKSPACE, TextEventReason.DELETE)
msg = "WEB: Event handled: Updating position due to deletion" if reason in deletionReasons and AXUtilities.is_editable(event.source):
msg = f"WEB: Event handled: Updating position due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
self._saveLastCursorPosition(event.source, event.detail1) self._saveLastCursorPosition(event.source, event.detail1)
return True return True
@@ -2887,7 +2893,7 @@ class Script(default.Script):
self._currentTextAttrs = {} self._currentTextAttrs = {}
return False return False
def onTextDeleted(self, event): def onTextDeleted(self, event: Atspi.Event) -> bool:
"""Callback for object:text-changed:delete accessibility events.""" """Callback for object:text-changed:delete accessibility events."""
if self.utilities.isZombie(event.source): if self.utilities.isZombie(event.source):
@@ -2895,26 +2901,52 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return True return True
reason = AXUtilitiesEvent.get_text_event_reason(event)
if reason == TextEventReason.PAGE_SWITCH:
msg = f"WEB: Deletion is due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
# UI and live-region classification precedes page-switch classification.
# Preserve page-switch ownership when those conditions overlap.
if self.utilities.lastInputEventWasPageSwitch(): if self.utilities.lastInputEventWasPageSwitch():
msg = "WEB: Deletion is believed to be due to page switch" msg = "WEB: Deletion is due to legacy page-switch fallback"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return True return True
if reason == TextEventReason.LIVE_REGION_UPDATE:
msg = f"WEB: Ignoring deletion due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
# Preserve web-specific live-region detection for values the shared
# classifier does not currently identify.
if self.utilities.isLiveRegion(event.source): if self.utilities.isLiveRegion(event.source):
msg = "WEB: Ignoring deletion from live region" msg = "WEB: Ignoring deletion from legacy live-region fallback"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if self.utilities.eventIsBrowserUINoise(event):
msg = "WEB: Ignoring event believed to be browser UI noise"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return True return True
if not self.utilities.inDocumentContent(event.source): if not self.utilities.inDocumentContent(event.source):
msg = "WEB: Event source is not in document content" msg = "WEB: Event source is not in document content"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
if reason == TextEventReason.UI_UPDATE:
msg = f"WEB: Ignoring browser UI event due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if self.utilities.eventIsBrowserUINoise(event):
msg = "WEB: Ignoring event believed to be browser UI noise"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
return False return False
if reason == TextEventReason.SPIN_BUTTON_VALUE_CHANGE:
msg = f"WEB: Ignoring event due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if self.utilities.eventIsSpinnerNoise(event): if self.utilities.eventIsSpinnerNoise(event):
msg = "WEB: Ignoring: Event believed to be spinner noise" msg = "WEB: Ignoring: Event believed to be spinner noise"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
@@ -2929,13 +2961,26 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
self.utilities.clearContentCache() self.utilities.clearContentCache()
if self.utilities.textEventIsDueToDeletion(event): deletionReasons = (TextEventReason.BACKSPACE, TextEventReason.DELETE)
msg = "WEB: Event believed to be due to editable text deletion" if reason in deletionReasons \
and AXUtilities.is_editable(event.source) \
and (reason == TextEventReason.DELETE or not self.utilities.isHidden(event.source)):
msg = f"WEB: Event is due to editable text deletion: {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return False return False
if self.utilities.textEventIsDueToInsertion(event): inputEvent = cthulhu_state.lastNonModifierKeyEvent
msg = "WEB: Ignoring event believed to be due to text insertion" unmodifiedPrintableInput = \
isinstance(cthulhu_state.lastInputEvent, input_event.KeyboardEvent) \
and inputEvent \
and inputEvent.is_printable_key() \
and not inputEvent.modifiers
typingReasons = (TextEventReason.TYPING, TextEventReason.TYPING_ECHOABLE)
if reason in typingReasons \
and event.source == cthulhu_state.locusOfFocus \
and AXUtilities.is_editable(event.source) \
and unmodifiedPrintableInput:
msg = f"WEB: Ignoring insertion-caused deletion: {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return True return True
@@ -2988,13 +3033,33 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return True return True
if self.utilities.lastInputEventWasPageSwitch(): reason = AXUtilitiesEvent.get_text_event_reason(event)
msg = "WEB: Insertion is believed to be due to page switch" if reason == TextEventReason.PAGE_SWITCH:
msg = f"WEB: Insertion is due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return True return True
# UI and live-region classification precedes page-switch classification.
# Preserve page-switch ownership when those conditions overlap.
if self.utilities.lastInputEventWasPageSwitch():
msg = "WEB: Insertion is due to legacy page-switch fallback"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if reason == TextEventReason.LIVE_REGION_UPDATE:
if self.utilities.handleAsLiveRegion(event):
msg = f"WEB: Event handled as {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
self.liveRegionManager.handleEvent(event)
else:
msg = f"WEB: Ignoring unpresented {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
# Preserve web-specific live-region detection for values the shared
# classifier does not currently identify, such as container-live=off.
if self.utilities.handleAsLiveRegion(event): if self.utilities.handleAsLiveRegion(event):
msg = "WEB: Event to be handled as live region" msg = "WEB: Event handled by legacy live-region fallback"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
self.liveRegionManager.handleEvent(event) self.liveRegionManager.handleEvent(event)
return True return True
@@ -3004,21 +3069,39 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return True return True
if self.utilities.eventIsEOCAdded(event): if reason == TextEventReason.CHILDREN_CHANGE:
msg = "WEB: Ignoring: Event was for embedded object char" msg = f"WEB: Ignoring event due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return True return True
if self.utilities.eventIsBrowserUINoise(event): # Preserve the broader legacy match for whitespace plus embedded
msg = "WEB: Ignoring event believed to be browser UI noise" # object characters until the shared classifier owns that pattern.
if self.utilities.eventIsEOCAdded(event):
msg = "WEB: Ignoring: Event was for embedded object char"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return True return True
if not self.utilities.inDocumentContent(event.source): if not self.utilities.inDocumentContent(event.source):
msg = "WEB: Event source is not in document content" msg = "WEB: Event source is not in document content"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
if reason == TextEventReason.UI_UPDATE:
msg = f"WEB: Ignoring browser UI event due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if self.utilities.eventIsBrowserUINoise(event):
msg = "WEB: Ignoring event believed to be browser UI noise"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
return False return False
if reason == TextEventReason.SPIN_BUTTON_VALUE_CHANGE:
msg = f"WEB: Ignoring event due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if self.utilities.eventIsSpinnerNoise(event): if self.utilities.eventIsSpinnerNoise(event):
msg = "WEB: Ignoring: Event believed to be spinner noise" msg = "WEB: Ignoring: Event believed to be spinner noise"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
@@ -3029,6 +3112,11 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return True return True
if reason == TextEventReason.AUTO_INSERTION_PRESENTABLE:
msg = f"WEB: Deferring presentable event due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
msg = "WEB: Clearing content cache due to text insertion" msg = "WEB: Clearing content cache due to text insertion"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
self.utilities.clearContentCache() self.utilities.clearContentCache()
@@ -3097,6 +3185,8 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return False return False
reason = AXUtilitiesEvent.get_text_event_reason(event)
if not self.utilities.inDocumentContent(cthulhu_state.locusOfFocus): if not self.utilities.inDocumentContent(cthulhu_state.locusOfFocus):
tokens = ["WEB: Event ignored: locusOfFocus", cthulhu_state.locusOfFocus, tokens = ["WEB: Event ignored: locusOfFocus", cthulhu_state.locusOfFocus,
"is not in document content"] "is not in document content"]
@@ -3124,8 +3214,18 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return True return True
if self.utilities.lastInputEventWasCaretNavWithSelection() \ selectionReasons = (
and not AXText.get_selected_ranges(event.source): TextEventReason.SELECTION_BY_CHARACTER,
TextEventReason.SELECTION_BY_LINE,
TextEventReason.SELECTION_BY_PARAGRAPH,
TextEventReason.SELECTION_BY_PAGE,
TextEventReason.SELECTION_BY_WORD,
TextEventReason.SELECTION_TO_FILE_BOUNDARY,
TextEventReason.SELECTION_TO_LINE_BOUNDARY,
TextEventReason.UNSPECIFIED_SELECTION,
)
isSelectionReason = reason in selectionReasons
if isSelectionReason and not AXText.get_selected_ranges(event.source):
document = self.utilities.getTopLevelDocumentForObject(event.source) document = self.utilities.getTopLevelDocumentForObject(event.source)
obj, offset = self.utilities.getCaretContext(document, False, False) obj, offset = self.utilities.getCaretContext(document, False, False)
focusOffset = AXText.get_caret_offset(event.source) focusOffset = AXText.get_caret_offset(event.source)
@@ -3135,6 +3235,7 @@ class Script(default.Script):
obj, obj,
offset, offset,
focusOverride=(event.source, focusOffset), focusOverride=(event.source, focusOffset),
isCaretSelection=True,
): ):
msg = "WEB: Event handled: synthetic selection from event source" msg = "WEB: Event handled: synthetic selection from event source"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
@@ -3155,7 +3256,7 @@ class Script(default.Script):
offset = AXText.get_caret_offset(event.source) offset = AXText.get_caret_offset(event.source)
char = AXText.get_substring(event.source, offset, offset + 1) char = AXText.get_substring(event.source, offset, offset + 1)
if char == self.EMBEDDED_OBJECT_CHARACTER \ if char == self.EMBEDDED_OBJECT_CHARACTER \
and not self.utilities.lastInputEventWasCaretNavWithSelection() \ and not isSelectionReason \
and not self.utilities.lastInputEventWasCommand(): and not self.utilities.lastInputEventWasCommand():
msg = "WEB: Ignoring: Not selecting and event offset is at embedded object" msg = "WEB: Ignoring: Not selecting and event offset is at embedded object"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
@@ -40,7 +40,6 @@ import time
import urllib import urllib
from cthulhu import debug from cthulhu import debug
from cthulhu import input_event
from cthulhu import input_event_manager from cthulhu import input_event_manager
from cthulhu import messages from cthulhu import messages
from cthulhu import cthulhu from cthulhu import cthulhu
@@ -4682,32 +4681,6 @@ class Utilities(script_utilities.Utilities):
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return False return False
def textEventIsDueToDeletion(self, event):
if not self.inDocumentContent(event.source) \
or not AXUtilities.is_editable(event.source):
return False
if self.isDeleteCommandTextDeletionEvent(event) \
or self.isBackSpaceCommandTextDeletionEvent(event):
return True
return False
def textEventIsDueToInsertion(self, event):
if not event.type.startswith("object:text-"):
return False
if not self.inDocumentContent(event.source) \
or event.source != cthulhu_state.locusOfFocus \
or not AXUtilities.is_editable(event.source):
return False
if isinstance(cthulhu_state.lastInputEvent, input_event.KeyboardEvent):
inputEvent = cthulhu_state.lastNonModifierKeyEvent
return inputEvent and inputEvent.is_printable_key() and not inputEvent.modifiers
return False
def textEventIsForNonNavigableTextObject(self, event): def textEventIsForNonNavigableTextObject(self, event):
if not event.type.startswith("object:text-"): if not event.type.startswith("object:text-"):
return False return False
+183
View File
@@ -8,6 +8,9 @@ from gi.repository import Atspi
from cthulhu import ax_object from cthulhu import ax_object
from cthulhu import cthulhu_state from cthulhu import cthulhu_state
from cthulhu import focus_manager from cthulhu import focus_manager
from cthulhu.scripts import default
from cthulhu.scripts.toolkits.Chromium import script as chromium_script
from cthulhu.scripts.toolkits.Chromium import script_utilities as chromium_utilities
class ChromiumOmniboxRegressionTests(unittest.TestCase): class ChromiumOmniboxRegressionTests(unittest.TestCase):
@@ -63,6 +66,186 @@ class ChromiumOmniboxRegressionTests(unittest.TestCase):
self.assertIs(manager.get_active_window(), activeWindow) self.assertIs(manager.get_active_window(), activeWindow)
self.assertIs(cthulhu_state.activeWindow, activeWindow) self.assertIs(cthulhu_state.activeWindow, activeWindow)
def test_detects_nameless_frame_with_listbox_as_autocomplete_popup(self):
utilities = chromium_utilities.Utilities.__new__(chromium_utilities.Utilities)
frame = object()
listbox = object()
with (
mock.patch.object(chromium_utilities.AXUtilities, "is_frame", return_value=True),
mock.patch.object(chromium_utilities.AXObject, "get_name", return_value=""),
mock.patch.object(chromium_utilities.AXObject, "get_child_count", return_value=1),
mock.patch.object(chromium_utilities.AXObject, "get_child", return_value=listbox),
mock.patch.object(chromium_utilities.AXUtilities, "is_list_box", return_value=True),
):
result = utilities.autocompletePopupForFrame(frame)
self.assertIs(result, listbox)
def test_does_not_treat_nested_listbox_as_autocomplete_popup(self):
utilities = chromium_utilities.Utilities.__new__(chromium_utilities.Utilities)
frame = object()
with (
mock.patch.object(chromium_utilities.AXUtilities, "is_frame", return_value=True),
mock.patch.object(chromium_utilities.AXObject, "get_name", return_value=""),
mock.patch.object(chromium_utilities.AXObject, "get_child_count", return_value=2),
):
result = utilities.autocompletePopupForFrame(frame)
self.assertIsNone(result)
@staticmethod
def _make_chromium_script():
testScript = chromium_script.Script.__new__(chromium_script.Script)
testScript.utilities = mock.Mock()
testScript.utilities.canBeActiveWindow.return_value = True
testScript.utilities.autocompletePopupForFrame.return_value = None
testScript.utilities.popupMenuForFrame.return_value = None
testScript._lastAutocompletePopupItem = None
testScript.presentObject = mock.Mock()
return testScript
def test_autocomplete_popup_activation_presents_item_without_frame_fallback(self):
testScript = self._make_chromium_script()
frame = object()
listbox = object()
item = object()
event = mock.Mock(source=frame)
testScript.utilities.autocompletePopupForFrame.return_value = listbox
testScript.utilities.selectedChildren.return_value = [item]
with (
mock.patch.object(chromium_script.AXUtilities, "get_focused_object", return_value=item),
mock.patch.object(chromium_script.cthulhu, "setActiveWindow") as setActiveWindow,
mock.patch.object(chromium_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus,
mock.patch.object(default.Script, "onWindowActivated") as defaultHandler,
):
chromium_script.Script.onWindowActivated(testScript, event)
setActiveWindow.assert_not_called()
setLocusOfFocus.assert_not_called()
testScript.presentObject.assert_called_once_with(item, interrupt=True)
self.assertIs(testScript._lastAutocompletePopupItem, item)
defaultHandler.assert_not_called()
def test_autocomplete_popup_activation_uses_focused_item_when_none_selected(self):
testScript = self._make_chromium_script()
frame = object()
listbox = object()
item = object()
event = mock.Mock(source=frame)
testScript.utilities.autocompletePopupForFrame.return_value = listbox
testScript.utilities.selectedChildren.return_value = []
with mock.patch.object(
chromium_script.AXUtilities,
"get_focused_object",
return_value=item,
):
chromium_script.Script.onWindowActivated(testScript, event)
testScript.presentObject.assert_called_once_with(item, interrupt=True)
self.assertIs(testScript._lastAutocompletePopupItem, item)
def test_autocomplete_popup_selection_is_presented_without_changing_document_focus(self):
testScript = self._make_chromium_script()
comboBox = object()
listbox = object()
frame = object()
item = object()
event = mock.Mock(source=item, detail1=True)
cthulhu_state.locusOfFocus = comboBox
testScript.utilities.inDocumentContent.return_value = False
with (
mock.patch.object(chromium_script.web.Script, "onSelectedChanged", return_value=False),
mock.patch.object(
chromium_script.AXObject,
"find_ancestor_inclusive",
return_value=listbox,
),
mock.patch.object(chromium_script.AXObject, "get_parent", return_value=frame),
mock.patch.object(chromium_script.AXObject, "get_name", return_value=""),
mock.patch.object(chromium_script.AXUtilities, "is_frame", return_value=True),
):
chromium_script.Script.onSelectedChanged(testScript, event)
chromium_script.Script.onSelectedChanged(testScript, event)
self.assertIs(cthulhu_state.locusOfFocus, comboBox)
testScript.presentObject.assert_called_once_with(item, interrupt=True)
def test_main_window_deactivation_for_autocomplete_popup_preserves_state(self):
testScript = self._make_chromium_script()
mainFrame = object()
comboBox = object()
event = mock.Mock(source=mainFrame)
cthulhu_state.locusOfFocus = comboBox
testScript.utilities.lastKeyAndModifiers.return_value = ("Down", 8)
with (
mock.patch.object(chromium_script.AXUtilities, "is_combo_box", return_value=True),
mock.patch.object(
chromium_script.AXUtilities,
"supports_autocompletion",
return_value=True,
),
mock.patch.object(default.Script, "onWindowDeactivated") as defaultHandler,
):
chromium_script.Script.onWindowDeactivated(testScript, event)
defaultHandler.assert_not_called()
def test_unrelated_autocomplete_window_deactivation_reaches_default(self):
testScript = self._make_chromium_script()
mainFrame = object()
comboBox = object()
app = object()
event = mock.Mock(source=mainFrame)
cthulhu_state.locusOfFocus = comboBox
testScript.utilities.lastKeyAndModifiers.return_value = ("w", 4)
with (
mock.patch.object(chromium_script.AXUtilities, "is_combo_box", return_value=True),
mock.patch.object(
chromium_script.AXUtilities,
"supports_autocompletion",
return_value=True,
),
mock.patch.object(chromium_script.AXObject, "get_application", return_value=app),
mock.patch.object(chromium_script.AXObject, "iter_children", return_value=iter(())),
mock.patch.object(chromium_script.web.Script, "onWindowDeactivated", return_value=False),
mock.patch.object(default.Script, "onWindowDeactivated") as defaultHandler,
):
chromium_script.Script.onWindowDeactivated(testScript, event)
defaultHandler.assert_called_once_with(testScript, event)
def test_unmodified_arrow_deactivation_reaches_default(self):
testScript = self._make_chromium_script()
mainFrame = object()
comboBox = object()
app = object()
event = mock.Mock(source=mainFrame)
cthulhu_state.locusOfFocus = comboBox
testScript.utilities.lastKeyAndModifiers.return_value = ("Down", 0)
with (
mock.patch.object(chromium_script.AXUtilities, "is_combo_box", return_value=True),
mock.patch.object(
chromium_script.AXUtilities,
"supports_autocompletion",
return_value=True,
),
mock.patch.object(chromium_script.AXObject, "get_application", return_value=app),
mock.patch.object(chromium_script.AXObject, "iter_children", return_value=iter(())),
mock.patch.object(chromium_script.web.Script, "onWindowDeactivated", return_value=False),
mock.patch.object(default.Script, "onWindowDeactivated") as defaultHandler,
):
chromium_script.Script.onWindowDeactivated(testScript, event)
defaultHandler.assert_called_once_with(testScript, event)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -7,13 +7,33 @@ from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
import cthulhu as cthulhu_package
from cthulhu import cthulhu_state from cthulhu import cthulhu_state
from cthulhu import cthulhu
from cthulhu import compositor_state_adapter from cthulhu import compositor_state_adapter
from cthulhu import compositor_state_types from cthulhu import compositor_state_types
from cthulhu import compositor_state_wayland from cthulhu import compositor_state_wayland
stubCthulhu = types.ModuleType("cthulhu.cthulhu")
stubCthulhu.cthulhuApp = mock.Mock()
previousCthulhuModule = sys.modules.get("cthulhu.cthulhu")
sys.modules.setdefault("cthulhu.cthulhu", stubCthulhu)
from cthulhu import event_manager
from cthulhu.wayland_protocols import ext_workspace_v1 from cthulhu.wayland_protocols import ext_workspace_v1
if previousCthulhuModule is None:
sys.modules.pop("cthulhu.cthulhu", None)
if getattr(cthulhu_package, "cthulhu", None) is stubCthulhu:
delattr(cthulhu_package, "cthulhu")
from cthulhu import cthulhu as restoredCthulhuModule
else:
sys.modules["cthulhu.cthulhu"] = previousCthulhuModule
cthulhu_package.cthulhu = previousCthulhuModule
restoredCthulhuModule = previousCthulhuModule
event_manager.cthulhu = restoredCthulhuModule
class FakeWorkspaceBackend: class FakeWorkspaceBackend:
def __init__(self, available: bool, name: str) -> None: def __init__(self, available: bool, name: str) -> None:
@@ -313,23 +333,38 @@ class CompositorStateAdapterRegressionTests(unittest.TestCase):
adapter = mock.Mock() adapter = mock.Mock()
adapter.sync_accessible_context = mock.Mock(return_value=None) adapter.sync_accessible_context = mock.Mock(return_value=None)
listener = mock.Mock() listener = mock.Mock()
fakeAtspi = types.SimpleNamespace(
EventListener=types.SimpleNamespace(new=mock.Mock(return_value=listener)),
)
fakeCthulhu = types.SimpleNamespace(
setActiveWindow=mock.Mock(),
setLocusOfFocus=mock.Mock(),
)
window = object() window = object()
focusedObject = object() focusedObject = object()
with ( with (
mock.patch.object(cthulhu.event_manager.Atspi.EventListener, "new", return_value=listener), mock.patch.object(event_manager, "Atspi", fakeAtspi),
mock.patch.object(cthulhu.event_manager.AXUtilities, "can_be_active_window", return_value=False), mock.patch.object(event_manager.AXUtilities, "can_be_active_window", return_value=False),
mock.patch.object(cthulhu.event_manager.AXUtilities, "find_active_window", return_value=window), mock.patch.object(event_manager.AXUtilities, "find_active_window", return_value=window),
mock.patch.object(cthulhu.event_manager.AXUtilities, "get_focused_object", return_value=focusedObject), mock.patch.object(event_manager.AXUtilities, "get_focused_object", return_value=focusedObject),
mock.patch.object(cthulhu.event_manager.cthulhu, "setActiveWindow") as setActiveWindow, mock.patch.object(event_manager, "cthulhu", fakeCthulhu),
mock.patch.object(cthulhu.event_manager.cthulhu, "setLocusOfFocus") as setLocusOfFocus,
): ):
manager = cthulhu.event_manager.EventManager(mock.Mock()) manager = event_manager.EventManager(mock.Mock())
manager.set_compositor_state_adapter(adapter) manager.set_compositor_state_adapter(adapter)
manager._sync_focus_on_startup() manager._sync_focus_on_startup()
setActiveWindow.assert_called_once_with(window, alsoSetLocusOfFocus=True, notifyScript=False) fakeCthulhu.setActiveWindow.assert_called_once_with(
setLocusOfFocus.assert_called_once_with(None, focusedObject, notifyScript=True, force=True) window,
alsoSetLocusOfFocus=True,
notifyScript=False,
)
fakeCthulhu.setLocusOfFocus.assert_called_once_with(
None,
focusedObject,
notifyScript=True,
force=True,
)
adapter.sync_accessible_context.assert_called_once_with("event-manager-startup") adapter.sync_accessible_context.assert_called_once_with("event-manager-startup")
+55 -11
View File
@@ -6,18 +6,62 @@ from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
input_event_manager_stub = types.ModuleType("cthulhu.input_event_manager") import cthulhu as cthulhuPackage
input_event_manager_stub.get_manager = mock.Mock(return_value=mock.Mock())
sys.modules["cthulhu.input_event_manager"] = input_event_manager_stub
from cthulhu.plugin_system_manager import PluginSystemManager missingModule = object()
from cthulhu import cthulhu_state previousInputEventManagerModule = sys.modules.get(
from cthulhu import speech "cthulhu.input_event_manager",
from cthulhu.plugins.CthulhuRemote.connection_info import ConnectionInfo, ConnectionMode missingModule,
from cthulhu.plugins.CthulhuRemote.local_machine import LocalMachine )
from cthulhu.plugins.CthulhuRemote.plugin import CthulhuRemote previousInputEventManagerAttribute = getattr(
from cthulhu.plugins.CthulhuRemote.protocol import RemoteMessageType cthulhuPackage,
from cthulhu.plugins.CthulhuRemote.serializer import JSONSerializer "input_event_manager",
missingModule,
)
previousPluginSystemManagerModule = sys.modules.get(
"cthulhu.plugin_system_manager",
missingModule,
)
previousPluginSystemManagerAttribute = getattr(
cthulhuPackage,
"plugin_system_manager",
missingModule,
)
inputEventManagerStub = types.ModuleType("cthulhu.input_event_manager")
inputEventManagerStub.get_manager = mock.Mock(return_value=mock.Mock())
sys.modules["cthulhu.input_event_manager"] = inputEventManagerStub
cthulhuPackage.input_event_manager = inputEventManagerStub
try:
from cthulhu import plugin_system_manager
from cthulhu import cthulhu_state
from cthulhu import speech
from cthulhu.plugin_system_manager import PluginSystemManager
from cthulhu.plugins.CthulhuRemote.connection_info import ConnectionInfo, ConnectionMode
from cthulhu.plugins.CthulhuRemote.local_machine import LocalMachine
from cthulhu.plugins.CthulhuRemote.plugin import CthulhuRemote
from cthulhu.plugins.CthulhuRemote.protocol import RemoteMessageType
from cthulhu.plugins.CthulhuRemote.serializer import JSONSerializer
finally:
if previousInputEventManagerModule is missingModule:
sys.modules.pop("cthulhu.input_event_manager", None)
else:
sys.modules["cthulhu.input_event_manager"] = previousInputEventManagerModule
if previousInputEventManagerAttribute is missingModule:
delattr(cthulhuPackage, "input_event_manager")
else:
cthulhuPackage.input_event_manager = previousInputEventManagerAttribute
if previousPluginSystemManagerModule is missingModule:
sys.modules.pop("cthulhu.plugin_system_manager", None)
else:
sys.modules["cthulhu.plugin_system_manager"] = previousPluginSystemManagerModule
if previousPluginSystemManagerAttribute is missingModule:
delattr(cthulhuPackage, "plugin_system_manager")
else:
cthulhuPackage.plugin_system_manager = previousPluginSystemManagerAttribute
class CthulhuRemotePluginTests(unittest.TestCase): class CthulhuRemotePluginTests(unittest.TestCase):
+9 -3
View File
@@ -30,6 +30,7 @@ sys.modules.setdefault(
), ),
) )
from cthulhu import ax_document_selection
from cthulhu.script_utilities import Utilities from cthulhu.script_utilities import Utilities
@@ -131,9 +132,14 @@ class DocumentSelectionRegressionTests(unittest.TestCase):
stack.enter_context(mock.patch("cthulhu.script_utilities.AXText.set_selected_text", set_selected_text)) stack.enter_context(mock.patch("cthulhu.script_utilities.AXText.set_selected_text", set_selected_text))
stack.enter_context(mock.patch("cthulhu.script_utilities.AXText._get_n_selections", return_value=1)) stack.enter_context(mock.patch("cthulhu.script_utilities.AXText._get_n_selections", return_value=1))
stack.enter_context(mock.patch("cthulhu.script_utilities.AXText._remove_selection", remove_selection)) stack.enter_context(mock.patch("cthulhu.script_utilities.AXText._remove_selection", remove_selection))
stack.enter_context(mock.patch("cthulhu.script_utilities.Atspi.Document.get_text_selections", side_effect=get_text_selections)) fakeAtspi = types.SimpleNamespace(
stack.enter_context(mock.patch("cthulhu.script_utilities.Atspi.Document.set_text_selections", side_effect=set_text_selections)) Document=types.SimpleNamespace(
stack.enter_context(mock.patch("cthulhu.script_utilities.Atspi.TextSelection", side_effect=self._new_text_selection)) get_text_selections=mock.Mock(side_effect=get_text_selections),
set_text_selections=mock.Mock(side_effect=set_text_selections),
),
TextSelection=mock.Mock(side_effect=self._new_text_selection),
)
stack.enter_context(mock.patch.object(ax_document_selection, "Atspi", fakeAtspi))
stack.enter_context( stack.enter_context(
mock.patch( mock.patch(
"cthulhu.script_utilities.cthulhu.cthulhuApp", "cthulhu.script_utilities.cthulhu.cthulhuApp",
@@ -34,11 +34,12 @@ class EventManagerCompositorContextRegressionTests(unittest.TestCase):
self.addCleanup(self._restore_cthulhu_state) self.addCleanup(self._restore_cthulhu_state)
self.listener = mock.Mock() self.listener = mock.Mock()
self.listenerPatch = mock.patch.object( fakeAtspi = types.SimpleNamespace(
event_manager.Atspi.EventListener, Accessible=event_manager.Atspi.Accessible,
"new", EventListener=types.SimpleNamespace(new=mock.Mock(return_value=self.listener)),
return_value=self.listener, Role=event_manager.Atspi.Role,
) )
self.listenerPatch = mock.patch.object(event_manager, "Atspi", fakeAtspi)
self.listenerPatch.start() self.listenerPatch.start()
self.addCleanup(self.listenerPatch.stop) self.addCleanup(self.listenerPatch.stop)
@@ -27,11 +27,12 @@ class FakeEvent:
class EventManagerRelevanceGateRegressionTests(unittest.TestCase): class EventManagerRelevanceGateRegressionTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.listener = mock.Mock() self.listener = mock.Mock()
self.listenerPatch = mock.patch.object( fakeAtspi = types.SimpleNamespace(
event_manager.Atspi.EventListener, Accessible=event_manager.Atspi.Accessible,
"new", EventListener=types.SimpleNamespace(new=mock.Mock(return_value=self.listener)),
return_value=self.listener, Role=event_manager.Atspi.Role,
) )
self.listenerPatch = mock.patch.object(event_manager, "Atspi", fakeAtspi)
self.listenerPatch.start() self.listenerPatch.start()
self.addCleanup(self.listenerPatch.stop) self.addCleanup(self.listenerPatch.stop)
@@ -0,0 +1,330 @@
import sys
import types
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cthulhu import input_event_manager
class FakeDevice:
def __init__(self) -> None:
self.add_key_watcher_calls = []
self.connect_calls = []
self.disconnect_calls = []
self.next_handler_id = 17
self.add_key_grab_calls = []
self.grab_ids = []
def add_key_watcher(self, callback: object, user_data: object = None) -> None:
self.add_key_watcher_calls.append((callback, user_data))
def connect(self, signalName: str, callback: object) -> int:
self.connect_calls.append((signalName, callback))
handlerId = self.next_handler_id
self.next_handler_id += 1
return handlerId
def disconnect(self, handlerId: int) -> None:
self.disconnect_calls.append(handlerId)
def add_key_grab(self, definition: object, callback: object = None) -> int:
self.add_key_grab_calls.append((definition, callback))
return self.grab_ids.pop(0)
class FakeDeviceFactory:
def __init__(self, device: FakeDevice) -> None:
self.device = device
def new(self) -> FakeDevice:
return self.device
def new_full(self, _appId: str) -> FakeDevice:
return self.device
class InputEventManagerKeyWatcherTests(unittest.TestCase):
@staticmethod
def _fake_atspi(
version: tuple[int, int, int],
device: FakeDevice,
) -> types.SimpleNamespace:
return types.SimpleNamespace(
get_version=lambda: version,
Device=FakeDeviceFactory(device),
)
def test_atspi_260_uses_key_signals_and_disconnects_them(self) -> None:
manager = input_event_manager.InputEventManager()
device = FakeDevice()
fakeAtspi = self._fake_atspi((2, 60, 5), device)
with (
mock.patch.object(input_event_manager, "Atspi", fakeAtspi),
mock.patch.dict(input_event_manager.os.environ, {"XDG_SESSION_TYPE": "wayland"}),
):
manager.start_key_watcher()
manager.stop_key_watcher()
self.assertEqual(
device.connect_calls,
[
("key-pressed", manager._on_key_pressed),
("key-released", manager._on_key_released),
],
)
self.assertEqual(device.add_key_watcher_calls, [])
self.assertEqual(device.disconnect_calls, [17, 18])
self.assertIsNone(manager.get_device())
def test_pre_atspi_260_keeps_legacy_key_watcher(self) -> None:
manager = input_event_manager.InputEventManager()
device = FakeDevice()
fakeAtspi = self._fake_atspi((2, 58, 4), device)
with (
mock.patch.object(input_event_manager, "Atspi", fakeAtspi),
mock.patch.dict(input_event_manager.os.environ, {"XDG_SESSION_TYPE": "wayland"}),
):
manager.start_key_watcher()
self.assertEqual(
device.add_key_watcher_calls,
[(manager.process_keyboard_event, None)],
)
self.assertEqual(device.connect_calls, [])
def test_key_signal_callbacks_preserve_pressed_state(self) -> None:
manager = input_event_manager.InputEventManager()
device = mock.Mock()
with mock.patch.object(manager, "process_keyboard_event") as processEvent:
manager._on_key_pressed(device, 56, 98, 0, "b")
manager._on_key_released(device, 56, 98, 0, "b")
self.assertEqual(
processEvent.call_args_list,
[
mock.call(device, True, 56, 98, 0, "b"),
mock.call(device, False, 56, 98, 0, "b"),
],
)
def test_x11_consumable_listener_includes_locking_modifier_combinations(self) -> None:
manager = input_event_manager.InputEventManager()
device = FakeDevice()
fakeAtspi = self._fake_atspi((2, 60, 5), device)
fakeAtspi.EventType = types.SimpleNamespace(KEY_PRESSED_EVENT=0, KEY_RELEASED_EVENT=1)
fakeKeySet = [types.SimpleNamespace(keycode=38), types.SimpleNamespace(keycode=56)]
with (
mock.patch.object(input_event_manager, "Atspi", fakeAtspi),
mock.patch.dict(input_event_manager.os.environ, {"XDG_SESSION_TYPE": "x11"}),
mock.patch.object(
input_event_manager,
"_get_x11_consumable_key_set",
return_value=fakeKeySet,
),
mock.patch.object(input_event_manager.pyatspi.Registry, "registerKeystrokeListener") as register,
mock.patch.object(input_event_manager.pyatspi.Registry, "deregisterKeystrokeListener") as deregister,
):
manager.start_key_watcher()
manager.stop_key_watcher()
shiftMask = 1 << int(input_event_manager.Atspi.ModifierType.SHIFT)
shiftLockMask = 1 << int(input_event_manager.Atspi.ModifierType.SHIFTLOCK)
numLockMask = 1 << int(input_event_manager.Atspi.ModifierType.NUMLOCK)
expectedMasks = [
0,
shiftMask,
shiftLockMask,
shiftMask | shiftLockMask,
numLockMask,
shiftMask | numLockMask,
shiftLockMask | numLockMask,
shiftMask | shiftLockMask | numLockMask,
]
register.assert_called_once_with(
manager._process_selective_legacy_keyboard_event,
key_set=fakeKeySet,
mask=expectedMasks,
kind=(0, 1),
synchronous=True,
preemptive=True,
)
deregister.assert_called_once_with(
manager._process_selective_legacy_keyboard_event,
key_set=fakeKeySet,
mask=expectedMasks,
kind=(0, 1),
)
self.assertEqual(
device.connect_calls,
[
("key-pressed", manager._on_key_pressed),
("key-released", manager._on_key_released),
],
)
def test_x11_consumable_keycodes_include_structural_navigation_but_not_tab(self) -> None:
keycodes = {
keysym: index + 20
for index, keysym in enumerate(input_event_manager._X11_CONSUMABLE_KEYSYMS)
}
with mock.patch("cthulhu.keybindings.get_keycodes") as getKeycodes:
getKeycodes.side_effect = lambda keysym: (0, keycodes[keysym])
result = input_event_manager._get_x11_consumable_key_set()
resultKeycodes = {definition.keycode for definition in result}
self.assertIn(keycodes["b"], resultKeycodes)
self.assertIn(keycodes["h"], resultKeycodes)
requestedKeysyms = {call.args[0] for call in getKeycodes.call_args_list}
self.assertNotIn("Tab", requestedKeysyms)
def test_x11_consumable_key_set_uses_atspi_key_definitions(self) -> None:
with mock.patch("cthulhu.keybindings.get_keycodes", return_value=(0, 38)):
keySet = input_event_manager._get_x11_consumable_key_set()
self.assertTrue(keySet)
self.assertTrue(all(isinstance(item, input_event_manager.Atspi.KeyDefinition) for item in keySet))
def test_modern_signal_defers_registered_x11_key_to_consumable_listener(self) -> None:
manager = input_event_manager.InputEventManager()
manager._selective_legacy_listener_active = True
manager._selective_legacy_keycodes = {56}
device = mock.Mock()
with mock.patch.object(manager, "process_keyboard_event") as processEvent:
manager._on_key_pressed(device, 56, 98, 0, "b")
manager._on_key_released(device, 56, 98, 0, "b")
processEvent.assert_not_called()
def test_modern_signal_defers_x11_key_with_locking_modifiers(self) -> None:
manager = input_event_manager.InputEventManager()
manager._selective_legacy_listener_active = True
manager._selective_legacy_keycodes = {56}
device = mock.Mock()
shiftMask = 1 << int(input_event_manager.Atspi.ModifierType.SHIFT)
shiftLockMask = 1 << int(input_event_manager.Atspi.ModifierType.SHIFTLOCK)
numLockMask = 1 << int(input_event_manager.Atspi.ModifierType.NUMLOCK)
lockingMasks = (
shiftLockMask,
shiftMask | shiftLockMask,
numLockMask,
shiftMask | numLockMask,
shiftLockMask | numLockMask,
shiftMask | shiftLockMask | numLockMask,
)
with mock.patch.object(manager, "process_keyboard_event") as processEvent:
for modifiers in lockingMasks:
manager._on_key_pressed(device, 56, 98, modifiers, "b")
manager._on_key_released(device, 56, 98, modifiers, "b")
processEvent.assert_not_called()
def test_modern_signal_still_processes_keys_not_owned_by_selective_listener(self) -> None:
manager = input_event_manager.InputEventManager()
manager._selective_legacy_listener_active = True
manager._selective_legacy_keycodes = {56}
device = mock.Mock()
with mock.patch.object(manager, "process_keyboard_event") as processEvent:
manager._on_key_pressed(device, 23, 65289, 0, "Tab")
manager._on_key_released(device, 23, 65289, 0, "Tab")
self.assertEqual(
processEvent.call_args_list,
[
mock.call(device, True, 23, 65289, 0, "Tab"),
mock.call(device, False, 23, 65289, 0, "Tab"),
],
)
def test_keyboard_event_returns_actual_consumption_decision(self) -> None:
manager = input_event_manager.InputEventManager()
focusManager = mock.Mock()
focusManager.get_active_window.return_value = object()
focusManager.get_locus_of_focus.return_value = object()
scriptManager = mock.Mock()
scriptManager.get_active_script.return_value = mock.Mock()
keyboardEvent = mock.Mock()
keyboardEvent.is_modifier_key.return_value = False
keyboardEvent.process.return_value = False
with (
mock.patch.object(input_event_manager.cthulhu_state, "capturingKeys", False),
mock.patch.object(input_event_manager.cthulhu_state, "pendingSelfHostedFocus", None),
mock.patch.object(input_event_manager.focus_manager, "get_manager", return_value=focusManager),
mock.patch.object(input_event_manager.script_manager, "get_manager", return_value=scriptManager),
mock.patch.object(input_event_manager.input_event, "KeyboardEvent", return_value=keyboardEvent),
mock.patch.object(manager, "_should_pass_through_for_active_xterm", return_value=False),
mock.patch.object(input_event_manager.AXUtilities, "can_be_active_window", return_value=True),
mock.patch.object(manager, "last_event_was_keyboard", return_value=False),
):
result = manager.process_keyboard_event(mock.Mock(), True, 56, 98, 0, "b")
self.assertFalse(result)
keyboardEvent.process.assert_called_once_with()
def test_x11_consumable_listener_is_only_owner_of_matching_key_grabs(self) -> None:
manager = input_event_manager.InputEventManager()
device = FakeDevice()
device.grab_ids = [101, 102, 103]
manager._device = device
manager._selective_legacy_listener_active = True
manager._selective_legacy_keycodes = {56}
legacyOwned = types.SimpleNamespace(keycode=0, keysym=98, modifiers=0)
modified = types.SimpleNamespace(keycode=0, keysym=98, modifiers=4)
unrelated = types.SimpleNamespace(keycode=0, keysym=106, modifiers=0)
binding = mock.Mock()
binding.is_enabled.return_value = True
binding.is_bound.return_value = True
binding.has_grabs.return_value = False
binding.keycode = 56
binding.key_definitions.return_value = [legacyOwned, modified]
unrelatedBinding = mock.Mock()
unrelatedBinding.is_enabled.return_value = True
unrelatedBinding.is_bound.return_value = True
unrelatedBinding.has_grabs.return_value = False
unrelatedBinding.keycode = 44
unrelatedBinding.key_definitions.return_value = [unrelated]
result = manager.add_grabs_for_keybinding(binding)
unrelatedResult = manager.add_grabs_for_keybinding(unrelatedBinding)
self.assertEqual(result, [101])
self.assertEqual(unrelatedResult, [102])
self.assertEqual(
device.add_key_grab_calls,
[(modified, None), (unrelated, None)],
)
def test_keybinding_grab_failure_rolls_back_partial_additions(self) -> None:
manager = input_event_manager.InputEventManager()
device = mock.Mock()
device.add_key_grab.side_effect = [101, RuntimeError("grab failed")]
manager._device = device
firstDefinition = types.SimpleNamespace(keycode=44, modifiers=0)
secondDefinition = types.SimpleNamespace(keycode=45, modifiers=0)
binding = mock.Mock()
binding.is_enabled.return_value = True
binding.is_bound.return_value = True
binding.has_grabs.return_value = False
binding.keycode = 44
binding.key_definitions.return_value = [firstDefinition, secondDefinition]
with self.assertRaisesRegex(RuntimeError, "grab failed"):
manager.add_grabs_for_keybinding(binding)
device.remove_key_grab.assert_called_once_with(101)
self.assertEqual(manager._grabbed_bindings, {})
if __name__ == "__main__":
unittest.main()
@@ -525,6 +525,114 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
oldScript.removeKeyGrabs.assert_called_once_with() oldScript.removeKeyGrabs.assert_called_once_with()
oldScript.addKeyGrabs.assert_not_called() oldScript.addKeyGrabs.assert_not_called()
def test_xterm_grabs_restore_on_x11_focus_change_without_keyboard_event(self):
manager = input_event_manager.InputEventManager()
activeScript = mock.Mock()
scriptManager = mock.Mock()
scriptManager.get_active_script.return_value = activeScript
screen = mock.Mock()
activeWindow = object()
screen.get_active_window.return_value = activeWindow
manager._scriptWithSuspendedGrabsForXterm = activeScript
with (
mock.patch.object(input_event_manager.script_manager, "get_manager", return_value=scriptManager),
mock.patch.object(manager, "_x11_window_xterm_match", return_value=False) as matcher,
mock.patch.object(input_event_manager.debug, "print_tokens"),
):
manager._on_active_x11_window_changed(screen, None)
matcher.assert_called_once_with(activeWindow)
activeScript.addKeyGrabs.assert_called_once_with()
self.assertIsNone(manager._scriptWithSuspendedGrabsForXterm)
def test_xterm_focus_check_failure_starts_recovery_timer(self):
manager = input_event_manager.InputEventManager()
manager._device = object()
manager._scriptWithSuspendedGrabsForXterm = object()
screen = mock.Mock()
screen.get_active_window.side_effect = RuntimeError("focus unavailable")
with (
mock.patch.object(input_event_manager.GLib, "timeout_add", return_value=23),
mock.patch.object(input_event_manager.debug, "print_message"),
):
manager._on_active_x11_window_changed(screen, None)
self.assertEqual(manager._xtermRecoverySourceId, 23)
def test_xterm_focus_monitor_is_connected_only_once(self):
manager = input_event_manager.InputEventManager()
screen = mock.Mock()
screen.connect.return_value = 17
manager._ensure_xterm_focus_monitor(screen)
manager._ensure_xterm_focus_monitor(screen)
screen.connect.assert_called_once_with(
"active-window-changed",
manager._on_active_x11_window_changed,
)
def test_xterm_suspension_starts_fallback_when_focus_monitor_is_unavailable(self):
manager = input_event_manager.InputEventManager()
manager._device = object()
activeScript = mock.Mock()
scriptManager = mock.Mock()
scriptManager.get_active_script.return_value = activeScript
with (
mock.patch.object(input_event_manager.script_manager, "get_manager", return_value=scriptManager),
mock.patch.object(input_event_manager.GLib, "timeout_add", return_value=23) as timeoutAdd,
mock.patch.object(input_event_manager.debug, "print_tokens"),
):
manager._suspend_key_grabs_for_xterm()
timeoutAdd.assert_called_once_with(100, manager._poll_xterm_grab_recovery)
self.assertEqual(manager._xtermRecoverySourceId, 23)
def test_xterm_grab_restore_failure_keeps_retry_state(self):
manager = input_event_manager.InputEventManager()
manager._device = object()
activeScript = mock.Mock()
activeScript.addKeyGrabs.side_effect = RuntimeError("grab failed")
scriptManager = mock.Mock()
scriptManager.get_active_script.return_value = activeScript
manager._scriptWithSuspendedGrabsForXterm = activeScript
with (
mock.patch.object(input_event_manager.script_manager, "get_manager", return_value=scriptManager),
mock.patch.object(input_event_manager.GLib, "timeout_add", return_value=23),
mock.patch.object(input_event_manager.debug, "print_message"),
mock.patch.object(input_event_manager.debug, "print_tokens"),
):
manager._restore_key_grabs_after_xterm()
self.assertIs(manager._scriptWithSuspendedGrabsForXterm, activeScript)
self.assertEqual(manager._xtermRecoverySourceId, 23)
activeScript.removeKeyGrabs.assert_called_once_with()
def test_stopping_key_watcher_disconnects_xterm_focus_monitor(self):
manager = input_event_manager.InputEventManager()
screen = mock.Mock()
manager._xtermFocusScreen = screen
manager._xtermFocusHandlerId = 17
manager._xtermRecoverySourceId = 23
manager._scriptWithSuspendedGrabsForXterm = object()
with (
mock.patch.object(input_event_manager.GLib, "source_remove") as sourceRemove,
mock.patch.object(input_event_manager.debug, "print_message"),
):
manager.stop_key_watcher()
screen.disconnect.assert_called_once_with(17)
sourceRemove.assert_called_once_with(23)
self.assertIsNone(manager._xtermFocusScreen)
self.assertEqual(manager._xtermFocusHandlerId, 0)
self.assertEqual(manager._xtermRecoverySourceId, 0)
self.assertIsNone(manager._scriptWithSuspendedGrabsForXterm)
def test_identifier_is_xterm_matches_exact_xterm_only(self): def test_identifier_is_xterm_matches_exact_xterm_only(self):
self.assertTrue(input_event_manager.InputEventManager._identifier_is_xterm("xterm")) self.assertTrue(input_event_manager.InputEventManager._identifier_is_xterm("xterm"))
self.assertTrue(input_event_manager.InputEventManager._identifier_is_xterm("/usr/bin/xterm")) self.assertTrue(input_event_manager.InputEventManager._identifier_is_xterm("/usr/bin/xterm"))
@@ -2,6 +2,7 @@ import sys
import types import types
import unittest import unittest
from pathlib import Path from pathlib import Path
from typing import Any
from unittest import mock from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
@@ -133,6 +134,20 @@ class InputEventManagerPointerMonitorTests(unittest.TestCase):
class MouseReviewBackendSelectionTests(unittest.TestCase): class MouseReviewBackendSelectionTests(unittest.TestCase):
@staticmethod
def _make_atspi(
version: tuple[int, int, int],
listener: Any,
pointerMonitor: int | None = None,
) -> types.SimpleNamespace:
fakeAtspi = types.SimpleNamespace(
EventListener=types.SimpleNamespace(new=mock.Mock(return_value=listener)),
get_version=mock.Mock(return_value=version),
)
if pointerMonitor is not None:
fakeAtspi.DeviceCapability = types.SimpleNamespace(POINTER_MONITOR=pointerMonitor)
return fakeAtspi
@staticmethod @staticmethod
def _make_app(enabled=False): def _make_app(enabled=False):
app = mock.Mock() app = mock.Mock()
@@ -146,16 +161,10 @@ class MouseReviewBackendSelectionTests(unittest.TestCase):
listener = mock.Mock() listener = mock.Mock()
deviceManager = mock.Mock() deviceManager = mock.Mock()
deviceManager.enable_pointer_monitoring.return_value = True deviceManager.enable_pointer_monitoring.return_value = True
fakeAtspi = self._make_atspi((2, 60, 0), listener, pointerMonitor=8)
with ( with (
mock.patch.object(mouse_review.Atspi.EventListener, "new", return_value=listener), mock.patch.object(mouse_review, "Atspi", fakeAtspi),
mock.patch.object(mouse_review.Atspi, "get_version", return_value=(2, 60, 0)),
mock.patch.object(
mouse_review.Atspi,
"DeviceCapability",
new=types.SimpleNamespace(POINTER_MONITOR=8),
create=True,
),
mock.patch.object(mouse_review.input_event_manager, "get_manager", return_value=deviceManager), mock.patch.object(mouse_review.input_event_manager, "get_manager", return_value=deviceManager),
mock.patch.object(mouse_review, "Wnck", None), mock.patch.object(mouse_review, "Wnck", None),
): ):
@@ -170,16 +179,10 @@ class MouseReviewBackendSelectionTests(unittest.TestCase):
listener = mock.Mock() listener = mock.Mock()
deviceManager = mock.Mock() deviceManager = mock.Mock()
deviceManager.enable_pointer_monitoring.return_value = True deviceManager.enable_pointer_monitoring.return_value = True
fakeAtspi = self._make_atspi((2, 60, 0), listener, pointerMonitor=8)
with ( with (
mock.patch.object(mouse_review.Atspi.EventListener, "new", return_value=listener), mock.patch.object(mouse_review, "Atspi", fakeAtspi),
mock.patch.object(mouse_review.Atspi, "get_version", return_value=(2, 60, 0)),
mock.patch.object(
mouse_review.Atspi,
"DeviceCapability",
new=types.SimpleNamespace(POINTER_MONITOR=8),
create=True,
),
mock.patch.object(mouse_review.input_event_manager, "get_manager", return_value=deviceManager), mock.patch.object(mouse_review.input_event_manager, "get_manager", return_value=deviceManager),
mock.patch.object(mouse_review.cthulhu_state, "locusOfFocus", None), mock.patch.object(mouse_review.cthulhu_state, "locusOfFocus", None),
mock.patch.object(mouse_review, "Wnck", None), mock.patch.object(mouse_review, "Wnck", None),
@@ -202,14 +205,19 @@ class MouseReviewBackendSelectionTests(unittest.TestCase):
screen.get_active_workspace.return_value = None screen.get_active_workspace.return_value = None
fakeWnck = mock.Mock() fakeWnck = mock.Mock()
fakeWnck.Screen.get_default.return_value = screen fakeWnck.Screen.get_default.return_value = screen
fakeAtspi = self._make_atspi((2, 58, 4), listener)
fakeGdk = types.SimpleNamespace(
Display=types.SimpleNamespace(
get_default=mock.Mock(return_value=mock.Mock()),
get_default_seat=mock.Mock(return_value=seat),
),
)
with ( with (
mock.patch.object(mouse_review.Atspi.EventListener, "new", return_value=listener), mock.patch.object(mouse_review, "Atspi", fakeAtspi),
mock.patch.object(mouse_review.Atspi, "get_version", return_value=(2, 58, 4)),
mock.patch.object(mouse_review.input_event_manager, "get_manager"), mock.patch.object(mouse_review.input_event_manager, "get_manager"),
mock.patch.object(mouse_review.cthulhu_state, "locusOfFocus", None), mock.patch.object(mouse_review.cthulhu_state, "locusOfFocus", None),
mock.patch.object(mouse_review.Gdk.Display, "get_default", return_value=mock.Mock()), mock.patch.object(mouse_review, "Gdk", fakeGdk),
mock.patch.object(mouse_review.Gdk.Display, "get_default_seat", return_value=seat),
mock.patch.object(mouse_review, "Wnck", fakeWnck), mock.patch.object(mouse_review, "Wnck", fakeWnck),
): ):
reviewer = mouse_review.MouseReviewer(self._make_app(enabled=False)) reviewer = mouse_review.MouseReviewer(self._make_app(enabled=False))
@@ -8,11 +8,55 @@ from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
input_event_manager_stub = types.ModuleType("cthulhu.input_event_manager") import cthulhu as cthulhuPackage
input_event_manager_stub.get_manager = mock.Mock(return_value=mock.Mock())
sys.modules["cthulhu.input_event_manager"] = input_event_manager_stub
from cthulhu.plugin_system_manager import PluginInfo, PluginSystemManager missingModule = object()
previousInputEventManagerModule = sys.modules.get(
"cthulhu.input_event_manager",
missingModule,
)
previousInputEventManagerAttribute = getattr(
cthulhuPackage,
"input_event_manager",
missingModule,
)
previousPluginSystemManagerModule = sys.modules.get(
"cthulhu.plugin_system_manager",
missingModule,
)
previousPluginSystemManagerAttribute = getattr(
cthulhuPackage,
"plugin_system_manager",
missingModule,
)
inputEventManagerStub = types.ModuleType("cthulhu.input_event_manager")
inputEventManagerStub.get_manager = mock.Mock(return_value=mock.Mock())
sys.modules["cthulhu.input_event_manager"] = inputEventManagerStub
cthulhuPackage.input_event_manager = inputEventManagerStub
try:
from cthulhu import plugin_system_manager
from cthulhu.plugin_system_manager import PluginInfo, PluginSystemManager
finally:
if previousInputEventManagerModule is missingModule:
sys.modules.pop("cthulhu.input_event_manager", None)
else:
sys.modules["cthulhu.input_event_manager"] = previousInputEventManagerModule
if previousInputEventManagerAttribute is missingModule:
delattr(cthulhuPackage, "input_event_manager")
else:
cthulhuPackage.input_event_manager = previousInputEventManagerAttribute
if previousPluginSystemManagerModule is missingModule:
sys.modules.pop("cthulhu.plugin_system_manager", None)
else:
sys.modules["cthulhu.plugin_system_manager"] = previousPluginSystemManagerModule
if previousPluginSystemManagerAttribute is missingModule:
delattr(cthulhuPackage, "plugin_system_manager")
else:
cthulhuPackage.plugin_system_manager = previousPluginSystemManagerAttribute
PLUGIN_TEMPLATE = """ PLUGIN_TEMPLATE = """
+140 -2
View File
@@ -8,6 +8,9 @@ import gi
gi.require_version("Gdk", "3.0") gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
@@ -20,9 +23,62 @@ if soundGeneratorModule is not None and not hasattr(soundGeneratorModule, "Sound
from cthulhu.ax_object import AXObject from cthulhu.ax_object import AXObject
from cthulhu.ax_utilities import AXUtilities from cthulhu.ax_utilities import AXUtilities
from cthulhu import cthulhu_state
from cthulhu.scripts.apps.steamwebhelper import script as steam_script from cthulhu.scripts.apps.steamwebhelper import script as steam_script
class SteamInactiveNotificationContractTests(unittest.TestCase):
@staticmethod
def _prepare_event_script(testScript, presentIfInactive):
handler = mock.Mock()
testScript.presentIfInactive = presentIfInactive
testScript.listeners = {"object:state-changed:showing": handler}
testScript.generatorCache = {"stale": object()}
testScript.skipObjectEvent = mock.Mock(return_value=False)
return handler
def test_steam_notification_is_processed_while_script_is_inactive(self):
app = object()
def initialize_chromium(testScript, initializedApp):
testScript.app = initializedApp
testScript.presentIfInactive = False
with mock.patch.object(
steam_script.Chromium.Script,
"__init__",
new=initialize_chromium,
):
testScript = steam_script.Script(app)
handler = self._prepare_event_script(testScript, testScript.presentIfInactive)
event = mock.Mock(type="object:state-changed:showing", source=object())
with (
mock.patch.object(cthulhu_state, "activeScript", object()),
mock.patch.object(AXObject, "get_role", return_value=Atspi.Role.NOTIFICATION),
):
testScript.processObjectEvent(event)
self.assertTrue(testScript.presentIfInactive)
handler.assert_called_once_with(event)
def test_generic_chromium_notification_is_deferred_while_script_is_inactive(self):
testScript = steam_script.Chromium.Script.__new__(
steam_script.Chromium.Script
)
handler = self._prepare_event_script(testScript, presentIfInactive=False)
event = mock.Mock(type="object:state-changed:showing", source=object())
with (
mock.patch.object(cthulhu_state, "activeScript", object()),
mock.patch.object(AXObject, "get_role", return_value=Atspi.Role.NOTIFICATION),
):
testScript.processObjectEvent(event)
handler.assert_not_called()
class SteamNotificationRootTests(unittest.TestCase): class SteamNotificationRootTests(unittest.TestCase):
def test_prefers_notification_ancestor_over_live_region_descendant(self): def test_prefers_notification_ancestor_over_live_region_descendant(self):
testScript = steam_script.Script.__new__(steam_script.Script) testScript = steam_script.Script.__new__(steam_script.Script)
@@ -50,11 +106,55 @@ class SteamNotificationRootTests(unittest.TestCase):
self.assertIs(result, notification) self.assertIs(result, notification)
class SteamNotificationQueueTests(unittest.TestCase): class SteamNotificationRoutingContractTests(unittest.TestCase):
def test_merges_non_overlapping_fragments_for_same_notification(self): def test_steam_notification_override_handles_showing_before_chromium(self):
testScript = steam_script.Script.__new__(steam_script.Script) testScript = steam_script.Script.__new__(steam_script.Script)
notification = object() notification = object()
event = mock.Mock(source=notification, detail1=1)
testScript._presentSteamNotification = mock.Mock()
with (
mock.patch.object(
testScript,
"_isSteamNotification",
return_value=True,
),
mock.patch.object(
steam_script.Chromium.Script,
"onShowingChanged",
) as chromiumHandler,
):
testScript.onShowingChanged(event)
testScript._presentSteamNotification.assert_called_once_with(notification)
chromiumHandler.assert_not_called()
def test_nonsteam_showing_event_defers_to_chromium(self):
testScript = steam_script.Script.__new__(steam_script.Script)
event = mock.Mock(source=object(), detail1=1)
testScript._presentSteamNotification = mock.Mock()
with (
mock.patch.object(
testScript,
"_isSteamNotification",
return_value=False,
),
mock.patch.object(
steam_script.Chromium.Script,
"onShowingChanged",
) as chromiumHandler,
):
testScript.onShowingChanged(event)
testScript._presentSteamNotification.assert_not_called()
chromiumHandler.assert_called_once_with(event)
class SteamNotificationQueueTests(unittest.TestCase):
@staticmethod
def _make_script():
testScript = steam_script.Script.__new__(steam_script.Script)
testScript._lastSteamNotification = ("", 0.0) testScript._lastSteamNotification = ("", 0.0)
testScript._steamPendingNotification = None testScript._steamPendingNotification = None
testScript._steamRelativeTimePattern = re.compile( testScript._steamRelativeTimePattern = re.compile(
@@ -63,6 +163,11 @@ class SteamNotificationQueueTests(unittest.TestCase):
) )
testScript._resetSteamPendingTimer = mock.Mock() testScript._resetSteamPendingTimer = mock.Mock()
testScript._presentSteamNotificationTextNow = mock.Mock() testScript._presentSteamNotificationTextNow = mock.Mock()
return testScript
def test_merges_non_overlapping_fragments_for_same_notification(self):
testScript = self._make_script()
notification = object()
testScript._queueSteamNotification("username", notification) testScript._queueSteamNotification("username", notification)
testScript._queueSteamNotification("Playing: Borderlands 2", notification) testScript._queueSteamNotification("Playing: Borderlands 2", notification)
@@ -80,6 +185,39 @@ class SteamNotificationQueueTests(unittest.TestCase):
notification, notification,
) )
def test_merges_relative_timestamp_without_repeated_presentation(self):
testScript = self._make_script()
notification = object()
testScript._queueSteamNotification("Username is now online", notification)
testScript._queueSteamNotification("2 minutes ago", notification)
testScript._presentSteamNotificationTextNow.assert_not_called()
self.assertEqual(
testScript._steamPendingNotification["text"],
"Username is now online. 2 minutes ago",
)
testScript._flushSteamPendingNotification(fromTimer=True)
testScript._presentSteamNotificationTextNow.assert_called_once_with(
"Username is now online. 2 minutes ago",
notification,
)
def test_duplicate_complete_notification_is_presented_once(self):
testScript = self._make_script()
notification = object()
testScript._queueSteamNotification("Username is now online", notification)
testScript._queueSteamNotification("Username is now online", notification)
testScript._flushSteamPendingNotification(fromTimer=True)
testScript._presentSteamNotificationTextNow.assert_called_once_with(
"Username is now online",
notification,
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+32
View File
@@ -569,6 +569,38 @@ class SteamReturnActivationTests(unittest.TestCase):
self.assertEqual(testScript._steamFriendsPanelActivationTarget, (None, "", 0.0)) self.assertEqual(testScript._steamFriendsPanelActivationTarget, (None, "", 0.0))
def test_return_rejects_friends_target_after_nonsteam_x11_handoff(self):
testScript = steam_script.Script.__new__(steam_script.Script)
button = object()
keyboardEvent = mock.Mock(event_string="Return", modifiers=0)
keyboardEvent.is_pressed_key.return_value = True
testScript.utilities = mock.Mock()
testScript._steamActiveQuickAccessTab = "Friends"
testScript._steamFriendsPanelActivationTarget = (
button,
"Username Playing Terraria",
100.0,
)
testScript._steamFriendsPanelActivationTargetMaxAge = 10.0
with (
mock.patch.object(steam_script.time, "monotonic", return_value=105.0),
mock.patch.object(
steam_script.Script,
"_activeX11WindowIsSteamSelectionContext",
return_value=False,
),
mock.patch.object(testScript, "_getClickableActivationTarget", return_value=None),
mock.patch.object(
steam_script.Script,
"_performClickableAction",
) as performAction,
):
self.assertFalse(testScript.shouldConsumeKeyboardEvent(keyboardEvent, None))
performAction.assert_not_called()
class SteamVirtualizedListMutationTests(unittest.TestCase): class SteamVirtualizedListMutationTests(unittest.TestCase):
def test_name_changed_presents_friends_panel_item_after_friends_tab_activation(self): def test_name_changed_presents_friends_panel_item_after_friends_tab_activation(self):
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff