7 Commits

Author SHA1 Message Date
Storm Dragon ddea36c211 Fix AT-SPI queue flush deadlock 2026-07-30 21:30:02 -04:00
Storm Dragon c39f3bda47 Add i3 focus tracking and harden X11 handoff 2026-07-30 18:53:56 -04:00
Storm Dragon f4ee5e4468 Fixed multiline edit box weirdness. 2026-07-29 03:22:10 -04:00
Storm Dragon 8b90a020c4 Merge branch 'testing' into wine-access 2026-07-28 23:34:18 -04:00
Storm Dragon 28984449e0 Add XTerm handoff runtime diagnostics 2026-07-28 23:33:22 -04:00
Storm Dragon fd426eb274 Fix notifications during XTerm handoff 2026-07-28 20:45:10 -04:00
Storm Dragon 1a9ae285d9 Harden XTerm handoff and web event routing 2026-07-28 20:01:20 -04:00
24 changed files with 2348 additions and 170 deletions
+39 -10
View File
@@ -29,6 +29,7 @@ from .compositor_state_types import (
RESUME_ATSPI_CHURN,
WORKSPACE_STATE_CHANGED,
)
from .compositor_state_i3 import I3WorkspaceBackend
from .compositor_state_wayland import NullWorkspaceBackend, WaylandSharedProtocolsBackend
from .wnck_support import get_session_type
@@ -42,6 +43,7 @@ class CompositorStateAdapter:
) -> None:
if workspace_backends is None:
workspace_backends = [
I3WorkspaceBackend(),
WaylandSharedProtocolsBackend(),
NullWorkspaceBackend(),
]
@@ -70,13 +72,15 @@ class CompositorStateAdapter:
if not self._backend_is_available(backend):
continue
self._workspaceBackend = backend
self._snapshot = DesktopContextSnapshot(
session_type=self.get_session_type(),
backend_name=self._backend_name(),
)
self._backend_activate(backend)
break
self._snapshot = DesktopContextSnapshot(
session_type=self.get_session_type(),
backend_name=self._backend_name(),
)
if self._workspaceBackend is None:
self._snapshot = DesktopContextSnapshot(session_type=self.get_session_type())
self.sync_accessible_context("activate")
def deactivate(self) -> None:
@@ -104,6 +108,9 @@ class CompositorStateAdapter:
locus_of_focus_pid=locus_of_focus_snapshot["pid"],
active_window_name=active_window_snapshot["name"],
locus_of_focus_name=locus_of_focus_snapshot["name"],
focused_window_id=workspaceSnapshot.focused_window_id,
focused_window_title=workspaceSnapshot.focused_window_title,
focused_workspace_empty=workspaceSnapshot.focused_workspace_empty,
)
previous_token = self._snapshot.active_window_token
@@ -145,27 +152,49 @@ class CompositorStateAdapter:
signal_name: str,
workspace_ids: Optional[Iterable[str]] = None,
reason: str = "",
payload: Optional[dict[str, Any]] = None,
) -> None:
if workspace_ids is not None:
payload = payload or {}
focusedWorkspaceEmpty = payload.get(
"focused_workspace_empty",
self._snapshot.focused_workspace_empty,
)
clearAccessibleContext = focusedWorkspaceEmpty is True
snapshot = replace(
self._snapshot,
active_workspace_ids=frozenset(str(workspaceId) for workspaceId in workspace_ids if workspaceId),
workspace_transition_pending=signal_name == DESKTOP_TRANSITION_STARTED,
active_window_token="" if clearAccessibleContext else self._snapshot.active_window_token,
locus_of_focus_token="" if clearAccessibleContext else self._snapshot.locus_of_focus_token,
active_window_pid=-1 if clearAccessibleContext else self._snapshot.active_window_pid,
locus_of_focus_pid=-1 if clearAccessibleContext else self._snapshot.locus_of_focus_pid,
active_window_name="" if clearAccessibleContext else self._snapshot.active_window_name,
locus_of_focus_name="" if clearAccessibleContext else self._snapshot.locus_of_focus_name,
focused_window_id=payload.get(
"focused_window_id",
self._snapshot.focused_window_id,
),
focused_window_title=payload.get(
"focused_window_title",
self._snapshot.focused_window_title,
),
focused_workspace_empty=focusedWorkspaceEmpty,
)
self._snapshot = snapshot
cthulhu_state.compositorSnapshot = snapshot
if signal_name == DESKTOP_TRANSITION_STARTED:
cthulhu_state.pauseAtspiChurn = True
self._emit(signal_name, reason, snapshot)
self._emit(PAUSE_ATSPI_CHURN, reason, snapshot)
self._emit(signal_name, reason, snapshot, payload)
self._emit(PAUSE_ATSPI_CHURN, reason, snapshot, payload)
return
if signal_name == DESKTOP_TRANSITION_FINISHED:
cthulhu_state.pauseAtspiChurn = False
self._emit(signal_name, reason, snapshot)
self._emit(RESUME_ATSPI_CHURN, reason, snapshot)
self._emit(FLUSH_STALE_ATSPI_EVENTS, reason, snapshot)
self._emit(signal_name, reason, snapshot, payload)
self._emit(RESUME_ATSPI_CHURN, reason, snapshot, payload)
self._emit(FLUSH_STALE_ATSPI_EVENTS, reason, snapshot, payload)
return
if signal_name == PAUSE_ATSPI_CHURN:
@@ -173,7 +202,7 @@ class CompositorStateAdapter:
elif signal_name == RESUME_ATSPI_CHURN:
cthulhu_state.pauseAtspiChurn = False
self._emit(signal_name, reason, snapshot)
self._emit(signal_name, reason, snapshot, payload)
return
normalized = (signal_name or "").strip().lower()
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
#
# Copyright (c) 2026 Stormux
#
# 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.
"""Optional i3 IPC backend for authoritative X11 workspace and focus state."""
from __future__ import annotations
import importlib
import threading
from collections.abc import Mapping
from typing import Any, Optional
from gi.repository import GLib
from . import debug
from .compositor_state_types import (
DESKTOP_TRANSITION_FINISHED,
DESKTOP_TRANSITION_STARTED,
WORKSPACE_STATE_CHANGED,
)
from .wnck_support import get_session_type
class I3WorkspaceBackend:
"""Tracks the focused i3 workspace and its real foreground X11 window."""
name = "i3-ipc"
def __init__(self, *, i3ipc_module: Any = None) -> None:
self._i3ipc = i3ipc_module
self._connection: Any = None
self._emitSignal: Any = None
self._eventThread: Optional[threading.Thread] = None
self._active = False
self._lastContext: Optional[tuple[Any, ...]] = None
def is_available(self, session_type: str | None = None) -> bool:
effectiveSessionType = (session_type or get_session_type()).strip().lower()
if effectiveSessionType != "x11":
return False
if self._i3ipc is None:
try:
self._i3ipc = importlib.import_module("i3ipc")
except ImportError:
return False
return self._ensure_connection()
def activate(self, emit_signal: Any = None) -> None:
self.deactivate()
self._emitSignal = emit_signal
if emit_signal is None or not self.is_available():
return
self._active = True
self._subscribe()
self._refresh_context("activate", transition=False)
self._eventThread = threading.Thread(
target=self._run_event_loop,
name="cthulhu-i3-ipc",
daemon=True,
)
self._eventThread.start()
def deactivate(self, emit_signal: Any = None) -> None:
self._active = False
connection = self._connection
if connection is not None:
quitLoop = getattr(connection, "main_quit", None)
if callable(quitLoop):
try:
quitLoop()
except Exception:
pass
commandSocket = getattr(connection, "_cmd_socket", None)
if commandSocket is not None:
try:
commandSocket.close()
except Exception:
pass
self._connection = None
self._eventThread = None
self._emitSignal = None
self._lastContext = None
def _ensure_connection(self) -> bool:
if self._connection is not None:
return True
if self._i3ipc is None:
return False
connectionClass = getattr(self._i3ipc, "Connection", None)
if not callable(connectionClass):
return False
try:
try:
self._connection = connectionClass(auto_reconnect=True)
except TypeError:
self._connection = connectionClass()
self._connection.get_workspaces()
except Exception as error:
self._connection = None
msg = f"COMPOSITOR STATE: i3 IPC unavailable: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
return True
def _subscribe(self) -> None:
if self._connection is None or self._i3ipc is None:
return
onEvent = getattr(self._connection, "on", None)
eventType = getattr(self._i3ipc, "Event", None)
if not callable(onEvent) or eventType is None:
return
onEvent(eventType.WORKSPACE, self._handle_ipc_event)
onEvent(eventType.WINDOW, self._handle_ipc_event)
def _run_event_loop(self) -> None:
connection = self._connection
runLoop = getattr(connection, "main", None)
if not callable(runLoop):
return
try:
runLoop()
except Exception as error:
if self._active:
msg = f"COMPOSITOR STATE: i3 IPC event loop stopped: {error}"
debug.printMessage(debug.LEVEL_WARNING, msg, True)
def _handle_ipc_event(self, _connection: Any, event: Any) -> None:
if not self._active:
return
change = str(getattr(event, "change", "") or "changed")
GLib.idle_add(self._refresh_context, f"i3-{change}", True)
def _query_context(self) -> tuple[set[str], dict[str, Any]]:
if self._connection is None:
raise RuntimeError("i3 IPC is not connected")
workspaceReplies = self._connection.get_workspaces()
focusedReply = next(
(workspace for workspace in workspaceReplies if bool(getattr(workspace, "focused", False))),
None,
)
if focusedReply is None:
raise RuntimeError("i3 did not report a focused workspace")
workspaceName = str(getattr(focusedReply, "name", "") or "").strip()
if not workspaceName:
raise RuntimeError("i3 focused workspace has no name")
tree = self._connection.get_tree()
focusedWorkspace = next(
(
workspace
for workspace in tree.workspaces()
if str(getattr(workspace, "name", "") or "") == workspaceName
),
None,
)
if focusedWorkspace is None:
raise RuntimeError(f"i3 tree is missing focused workspace {workspaceName}")
windowNodes = [
node
for node in focusedWorkspace
if self._positive_window_id(getattr(node, "window", None)) is not None
]
focusedNode = tree.find_focused()
focusedWindowId: Optional[int] = None
focusedWindowTitle = ""
if focusedNode is not None and focusedNode.workspace() is focusedWorkspace:
focusedWindowId = self._positive_window_id(getattr(focusedNode, "window", None))
if focusedWindowId is not None:
focusedWindowTitle = str(
getattr(focusedNode, "window_title", None)
or getattr(focusedNode, "name", None)
or ""
).strip()
payload = {
"focused_window_id": focusedWindowId,
"focused_window_title": focusedWindowTitle,
"focused_workspace_empty": not windowNodes,
}
return {workspaceName}, payload
@staticmethod
def _positive_window_id(value: Any) -> Optional[int]:
try:
windowId = int(value)
except (TypeError, ValueError):
return None
return windowId if windowId > 0 else None
def _refresh_context(self, reason: str, transition: bool = True) -> bool:
if not self._active:
return False
try:
workspaceIds, payload = self._query_context()
except Exception as error:
msg = f"COMPOSITOR STATE: Could not query i3 context: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
unknownPayload = {
"focused_window_id": None,
"focused_window_title": "",
"focused_workspace_empty": None,
}
self._lastContext = None
self._emit_signal(
WORKSPACE_STATE_CHANGED,
set(),
f"{reason}: i3-context-unknown",
unknownPayload,
)
return False
context = (
frozenset(workspaceIds),
payload["focused_window_id"],
payload["focused_window_title"],
payload["focused_workspace_empty"],
)
if context == self._lastContext:
return False
self._lastContext = context
if transition:
self._emit_signal(DESKTOP_TRANSITION_STARTED, workspaceIds, reason, payload)
self._emit_signal(WORKSPACE_STATE_CHANGED, workspaceIds, reason, payload)
if transition:
self._emit_signal(DESKTOP_TRANSITION_FINISHED, workspaceIds, reason, payload)
return False
def _emit_signal(
self,
signalType: str,
workspaceIds: set[str],
reason: str,
payload: Mapping[str, Any],
) -> None:
if callable(self._emitSignal):
self._emitSignal(signalType, workspaceIds, reason, dict(payload))
+3
View File
@@ -38,6 +38,9 @@ class DesktopContextSnapshot:
locus_of_focus_pid: int = -1
active_window_name: str = ""
locus_of_focus_name: str = ""
focused_window_id: Optional[int] = None
focused_window_title: str = ""
focused_workspace_empty: Optional[bool] = None
@dataclass(frozen=True, slots=True)
+167 -28
View File
@@ -60,6 +60,7 @@ from .compositor_state_types import (
PAUSE_ATSPI_CHURN,
PRIORITIZE_FOCUS,
RESUME_ATSPI_CHURN,
WORKSPACE_STATE_CHANGED,
)
if TYPE_CHECKING:
@@ -113,6 +114,8 @@ class EventManager:
self._compositorStateAdapter: Optional[Any] = None
self._churnSuppressed: bool = cthulhu_state.pauseAtspiChurn
self._prioritizedContextToken: Optional[str] = cthulhu_state.prioritizedDesktopContextToken
self._desktopContextConfirmedEmpty: bool = False
self._flushingStaleAtspiEvents: bool = False
self._relevanceBurstWindow: float = 0.15
self._relevanceBurstHistory: Dict[Tuple[str, str, str], float] = {}
@@ -292,6 +295,9 @@ class EventManager:
if not contextToken:
contextToken = snapshotToken or None
if signalType in (WORKSPACE_STATE_CHANGED, DESKTOP_TRANSITION_FINISHED):
self._update_i3_workspace_context(snapshot)
if signalType in (DESKTOP_FOCUS_CONTEXT_CHANGED, PRIORITIZE_FOCUS):
self._prioritizedContextToken = contextToken
cthulhu_state.prioritizedDesktopContextToken = contextToken
@@ -318,6 +324,43 @@ class EventManager:
if signalType == FLUSH_STALE_ATSPI_EVENTS:
self._flush_stale_atspi_events()
def _update_i3_workspace_context(self, snapshot: Any) -> None:
"""Clears stale accessible state when i3 confirms a blank workspace."""
if snapshot is None or snapshot.backend_name != "i3-ipc":
return
workspaceEmpty = snapshot.focused_workspace_empty
if workspaceEmpty is None:
return
if not workspaceEmpty:
if self._desktopContextConfirmedEmpty:
self._desktopContextConfirmedEmpty = False
GLib.idle_add(self._sync_focus_on_startup)
return
if self._desktopContextConfirmedEmpty:
return
self._desktopContextConfirmedEmpty = True
activeScript = self.app.scriptManager.get_active_script()
if activeScript is not None:
activeScript.presentationInterrupt()
manager = focus_manager.get_manager()
if manager is not None:
manager.clear_state("confirmed empty i3 workspace")
cthulhu_state.pendingSelfHostedFocus = None
self._prioritizedContextToken = None
cthulhu_state.prioritizedDesktopContextToken = None
defaultScript = self.app.scriptManager.get_default_script()
self.app.scriptManager.set_active_script(
defaultScript,
"focus: confirmed-empty-i3-workspace",
)
def ignoreEventTypes(self, eventTypeList: List[str]) -> None:
for eventType in eventTypeList:
if eventType not in self._ignoredEvents:
@@ -882,32 +925,32 @@ class EventManager:
return False
def _addToQueue(self, event: Any, asyncMode: bool) -> None:
def _addToQueue(self, event: Any, asyncMode: bool) -> bool:
debugging = debug.debugEventQueue
if debugging:
debug.printMessage(debug.LEVEL_ALL, " acquiring lock...")
self._gidleLock.acquire()
if debugging:
debug.printMessage(debug.LEVEL_ALL, " ...acquired")
debug.printMessage(debug.LEVEL_ALL, " calling queue.put...")
debug.printMessage(debug.LEVEL_ALL, " (full=%s)" \
% self._eventQueue.full())
with self._gidleLock:
effectiveAsyncMode = asyncMode or self._flushingStaleAtspiEvents
if debugging:
debug.printMessage(debug.LEVEL_ALL, " ...acquired")
debug.printMessage(debug.LEVEL_ALL, " calling queue.put...")
debug.printMessage(debug.LEVEL_ALL, " (full=%s)" \
% self._eventQueue.full())
self._eventQueue.put(event)
if debugging:
debug.printMessage(debug.LEVEL_ALL, " ...put complete")
self._eventQueue.put(event)
if debugging:
debug.printMessage(debug.LEVEL_ALL, " ...put complete")
if asyncMode and not self._gidleId:
if self._gilSleepTime:
time.sleep(self._gilSleepTime)
self._gidleId = GLib.idle_add(self._dequeue)
if effectiveAsyncMode and not self._gidleId:
if self._gilSleepTime:
time.sleep(self._gilSleepTime)
self._gidleId = GLib.idle_add(self._dequeue)
if debugging:
debug.printMessage(debug.LEVEL_ALL, " releasing lock...")
self._gidleLock.release()
if debug.debugEventQueue:
debug.printMessage(debug.LEVEL_ALL, " releasing lock...")
debug.printMessage(debug.LEVEL_ALL, " ...released")
return effectiveAsyncMode
def _queuePrintln(self, e: Any, isEnqueue: bool = True, isPrune: Optional[bool] = None) -> None:
"""Convenience method to output queue-related debugging info."""
@@ -1076,7 +1119,7 @@ class EventManager:
script = cthulhu.cthulhuApp.scriptManager.get_script(AXObject.get_application(e.source), e.source)
script.eventCache[e.type] = (e, time.time())
self._addToQueue(e, asyncMode)
asyncMode = self._addToQueue(e, asyncMode)
if not asyncMode:
self._dequeue()
@@ -1685,26 +1728,58 @@ class EventManager:
def _flush_stale_atspi_events(self) -> None:
"""Drops queued events that no longer match the compositor context."""
self._gidleLock.acquire()
try:
with self._gidleLock:
self._flushingStaleAtspiEvents = True
originalQueue = self._eventQueue
newQueue: queue.Queue[Any] = queue.Queue(0)
self._eventQueue = queue.Queue(0)
retainedEvents: list[Any] = []
try:
while not originalQueue.empty():
try:
event = originalQueue.get_nowait()
except queue.Empty:
break
if self._event_is_from_stale_context(event) and not self._should_preserve_during_suppression(event):
# Accessible property calls can dispatch nested AT-SPI events.
# Keep them outside _gidleLock so a nested enqueue cannot deadlock.
try:
isStale = self._event_is_from_stale_context(event)
shouldPreserve = (
isStale
and self._should_preserve_during_suppression(event)
)
except Exception:
retainedEvents.append(event)
raise
if isStale and not shouldPreserve:
continue
newQueue.put(event)
self._eventQueue = newQueue
if self._asyncMode and not self._eventQueue.empty() and not self._gidleId:
self._gidleId = GLib.idle_add(self._dequeue)
retainedEvents.append(event)
finally:
self._gidleLock.release()
while not originalQueue.empty():
try:
retainedEvents.append(originalQueue.get_nowait())
except queue.Empty:
break
with self._gidleLock:
eventsQueuedDuringFlush = self._eventQueue
mergedQueue: queue.Queue[Any] = queue.Queue(0)
for event in retainedEvents:
mergedQueue.put(event)
while not eventsQueuedDuringFlush.empty():
try:
mergedQueue.put(eventsQueuedDuringFlush.get_nowait())
except queue.Empty:
break
self._eventQueue = mergedQueue
self._flushingStaleAtspiEvents = False
if self._asyncMode and not self._eventQueue.empty() and not self._gidleId:
self._gidleId = GLib.idle_add(self._dequeue)
def _inFlood(self) -> bool:
size = self._eventQueue.qsize()
@@ -1715,6 +1790,49 @@ class EventManager:
return False
def _event_refreshes_xterm_handoff(self, event: Atspi.Event) -> bool:
"""Returns True when an event can represent a real foreground transition."""
if event.type.startswith("window:activate"):
return True
if event.type.startswith("object:state-changed:focused"):
return bool(event.detail1)
if event.type.startswith("object:state-changed:active") and event.detail1:
return AXUtilities.is_frame(event.source) or AXUtilities.is_window(event.source)
return False
def _is_notification_event_during_xterm_handoff(self, event: Atspi.Event) -> bool:
"""Returns True when an event should bypass XTerm handoff suppression."""
for obj in (event.source, event.any_data):
if obj is None or isinstance(obj, (str, bytes, int, float, bool)):
continue
try:
if AXUtilities.is_notification(obj) or AXUtilities.is_alert(obj):
return True
except Exception:
pass
notificationEventTypes = (
"window:create",
"object:property-change:accessible-name",
"object:property-change:accessible-value",
)
if not event.type.startswith(notificationEventTypes):
return False
app = AXObject.get_application(event.source)
appName = (AXObject.get_name(app) or "").casefold()
return appName in (
"notification-daemon",
"notify-osd",
"xfce4-notifyd",
"mate-notification-daemon",
)
def _processObjectEvent(self, event: Atspi.Event) -> None:
"""Handles all object events destined for scripts.
@@ -1728,6 +1846,27 @@ class EventManager:
debug.printObjectEvent(debug.LEVEL_INFO, event, timestamp=True)
eType = event.type
if self._desktopContextConfirmedEmpty \
and not self._is_notification_event_during_xterm_handoff(event):
msg = "EVENT MANAGER: Ignoring AT-SPI event on confirmed empty i3 workspace."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return
refreshXtermHandoff = self._event_refreshes_xterm_handoff(event)
isNotification = self._is_notification_event_during_xterm_handoff(event)
suppressForXterm = not isNotification and self._inputEventManager is not None and (
self._inputEventManager.should_suppress_atspi_events_for_xterm(
refresh=refreshXtermHandoff,
)
)
if suppressForXterm:
msg = (
"EVENT MANAGER: Ignoring AT-SPI event while XTerm owns the "
"foreground handoff."
)
debug.printMessage(debug.LEVEL_INFO, msg, True)
return
if eType.startswith("object:children-changed:remove") \
and event.source == AXUtilities.get_desktop():
cthulhu.cthulhuApp.scriptManager.reclaim_scripts()
+312 -40
View File
@@ -120,10 +120,20 @@ class InputEventManager:
self._secure_input_active: bool = False
self._wnck = None
self._did_attempt_wnck_load: bool = False
self._x11Display = None
self._x11Root = None
self._netActiveWindowAtom = None
self._x11AnyPropertyType = None
self._didAttemptX11Display: bool = False
self._lastEwmhReadWindowId: Optional[int] = None
self._lastEwmhActiveWindowId: Optional[int] = None
self._lastEwmhXtermMatch: Optional[bool] = None
self._scriptWithSuspendedGrabsForXterm = None
self._xtermFocusScreen = None
self._xtermFocusHandlerId: int = 0
self._xtermRecoverySourceId: int = 0
self._xtermRecoveryPollCount: int = 0
self._xtermHandoffHistory: List[str] = []
def activate_device(self) -> Atspi.Device:
"""Creates and returns the AT-SPI device used by this manager."""
@@ -177,6 +187,9 @@ class InputEventManager:
debug.print_message(debug.LEVEL_INFO, msg, True)
device.add_key_watcher(self.process_keyboard_event)
if os.environ.get("XDG_SESSION_TYPE", "").lower() == "x11":
self._initialize_xterm_handoff()
def stop_key_watcher(self) -> None:
"""Stops the watcher for keyboard input events."""
@@ -201,6 +214,8 @@ class InputEventManager:
self._device = None
self._stop_xterm_focus_monitor()
self._stop_xterm_recovery_timer()
self._close_x11_display()
self._didAttemptX11Display = False
self._scriptWithSuspendedGrabsForXterm = None
def get_debug_snapshot(self) -> Dict[str, object]:
@@ -215,6 +230,22 @@ class InputEventManager:
"paused": self._paused,
"secure_input_active": self._secure_input_active,
"suspended_xterm_script_present": self._scriptWithSuspendedGrabsForXterm is not None,
"xterm_focus_monitor_active": self._xtermFocusHandlerId != 0,
"xterm_recovery_timer_active": self._xtermRecoverySourceId != 0,
"xterm_recovery_source_present": self._xterm_recovery_source_is_present(),
"xterm_recovery_poll_count": self._xtermRecoveryPollCount,
"xterm_handoff_history": " | ".join(self._xtermHandoffHistory),
"last_ewmh_read_window_id": (
hex(self._lastEwmhReadWindowId)
if self._lastEwmhReadWindowId is not None
else ""
),
"last_ewmh_active_window_id": (
hex(self._lastEwmhActiveWindowId)
if self._lastEwmhActiveWindowId is not None
else ""
),
"last_ewmh_xterm_match": self._lastEwmhXtermMatch,
"last_input_event_present": self._last_input_event is not None,
"last_non_modifier_key_event_present": self._last_non_modifier_key_event is not None,
}
@@ -524,8 +555,8 @@ class InputEventManager:
return self._wnck
def _get_active_x11_window(self) -> Optional[Any]:
"""Returns the active X11 window if Wnck can provide it."""
def _get_active_x11_window(self, ewmhWindowId: Optional[int] = None) -> Optional[Any]:
"""Returns the EWMH active X11 window, using Wnck for its metadata."""
wnck = self._get_wnck()
if wnck is None:
@@ -537,12 +568,93 @@ class InputEventManager:
return None
self._ensure_xterm_focus_monitor(screen)
screen.force_update()
windowId = ewmhWindowId
if windowId is None:
windowId = self._get_ewmh_active_x11_window_id()
if windowId is not None:
window = wnck.Window.get(windowId)
if window is None:
tokens = [
"INPUT EVENT MANAGER: Wnck has no window for EWMH active X11 id",
hex(windowId),
]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return window
return screen.get_active_window()
except Exception as error:
msg = f"INPUT EVENT MANAGER: Could not obtain active X11 window: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return None
def _get_ewmh_active_x11_window_id(self) -> Optional[int]:
"""Returns _NET_ACTIVE_WINDOW from the X11 root window when available."""
if not self._ensure_x11_display():
return None
try:
propertyData = self._x11Root.get_full_property(
self._netActiveWindowAtom,
self._x11AnyPropertyType,
)
if propertyData is None or not propertyData.value:
return None
windowId = int(propertyData.value[0])
return windowId if windowId > 0 else None
except Exception as error:
msg = f"INPUT EVENT MANAGER: Could not read EWMH active window: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
self._close_x11_display()
self._didAttemptX11Display = False
return None
def _ensure_x11_display(self) -> bool:
"""Initializes the optional exact-X11 metadata connection."""
if self._x11Display is not None:
return True
if not self._didAttemptX11Display:
self._didAttemptX11Display = True
try:
from Xlib import X # pylint: disable=import-outside-toplevel
from Xlib import display as xDisplay # pylint: disable=import-outside-toplevel
self._x11Display = xDisplay.Display()
self._x11Root = self._x11Display.screen().root
self._netActiveWindowAtom = self._x11Display.intern_atom("_NET_ACTIVE_WINDOW")
self._x11AnyPropertyType = X.AnyPropertyType
except Exception as error:
msg = f"INPUT EVENT MANAGER: EWMH active-window lookup unavailable: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
self._close_x11_display()
return (
self._x11Display is not None
and self._x11Root is not None
and self._netActiveWindowAtom is not None
)
def _close_x11_display(self) -> None:
"""Closes the optional X11 display used for EWMH focus lookup."""
x11Display = self._x11Display
self._x11Display = None
self._x11Root = None
self._netActiveWindowAtom = None
self._x11AnyPropertyType = None
self._lastEwmhReadWindowId = None
self._lastEwmhActiveWindowId = None
self._lastEwmhXtermMatch = None
if x11Display is None:
return
try:
x11Display.close()
except Exception as error:
msg = f"INPUT EVENT MANAGER: Could not close EWMH display: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
def _get_active_x11_window_pid(self) -> int:
"""Returns the PID of the active X11 window if Wnck can provide it."""
@@ -641,6 +753,16 @@ class InputEventManager:
self._xtermFocusScreen = screen
self._xtermFocusHandlerId = handlerId
def _initialize_xterm_handoff(self) -> None:
"""Initializes X11 focus monitoring and suspends immediately for XTerm."""
match = self._active_x11_window_xterm_match()
tokens = ["INPUT EVENT MANAGER: Initial XTerm handoff matcher returned", match]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
if match is True:
self._suspend_key_grabs_for_xterm()
self._ensure_xterm_recovery_timer()
def _stop_xterm_recovery_timer(self) -> None:
"""Stops the fallback timer used to recover XTerm-suspended grabs."""
@@ -656,7 +778,7 @@ class InputEventManager:
debug.print_message(debug.LEVEL_INFO, msg, True)
def _ensure_xterm_recovery_timer(self) -> None:
"""Starts fallback recovery when X11 focus notifications are insufficient."""
"""Starts generic X11 polling when focus notifications are insufficient."""
if self._device is None or self._xtermRecoverySourceId:
return
@@ -666,53 +788,96 @@ class InputEventManager:
self._poll_xterm_grab_recovery,
)
def _poll_xterm_grab_recovery(self) -> bool:
"""Checks whether XTerm-suspended grabs can now be restored."""
def _xterm_recovery_source_is_present(self) -> bool:
"""Returns whether GLib still owns the recorded recovery source."""
if self._scriptWithSuspendedGrabsForXterm is None:
self._xtermRecoverySourceId = 0
if not self._xtermRecoverySourceId:
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()
context = GLib.MainContext.default()
return context.find_source_by_id(self._xtermRecoverySourceId) is not None
except Exception:
return False
def _poll_xterm_grab_recovery(self) -> bool:
"""Updates XTerm handoff state from the authoritative EWMH active window."""
self._xtermRecoveryPollCount += 1
if self._device is None:
self._xtermRecoverySourceId = 0
return False
try:
match = self._active_x11_window_xterm_match()
if match is True:
self._suspend_key_grabs_for_xterm()
elif match is False:
self._restore_key_grabs_after_xterm()
except Exception as error:
msg = f"INPUT EVENT MANAGER: Could not check changed X11 focus: {error}"
msg = f"INPUT EVENT MANAGER: XTerm recovery poll failed: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
self._ensure_xterm_recovery_timer()
return True
def _record_xterm_handoff_action(self, action: str) -> None:
"""Retains a short history of XTerm focus and grab-state transitions."""
if self._xtermHandoffHistory and self._xtermHandoffHistory[-1] == action:
return
match = self._x11_window_xterm_match(window)
self._xtermHandoffHistory.append(action)
self._xtermHandoffHistory = self._xtermHandoffHistory[-12:]
def _on_active_x11_window_changed(self, screen: Any, _previousWindow: Any) -> None:
"""Updates grab suspension as soon as X11 focus changes."""
del screen
match = self._active_x11_window_xterm_match()
tokens = ["INPUT EVENT MANAGER: XTerm focus-change matcher returned", match]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
if match is False:
if match is True:
self._suspend_key_grabs_for_xterm()
elif match is False:
self._restore_key_grabs_after_xterm()
elif match is None:
elif self._scriptWithSuspendedGrabsForXterm is not None:
self._ensure_xterm_recovery_timer()
def _active_x11_window_xterm_match(self) -> Optional[bool]:
"""Returns whether the active X11 window is XTerm, or None when unknown."""
window = self._get_active_x11_window()
return self._x11_window_xterm_match(window)
snapshot = cthulhu_state.compositorSnapshot
usingI3Context = snapshot is not None and snapshot.backend_name == "i3-ipc"
if usingI3Context and snapshot.focused_workspace_empty is True:
self._record_xterm_handoff_action("i3:confirmed-empty-workspace")
self._lastEwmhReadWindowId = None
return False
if usingI3Context and snapshot.focused_window_id is not None:
windowId = snapshot.focused_window_id
else:
windowId = self._get_ewmh_active_x11_window_id()
previousWindowId = self._lastEwmhReadWindowId
if windowId != previousWindowId:
previousText = hex(previousWindowId) if previousWindowId is not None else "unknown"
currentText = hex(windowId) if windowId is not None else "unknown"
self._record_xterm_handoff_action(f"ewmh:{previousText}->{currentText}")
self._lastEwmhReadWindowId = windowId
if (
windowId is not None
and windowId == self._lastEwmhActiveWindowId
and self._lastEwmhXtermMatch is not None
):
return self._lastEwmhXtermMatch
if windowId is None:
return None
match = self._x11_window_id_xterm_match(windowId)
if windowId is not None and match is not None:
self._lastEwmhActiveWindowId = windowId
self._lastEwmhXtermMatch = match
return match
def _x11_window_xterm_match(self, window: Any) -> Optional[bool]:
"""Returns whether window is XTerm, or None when it cannot be identified."""
@@ -770,11 +935,104 @@ class InputEventManager:
return self._identifier_is_xterm(executable)
def _get_x11_window_metadata(
self,
windowId: int,
) -> Optional[Tuple[List[str], int]]:
"""Returns identifiers and pid directly from the exact X11 window id."""
if not self._ensure_x11_display():
return None
try:
window = self._x11Display.create_resource_object("window", windowId)
identifiers = [
value
for value in (window.get_wm_class() or ())
if isinstance(value, str) and value.strip()
]
name = window.get_wm_name()
if isinstance(name, str) and name.strip():
identifiers.append(name)
netWmNameAtom = self._x11Display.intern_atom("_NET_WM_NAME")
utf8StringAtom = self._x11Display.intern_atom("UTF8_STRING")
nameProperty = window.get_full_property(netWmNameAtom, utf8StringAtom)
if nameProperty is not None and nameProperty.value:
value = nameProperty.value
if isinstance(value, bytes):
value = value.decode(errors="ignore")
if isinstance(value, str) and value.strip():
identifiers.append(value)
netWmPidAtom = self._x11Display.intern_atom("_NET_WM_PID")
pidProperty = window.get_full_property(
netWmPidAtom,
self._x11AnyPropertyType,
)
pid = -1
if pidProperty is not None and pidProperty.value:
pid = int(pidProperty.value[0])
except Exception as error:
msg = (
"INPUT EVENT MANAGER: Could not read metadata for X11 window "
f"{hex(windowId)}: {error}"
)
debug.print_message(debug.LEVEL_INFO, msg, True)
return None
return identifiers, pid
def _x11_window_id_xterm_match(self, windowId: int) -> Optional[bool]:
"""Returns whether the exact EWMH X11 window id identifies XTerm."""
metadata = self._get_x11_window_metadata(windowId)
if metadata is None:
return None
identifiers, pid = metadata
if any(self._identifier_is_xterm(value) for value in identifiers):
return True
if pid > 0:
try:
with open(f"/proc/{pid}/cmdline", "rb") as cmdlineFile:
executable = cmdlineFile.read().split(b"\0", 1)[0].decode(errors="ignore")
if self._identifier_is_xterm(executable):
return True
except OSError:
pass
if identifiers or pid > 0:
return False
return None
def _active_x11_window_is_xterm(self) -> bool:
"""Returns True when the active X11 window appears to be XTerm."""
return self._active_x11_window_xterm_match() is True
def should_suppress_atspi_events_for_xterm(self, refresh: bool = False) -> bool:
"""Returns True while the XTerm handoff should suppress AT-SPI events."""
suspendedScript = self._scriptWithSuspendedGrabsForXterm
if suspendedScript is None:
return False
if not refresh:
return True
match = self._active_x11_window_xterm_match()
tokens = ["INPUT EVENT MANAGER: XTerm AT-SPI suppression matcher returned", match]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
if match is False:
self._restore_key_grabs_after_xterm()
return False
return True
def _find_active_x11_atspi_window(self) -> Optional[Atspi.Accessible]:
"""Returns the focused AT-SPI window for the active X11 PID, if possible."""
@@ -924,7 +1182,17 @@ class InputEventManager:
"""Suspends active-script key grabs so XTerm/Fenrir can receive them."""
script = script_manager.get_manager().get_active_script()
if script is None or script is self._scriptWithSuspendedGrabsForXterm:
if script is None:
self._record_xterm_handoff_action("suspend:skipped-no-active-script")
return
if script is self._scriptWithSuspendedGrabsForXterm:
grabIds = getattr(script, "grab_ids", [])
scriptGrabCount = len(grabIds) if isinstance(grabIds, list) else -1
self._record_xterm_handoff_action(
f"suspend:already-marked:script-grabs={scriptGrabCount}:"
f"all-grabs={len(self._grabbed_bindings)}"
)
return
if self._scriptWithSuspendedGrabsForXterm is not None:
@@ -941,8 +1209,10 @@ class InputEventManager:
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
removeGrabs()
self._scriptWithSuspendedGrabsForXterm = script
if not self._xtermFocusHandlerId:
self._ensure_xterm_recovery_timer()
self._record_xterm_handoff_action(
f"suspend:success:all-grabs={len(self._grabbed_bindings)}"
)
self._ensure_xterm_recovery_timer()
def _restore_key_grabs_after_xterm(self) -> None:
"""Restores key grabs after leaving XTerm."""
@@ -953,6 +1223,7 @@ class InputEventManager:
activeScript = script_manager.get_manager().get_active_script()
if script is not activeScript:
self._record_xterm_handoff_action("restore:dropped-active-script-changed")
tokens = [
"INPUT EVENT MANAGER: Not restoring XTerm-suspended key grabs; active script changed:",
script,
@@ -960,7 +1231,6 @@ class InputEventManager:
]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
self._scriptWithSuspendedGrabsForXterm = None
self._stop_xterm_recovery_timer()
return
addGrabs = getattr(script, "addKeyGrabs", None)
@@ -975,6 +1245,7 @@ class InputEventManager:
try:
addGrabs()
except Exception as error:
self._record_xterm_handoff_action(f"restore:failed:{type(error).__name__}")
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)
@@ -988,13 +1259,14 @@ class InputEventManager:
)
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()
self._record_xterm_handoff_action(
f"restore:success:all-grabs={len(self._grabbed_bindings)}"
)
# pylint: disable=too-many-arguments
# pylint: disable=too-many-positional-arguments
+1
View File
@@ -34,6 +34,7 @@ cthulhu_python_sources = files([
'cmdnames.py',
'colornames.py',
'compositor_state_adapter.py',
'compositor_state_i3.py',
'compositor_state_types.py',
'compositor_state_wayland.py',
'common_keyboardmap.py',
+139 -17
View File
@@ -80,7 +80,7 @@ class WindowTitleReader(Plugin):
self,
overwrite=True,
)
if xlibAvailable:
if xlibAvailable or self._get_i3_title_context() is not None:
self._start_tracking()
self._activated = True
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Activated", True)
@@ -136,7 +136,7 @@ class WindowTitleReader(Plugin):
self._present_message("Window title reader off")
return True
if not xlibAvailable:
if not xlibAvailable and self._get_i3_title_context() is None:
self._present_message("Window title reader unavailable")
debug.printMessage(
debug.LEVEL_INFO,
@@ -167,7 +167,7 @@ class WindowTitleReader(Plugin):
self._present_message("Window title reader on")
return True
if not xlibAvailable:
if not xlibAvailable and self._get_i3_title_context() is None:
if notify_user:
self._present_message("Window title reader unavailable")
debug.printMessage(
@@ -218,9 +218,9 @@ class WindowTitleReader(Plugin):
return True
try:
self._display = xDisplay.Display()
self._root = self._display.screen().root
self._init_atoms()
if self._get_i3_title_context() is None:
if not self._ensure_x11_display():
return False
self._pollSourceId = GLib.timeout_add(self._pollIntervalMs, self._poll_window_title)
self._poll_window_title()
return True
@@ -273,17 +273,24 @@ class WindowTitleReader(Plugin):
return False
try:
activeWindow = self._get_active_window()
if not activeWindow:
i3Context = self._get_i3_title_context()
if i3Context is not None:
activeWindowId, windowTitle = i3Context
else:
if not self._ensure_x11_display():
self._lastTitle = None
return True
activeWindow = self._get_active_window()
if not activeWindow:
self._lastTitle = None
return True
activeWindowId = activeWindow.id
windowTitle = self._get_current_title(activeWindow)
if activeWindowId is None or not windowTitle:
self._lastTitle = None
return True
windowTitle = self._get_current_title(activeWindow)
if not windowTitle:
self._lastTitle = None
return True
activeWindowId = activeWindow.id
if self._lastActiveWindowId is None:
self._lastActiveWindowId = activeWindowId
elif activeWindowId != self._lastActiveWindowId:
@@ -321,12 +328,25 @@ class WindowTitleReader(Plugin):
def get_fallback_title(self, atspiTitle=None):
"""Returns the X11 title when AT-SPI has not exposed an equivalent title."""
i3Context = self._get_i3_title_context()
if i3Context is not None:
_windowId, titleText = i3Context
if not titleText:
return ""
if atspiTitle is None:
atspiTitle = self._get_atspi_title()
if self._titles_match(atspiTitle, titleText):
return ""
return titleText
if not xlibAvailable and self._display is None:
return ""
startedTracking = self._pollSourceId is not None
if not startedTracking and not self._start_tracking():
return ""
if not self._ensure_x11_display():
return ""
activeWindow = self._get_active_window()
titleText = self._get_current_title(activeWindow) if activeWindow else ""
@@ -341,6 +361,65 @@ class WindowTitleReader(Plugin):
return titleText
def _get_i3_title_context(self):
"""Returns authoritative i3 window id/title, or None when i3 is not active."""
if not self.app:
return None
try:
adapter = self.app.getCompositorStateAdapter()
snapshot = adapter.get_snapshot() if adapter else None
except Exception:
return None
if snapshot is None or getattr(snapshot, "backend_name", "") != "i3-ipc":
return None
if getattr(snapshot, "focused_workspace_empty", None) is True:
return (None, "")
windowId = getattr(snapshot, "focused_window_id", None)
titleText = str(getattr(snapshot, "focused_window_title", "") or "").strip()
if windowId is None:
return None
if not titleText or self._is_wine_desktop_title(titleText):
if not self._ensure_x11_display():
return None
try:
activeWindow = self._display.create_resource_object("window", windowId)
except Exception:
return None
titleText = self._get_current_title(activeWindow)
if not titleText:
return None
return (windowId, titleText)
def _ensure_x11_display(self):
"""Lazily opens the generic X11 title source when i3 IPC is unavailable."""
if self._display is not None:
return True
if not xlibAvailable:
return False
try:
self._display = xDisplay.Display()
self._root = self._display.screen().root
self._init_atoms()
except Exception as error:
debug.printMessage(
debug.LEVEL_INFO,
f"WindowTitleReader: Failed to open X11 fallback: {error}",
True,
)
self._cleanup_display()
return False
return True
def _get_atspi_title(self):
if not self.app:
return ""
@@ -386,9 +465,11 @@ class WindowTitleReader(Plugin):
def _get_wine_desktop_title(self, desktopWindow):
focusWindow = self._get_focus_window()
if focusWindow and focusWindow.id != desktopWindow.id:
if focusWindow \
and focusWindow.id != desktopWindow.id \
and self._window_is_descendant_of(focusWindow, desktopWindow):
focusTitle = self._get_window_title(focusWindow)
if focusTitle and not self._is_wine_desktop_title(focusTitle):
if self._is_usable_wine_child_title(focusWindow, focusTitle):
return focusTitle
return self._find_child_title(desktopWindow)
@@ -404,7 +485,7 @@ class WindowTitleReader(Plugin):
window = childQueue.pop(0)
checkedCount += 1
titleText = self._get_window_title(window)
if titleText and not self._is_wine_desktop_title(titleText):
if self._is_usable_wine_child_title(window, titleText):
return titleText
try:
@@ -414,6 +495,47 @@ class WindowTitleReader(Plugin):
return ""
def _window_is_descendant_of(self, window, ancestor):
if not window or not ancestor:
return False
current = window
for _hop in range(64):
if current.id == ancestor.id:
return True
try:
parent = current.query_tree().parent
except xError.XError:
return False
if not parent or parent.id == current.id:
return False
current = parent
return False
def _is_usable_wine_child_title(self, window, titleText):
if not titleText or self._is_wine_desktop_title(titleText):
return False
normalizedTitle = " ".join(titleText.casefold().split())
if normalizedTitle in ("default ime", "program manager"):
return False
try:
windowClass = window.get_wm_class() or ()
except xError.XError:
windowClass = ()
normalizedClass = {
str(value or "").strip().casefold()
for value in windowClass
}
if "cthulhu-wine-access.exe" in normalizedClass:
return False
if normalizedTitle == "nvda" and "nvda.exe" in normalizedClass:
return False
return True
def _is_wine_desktop_title(self, titleText):
return self._wineDesktopLabel.lower() in titleText.lower()
+39 -15
View File
@@ -171,12 +171,20 @@ class Script(web.Script):
def onColumnReordered(self, event):
"""Callback for object:column-reordered accessibility events."""
if super().onColumnReordered(event):
return
def handle_shared_web(routed_event: object) -> RouteResult:
handled = web.Script.onColumnReordered(self, routed_event)
return RouteResult.HANDLED if handled else RouteResult.CONTINUE
msg = "CHROMIUM: Passing along event to default script"
debug.printMessage(debug.LEVEL_INFO, msg, True)
default.Script.onColumnReordered(self, event)
self._eventRouter.route_event(
event,
application_handler=None,
toolkit_handler=None,
web_handler=handle_shared_web,
default_handler=lambda routed_event: default.Script.onColumnReordered(
self,
routed_event,
),
)
def onChildrenAdded(self, event):
"""Callback for object:children-changed:add accessibility events."""
@@ -256,12 +264,20 @@ class Script(web.Script):
def onExpandedChanged(self, event):
"""Callback for object:state-changed:expanded accessibility events."""
if super().onExpandedChanged(event):
return
def handle_shared_web(routed_event: object) -> RouteResult:
handled = web.Script.onExpandedChanged(self, routed_event)
return RouteResult.HANDLED if handled else RouteResult.CONTINUE
msg = "CHROMIUM: Passing along event to default script"
debug.printMessage(debug.LEVEL_INFO, msg, True)
default.Script.onExpandedChanged(self, event)
self._eventRouter.route_event(
event,
application_handler=None,
toolkit_handler=None,
web_handler=handle_shared_web,
default_handler=lambda routed_event: default.Script.onExpandedChanged(
self,
routed_event,
),
)
def onFocus(self, event):
"""Callback for focus: accessibility events."""
@@ -315,12 +331,20 @@ class Script(web.Script):
def onRowReordered(self, event):
"""Callback for object:row-reordered accessibility events."""
if super().onRowReordered(event):
return
def handle_shared_web(routed_event: object) -> RouteResult:
handled = web.Script.onRowReordered(self, routed_event)
return RouteResult.HANDLED if handled else RouteResult.CONTINUE
msg = "CHROMIUM: Passing along event to default script"
debug.printMessage(debug.LEVEL_INFO, msg, True)
default.Script.onRowReordered(self, event)
self._eventRouter.route_event(
event,
application_handler=None,
toolkit_handler=None,
web_handler=handle_shared_web,
default_handler=lambda routed_event: default.Script.onRowReordered(
self,
routed_event,
),
)
def onSelectedChanged(self, event):
"""Callback for object:state-changed:selected accessibility events."""
+39 -15
View File
@@ -144,12 +144,20 @@ class Script(web.Script):
def onColumnReordered(self, event):
"""Callback for object:column-reordered accessibility events."""
if super().onColumnReordered(event):
return
def handle_shared_web(routed_event: object) -> RouteResult:
handled = web.Script.onColumnReordered(self, routed_event)
return RouteResult.HANDLED if handled else RouteResult.CONTINUE
msg = "GECKO: Passing along event to default script"
debug.printMessage(debug.LEVEL_INFO, msg, True)
default.Script.onColumnReordered(self, event)
self._eventRouter.route_event(
event,
application_handler=None,
toolkit_handler=None,
web_handler=handle_shared_web,
default_handler=lambda routed_event: default.Script.onColumnReordered(
self,
routed_event,
),
)
def onChildrenAdded(self, event):
"""Callback for object:children-changed:add accessibility events."""
@@ -209,12 +217,20 @@ class Script(web.Script):
def onExpandedChanged(self, event):
"""Callback for object:state-changed:expanded accessibility events."""
if super().onExpandedChanged(event):
return
def handle_shared_web(routed_event: object) -> RouteResult:
handled = web.Script.onExpandedChanged(self, routed_event)
return RouteResult.HANDLED if handled else RouteResult.CONTINUE
msg = "GECKO: Passing along event to default script"
debug.printMessage(debug.LEVEL_INFO, msg, True)
default.Script.onExpandedChanged(self, event)
self._eventRouter.route_event(
event,
application_handler=None,
toolkit_handler=None,
web_handler=handle_shared_web,
default_handler=lambda routed_event: default.Script.onExpandedChanged(
self,
routed_event,
),
)
def onFocus(self, event):
"""Callback for focus: accessibility events."""
@@ -285,12 +301,20 @@ class Script(web.Script):
def onRowReordered(self, event):
"""Callback for object:row-reordered accessibility events."""
if super().onRowReordered(event):
return
def handle_shared_web(routed_event: object) -> RouteResult:
handled = web.Script.onRowReordered(self, routed_event)
return RouteResult.HANDLED if handled else RouteResult.CONTINUE
msg = "GECKO: Passing along event to default script"
debug.printMessage(debug.LEVEL_INFO, msg, True)
default.Script.onRowReordered(self, event)
self._eventRouter.route_event(
event,
application_handler=None,
toolkit_handler=None,
web_handler=handle_shared_web,
default_handler=lambda routed_event: default.Script.onRowReordered(
self,
routed_event,
),
)
def onSelectedChanged(self, event):
"""Callback for object:state-changed:selected accessibility events."""
+11 -16
View File
@@ -1152,7 +1152,13 @@ class Script(default.Script):
def sayLine(self, obj):
"""Speaks the line at the current caret position."""
isEditable = self.utilities.isContentEditableWithEmbeddedObjects(obj)
isNativeMultilineEditable = (
self.utilities.inDocumentContent(obj)
and AXUtilities.is_editable(obj)
and AXUtilities.is_multi_line(obj)
)
isEditable = self.utilities.isContentEditableWithEmbeddedObjects(obj) \
or isNativeMultilineEditable
if not (self._lastCommandWasCaretNav or self._lastCommandWasStructNav) and not isEditable:
super().sayLine(obj)
return
@@ -1164,7 +1170,10 @@ class Script(default.Script):
obj, offset = self.utilities.getCaretContext(documentFrame=document)
contents = self.utilities.getLineContentsAtOffset(obj, offset, useCache=True)
self.speakContents(contents, priorObj=priorObj)
if isNativeMultilineEditable:
self.speakContents(contents, priorObj=priorObj, alreadyFocused=True)
else:
self.speakContents(contents, priorObj=priorObj)
self.pointOfReference["lastTextUnitSpoken"] = "line"
def presentObject(self, obj, **args):
@@ -2651,20 +2660,6 @@ class Script(default.Script):
self.utilities.setCaretContext(event.source, 0)
obj, offset = event.source, 0
if self._lastCommandWasCaretNav \
and obj is not event.source \
and AXUtilities.is_focusable(event.source) \
and AXUtilities.is_focused(event.source) \
and self.utilities.inDocumentContent(event.source) \
and self.utilities.isLink(event.source) \
and obj \
and AXUtilities.is_section(obj):
msg = "WEB: Event handled: Replacing stale section context with focused link"
debug.printMessage(debug.LEVEL_INFO, msg, True)
cthulhu.setLocusOfFocus(event, event.source, False)
self.utilities.setCaretContext(event.source, 0)
return True
if self._lastCommandWasCaretNav:
msg = "WEB: Event ignored: Last command was caret nav"
debug.printMessage(debug.LEVEL_INFO, msg, True)
+19 -13
View File
@@ -1822,6 +1822,17 @@ class Utilities(script_utilities.Utilities):
string = AXText.get_substring(obj, offset, firstEnd)
return [[firstObj, offset, firstEnd, string]] + contents[1:]
def _getEditableLineBoundary(self, obj):
if self.isContentEditableWithEmbeddedObjects(obj):
return obj
if self.inDocumentContent(obj) \
and AXUtilities.is_editable(obj) \
and AXUtilities.is_multi_line(obj):
return obj
return AXObject.find_ancestor(obj, self.isContentEditableWithEmbeddedObjects)
def _getLineContentsAtOffset(self, obj, offset, layoutMode=None, useCache=True):
startTime = time.time()
if not obj:
@@ -1930,15 +1941,10 @@ class Utilities(script_utilities.Utilities):
prevObj, pOffset = self.findPreviousCaretInOrder(firstObj, firstStart)
nextObj, nOffset = self.findNextCaretInOrder(lastObj, lastEnd - 1)
# If we're inside a content editable, don't expand line contents beyond
# its boundaries (e.g. don't include a "More options" button adjacent to
# a message entry just because it's on the same visual line).
contentEditableBoundary = None
if self.isContentEditableWithEmbeddedObjects(obj):
contentEditableBoundary = obj
else:
contentEditableBoundary = AXObject.find_ancestor(
obj, self.isContentEditableWithEmbeddedObjects)
# If we're inside an editable, don't expand line contents beyond its
# boundaries (e.g. don't include an adjacent button just because it's
# on the same visual line).
editableBoundary = self._getEditableLineBoundary(obj)
# Check for things on the same line to the left of this object.
prevStartTime = time.time()
@@ -1954,8 +1960,8 @@ class Utilities(script_utilities.Utilities):
if objRow != AXObject.find_ancestor(prevObj, AXUtilities.is_table_row):
break
if contentEditableBoundary and prevObj != contentEditableBoundary \
and not AXObject.find_ancestor(prevObj, lambda x: x == contentEditableBoundary):
if editableBoundary and prevObj != editableBoundary \
and not AXObject.find_ancestor(prevObj, lambda x: x == editableBoundary):
break
onLeft = self._getContentsForObj(prevObj, pOffset, boundary)
@@ -1988,8 +1994,8 @@ class Utilities(script_utilities.Utilities):
if objRow != AXObject.find_ancestor(nextObj, AXUtilities.is_table_row):
break
if contentEditableBoundary and nextObj != contentEditableBoundary \
and not AXObject.find_ancestor(nextObj, lambda x: x == contentEditableBoundary):
if editableBoundary and nextObj != editableBoundary \
and not AXObject.find_ancestor(nextObj, lambda x: x == editableBoundary):
break
onRight = self._getContentsForObj(nextObj, nOffset, boundary)
@@ -11,6 +11,7 @@ import cthulhu as cthulhu_package
from cthulhu import cthulhu_state
from cthulhu import compositor_state_adapter
from cthulhu import compositor_state_i3
from cthulhu import compositor_state_types
from cthulhu import compositor_state_wayland
@@ -74,7 +75,7 @@ class CompositorStateAdapterRegressionTests(unittest.TestCase):
self.assertTrue(callable(selectedBackend.activate_calls[0]))
self.assertEqual(adapter.get_snapshot().backend_name, "selected")
def test_default_workspace_backends_include_wayland_then_null_backend(self) -> None:
def test_default_workspace_backends_include_i3_wayland_then_null_backend(self) -> None:
adapter = compositor_state_adapter.CompositorStateAdapter()
backend_types = [type(backend) for backend in adapter._workspaceBackends]
@@ -82,11 +83,68 @@ class CompositorStateAdapterRegressionTests(unittest.TestCase):
self.assertEqual(
backend_types,
[
compositor_state_i3.I3WorkspaceBackend,
compositor_state_wayland.WaylandSharedProtocolsBackend,
compositor_state_wayland.NullWorkspaceBackend,
],
)
def test_workspace_payload_records_i3_foreground_context(self) -> None:
backend = FakeWorkspaceBackend(True, "i3-ipc")
adapter = compositor_state_adapter.CompositorStateAdapter(workspace_backends=[backend])
adapter.activate()
backend.activate_calls[-1](
compositor_state_types.WORKSPACE_STATE_CHANGED,
{"8"},
"i3-window-focus",
{
"focused_window_id": 0x200000C,
"focused_window_title": "9 devel",
"focused_workspace_empty": False,
},
)
snapshot = adapter.get_snapshot()
self.assertEqual(snapshot.active_workspace_ids, frozenset({"8"}))
self.assertEqual(snapshot.focused_window_id, 0x200000C)
self.assertEqual(snapshot.focused_window_title, "9 devel")
self.assertFalse(snapshot.focused_workspace_empty)
def test_confirmed_empty_workspace_clears_stale_accessible_context(self) -> None:
backend = FakeWorkspaceBackend(True, "i3-ipc")
adapter = compositor_state_adapter.CompositorStateAdapter(workspace_backends=[backend])
adapter.activate()
adapter._snapshot = compositor_state_types.DesktopContextSnapshot(
session_type="x11",
backend_name="i3-ipc",
active_window_token="42:Browser",
locus_of_focus_token="42:Web content",
active_window_pid=42,
locus_of_focus_pid=42,
active_window_name="Browser",
locus_of_focus_name="Web content",
)
backend.activate_calls[-1](
compositor_state_types.WORKSPACE_STATE_CHANGED,
{"9"},
"i3-workspace-focus",
{
"focused_window_id": None,
"focused_window_title": "",
"focused_workspace_empty": True,
},
)
snapshot = adapter.get_snapshot()
self.assertEqual(snapshot.active_window_token, "")
self.assertEqual(snapshot.locus_of_focus_token, "")
self.assertEqual(snapshot.active_window_pid, -1)
self.assertEqual(snapshot.locus_of_focus_pid, -1)
self.assertEqual(snapshot.active_window_name, "")
self.assertEqual(snapshot.locus_of_focus_name, "")
def test_wayland_backend_is_unavailable_without_wayland_session(self) -> None:
backend = compositor_state_wayland.WaylandSharedProtocolsBackend()
@@ -0,0 +1,181 @@
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 compositor_state_i3
from cthulhu import compositor_state_types
class FakeNode:
def __init__(
self,
*,
node_type="con",
name="",
window=None,
window_title=None,
focused=False,
children=None,
):
self.type = node_type
self.name = name
self.window = window
self.window_title = window_title
self.focused = focused
self.nodes = list(children or [])
self.floating_nodes = []
self.parent = None
for child in self.nodes:
child.parent = self
def __iter__(self):
pending = list(self.nodes) + list(self.floating_nodes)
while pending:
node = pending.pop(0)
yield node
pending.extend(node.nodes)
pending.extend(node.floating_nodes)
def workspaces(self):
return [node for node in self if node.type == "workspace"]
def find_focused(self):
if self.focused:
return self
return next((node for node in self if node.focused), None)
def workspace(self):
node = self
while node is not None and node.type != "workspace":
node = node.parent
return node
class FakeWorkspaceReply:
def __init__(self, name, focused):
self.name = name
self.focused = focused
class FakeConnection:
def __init__(self, tree, workspaces):
self.tree = tree
self.workspaceReplies = workspaces
def get_tree(self):
return self.tree
def get_workspaces(self):
return self.workspaceReplies
class I3WorkspaceBackendRegressionTests(unittest.TestCase):
def test_query_reports_focused_window_id_title_and_nonempty_workspace(self):
focusedWindow = FakeNode(
name="AT-SPI misses this title",
window=0x200000C,
window_title="Accurate i3 title",
focused=True,
)
workspace = FakeNode(
node_type="workspace",
name="8",
children=[focusedWindow],
)
root = FakeNode(node_type="root", children=[workspace])
connection = FakeConnection(root, [FakeWorkspaceReply("8", True)])
backend = compositor_state_i3.I3WorkspaceBackend(i3ipc_module=types.SimpleNamespace())
backend._connection = connection
workspaceIds, payload = backend._query_context()
self.assertEqual(workspaceIds, {"8"})
self.assertEqual(payload["focused_window_id"], 0x200000C)
self.assertEqual(payload["focused_window_title"], "Accurate i3 title")
self.assertFalse(payload["focused_workspace_empty"])
def test_query_reports_confirmed_empty_focused_workspace(self):
workspace = FakeNode(
node_type="workspace",
name="9",
focused=True,
)
root = FakeNode(node_type="root", children=[workspace])
connection = FakeConnection(root, [FakeWorkspaceReply("9", True)])
backend = compositor_state_i3.I3WorkspaceBackend(i3ipc_module=types.SimpleNamespace())
backend._connection = connection
workspaceIds, payload = backend._query_context()
self.assertEqual(workspaceIds, {"9"})
self.assertIsNone(payload["focused_window_id"])
self.assertEqual(payload["focused_window_title"], "")
self.assertTrue(payload["focused_workspace_empty"])
def test_refresh_emits_started_state_and_finished_with_foreground_payload(self):
backend = compositor_state_i3.I3WorkspaceBackend(i3ipc_module=types.SimpleNamespace())
backend._active = True
backend._emitSignal = mock.Mock()
payload = {
"focused_window_id": None,
"focused_window_title": "",
"focused_workspace_empty": True,
}
with mock.patch.object(backend, "_query_context", return_value=({"9"}, payload)):
self.assertFalse(backend._refresh_context("workspace-focus", transition=True))
self.assertEqual(
backend._emitSignal.call_args_list,
[
mock.call(
compositor_state_types.DESKTOP_TRANSITION_STARTED,
{"9"},
"workspace-focus",
payload,
),
mock.call(
compositor_state_types.WORKSPACE_STATE_CHANGED,
{"9"},
"workspace-focus",
payload,
),
mock.call(
compositor_state_types.DESKTOP_TRANSITION_FINISHED,
{"9"},
"workspace-focus",
payload,
),
],
)
def test_query_failure_emits_unknown_instead_of_preserving_stale_i3_context(self):
backend = compositor_state_i3.I3WorkspaceBackend(i3ipc_module=types.SimpleNamespace())
backend._active = True
backend._emitSignal = mock.Mock()
backend._lastContext = (frozenset({"8"}), 0x200000C, "Browser", False)
with (
mock.patch.object(backend, "_query_context", side_effect=OSError("IPC disconnected")),
mock.patch.object(compositor_state_i3.debug, "printMessage"),
):
self.assertFalse(backend._refresh_context("window-focus", transition=True))
backend._emitSignal.assert_called_once_with(
compositor_state_types.WORKSPACE_STATE_CHANGED,
set(),
"window-focus: i3-context-unknown",
{
"focused_window_id": None,
"focused_window_title": "",
"focused_workspace_empty": None,
},
)
if __name__ == "__main__":
unittest.main()
@@ -91,6 +91,75 @@ class EventManagerCompositorContextRegressionTests(unittest.TestCase):
self.assertFalse(cthulhu_state.pauseAtspiChurn)
self.assertFalse(self.manager._churnSuppressed)
def test_confirmed_empty_i3_workspace_clears_stale_web_context(self) -> None:
staleScript = mock.Mock()
defaultScript = mock.Mock()
focusManager = mock.Mock()
self.manager.app.scriptManager.get_active_script.return_value = staleScript
self.manager.app.scriptManager.get_default_script.return_value = defaultScript
snapshot = compositor_state_types.DesktopContextSnapshot(
session_type="x11",
backend_name="i3-ipc",
active_workspace_ids=frozenset({"9"}),
focused_workspace_empty=True,
)
with mock.patch.object(
event_manager.focus_manager,
"get_manager",
return_value=focusManager,
):
self.manager._handle_compositor_signal(
compositor_state_types.CompositorStateEvent(
compositor_state_types.DESKTOP_TRANSITION_FINISHED,
reason="workspace-focus",
snapshot=snapshot,
)
)
staleScript.presentationInterrupt.assert_called_once_with()
focusManager.clear_state.assert_called_once_with("confirmed empty i3 workspace")
self.manager.app.scriptManager.set_active_script.assert_called_once_with(
defaultScript,
"focus: confirmed-empty-i3-workspace",
)
self.assertTrue(self.manager._desktopContextConfirmedEmpty)
def test_atspi_event_is_ignored_while_i3_workspace_is_confirmed_empty(self) -> None:
self.manager._desktopContextConfirmedEmpty = True
event = FakeEvent("object:state-changed:focused", source=object(), detail1=1)
self.manager._get_scriptForEvent = mock.Mock()
with (
mock.patch.object(event_manager.debug, "printObjectEvent"),
mock.patch.object(event_manager.debug, "printMessage"),
):
self.manager._processObjectEvent(event)
self.manager._get_scriptForEvent.assert_not_called()
def test_nonempty_i3_workspace_schedules_focus_resync_after_empty_workspace(self) -> None:
self.manager._desktopContextConfirmedEmpty = True
snapshot = compositor_state_types.DesktopContextSnapshot(
session_type="x11",
backend_name="i3-ipc",
active_workspace_ids=frozenset({"8"}),
focused_window_id=0x200000C,
focused_workspace_empty=False,
)
with mock.patch.object(event_manager.GLib, "idle_add") as idleAdd:
self.manager._handle_compositor_signal(
compositor_state_types.CompositorStateEvent(
compositor_state_types.WORKSPACE_STATE_CHANGED,
reason="window-focus",
snapshot=snapshot,
)
)
self.assertFalse(self.manager._desktopContextConfirmedEmpty)
idleAdd.assert_called_once_with(self.manager._sync_focus_on_startup)
def test_stale_context_event_is_obsolete_while_churn_is_paused(self) -> None:
self.manager._churnSuppressed = True
self.manager._prioritizedContextToken = "current"
@@ -117,6 +186,64 @@ class EventManagerCompositorContextRegressionTests(unittest.TestCase):
self.assertEqual(list(self.manager._eventQueue.queue), [currentEvent])
def test_flush_classifies_outside_queue_lock_and_preserves_nested_event(self) -> None:
staleEvent = FakeEvent("object:children-changed:add", source="stale")
currentEvent = FakeEvent("object:children-changed:add", source="current")
nestedEvent = FakeEvent("object:state-changed:focused", source="nested", detail1=1)
self.manager._eventQueue.put(staleEvent)
self.manager._eventQueue.put(currentEvent)
self.manager._ignore = mock.Mock(return_value=False)
self.manager._prioritizeSelfHostedFocusedEvent = mock.Mock(return_value=False)
self.manager._queuePrintln = mock.Mock()
self.manager._inFlood = mock.Mock(return_value=False)
self.manager._shouldSuspendEventsFor = mock.Mock(return_value=False)
self.manager._dequeue = mock.Mock(return_value=False)
script = types.SimpleNamespace(eventCache={})
def classify_event(event) -> bool:
self.assertFalse(self.manager._gidleLock.locked())
if event is staleEvent:
self.manager._enqueue(nestedEvent)
return True
return False
self.manager._event_is_from_stale_context = mock.Mock(side_effect=classify_event)
self.manager._should_preserve_during_suppression = mock.Mock(return_value=False)
with (
mock.patch.object(event_manager.AXObject, "get_application", return_value=object()),
mock.patch.object(event_manager.GLib, "idle_add", return_value=1),
mock.patch.object(
event_manager.cthulhu.cthulhuApp.scriptManager,
"get_script",
return_value=script,
),
mock.patch.object(
event_manager.AXUtilities,
"get_application_toolkit_name",
return_value="VCL",
),
):
self.manager._flush_stale_atspi_events()
self.assertEqual(list(self.manager._eventQueue.queue), [currentEvent, nestedEvent])
self.manager._dequeue.assert_not_called()
def test_flush_restores_retained_and_unexamined_events_after_exception(self) -> None:
firstEvent = FakeEvent("object:children-changed:add", source="first")
secondEvent = FakeEvent("object:children-changed:add", source="second")
self.manager._eventQueue.put(firstEvent)
self.manager._eventQueue.put(secondEvent)
self.manager._event_is_from_stale_context = mock.Mock(
side_effect=RuntimeError("property lookup failed"),
)
with self.assertRaisesRegex(RuntimeError, "property lookup failed"):
self.manager._flush_stale_atspi_events()
self.assertEqual(list(self.manager._eventQueue.queue), [firstEvent, secondEvent])
self.assertFalse(self.manager._flushingStaleAtspiEvents)
def test_stale_background_event_does_not_activate_script_during_suppression(self) -> None:
script = mock.Mock()
script.isActivatableEvent.return_value = True
@@ -0,0 +1,129 @@
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"))
stubCthulhu = types.ModuleType("cthulhu.cthulhu")
stubCthulhu.cthulhuApp = mock.Mock()
sys.modules.setdefault("cthulhu.cthulhu", stubCthulhu)
from cthulhu import event_manager
class FakeEvent:
def __init__(self, event_type, source="window", detail1=0):
self.type = event_type
self.source = source
self.detail1 = detail1
self.detail2 = 0
self.any_data = None
class EventManagerXtermHandoffRegressionTests(unittest.TestCase):
def setUp(self) -> None:
listener = mock.Mock()
fakeAtspi = types.SimpleNamespace(
Accessible=event_manager.Atspi.Accessible,
EventListener=types.SimpleNamespace(new=mock.Mock(return_value=listener)),
Role=event_manager.Atspi.Role,
)
self.listenerPatch = mock.patch.object(event_manager, "Atspi", fakeAtspi)
self.listenerPatch.start()
self.addCleanup(self.listenerPatch.stop)
self.manager = event_manager.EventManager(mock.Mock(), asyncMode=False)
self.manager._active = True
self.manager._inputEventManager = mock.Mock()
self.manager._inputEventManager.should_suppress_atspi_events_for_xterm.return_value = True
self.manager._get_scriptForEvent = mock.Mock()
def test_window_activate_is_dropped_while_xterm_is_foreground(self) -> None:
event = FakeEvent("window:activate")
with (
mock.patch.object(event_manager.debug, "printObjectEvent"),
mock.patch.object(event_manager.debug, "printMessage"),
):
self.manager._processObjectEvent(event)
self.manager._get_scriptForEvent.assert_not_called()
self.manager._inputEventManager.should_suppress_atspi_events_for_xterm.assert_called_once_with(
refresh=True,
)
def test_active_frame_event_is_dropped_while_xterm_is_foreground(self) -> None:
event = FakeEvent("object:state-changed:active", detail1=1)
with (
mock.patch.object(event_manager.debug, "printObjectEvent"),
mock.patch.object(event_manager.debug, "printMessage"),
mock.patch.object(event_manager.AXUtilities, "is_frame", return_value=True),
):
self.manager._processObjectEvent(event)
self.manager._get_scriptForEvent.assert_not_called()
self.manager._inputEventManager.should_suppress_atspi_events_for_xterm.assert_called_once_with(
refresh=True,
)
def test_focused_event_is_dropped_while_xterm_is_foreground(self) -> None:
event = FakeEvent("object:state-changed:focused", detail1=1)
with (
mock.patch.object(event_manager.debug, "printObjectEvent"),
mock.patch.object(event_manager.debug, "printMessage"),
):
self.manager._processObjectEvent(event)
self.manager._get_scriptForEvent.assert_not_called()
self.manager._inputEventManager.should_suppress_atspi_events_for_xterm.assert_called_once_with(
refresh=True,
)
def test_background_event_is_dropped_during_xterm_handoff_without_refresh(self) -> None:
event = FakeEvent("object:text-changed:insert")
with (
mock.patch.object(event_manager.debug, "printObjectEvent"),
mock.patch.object(event_manager.debug, "printMessage"),
):
self.manager._processObjectEvent(event)
self.manager._get_scriptForEvent.assert_not_called()
self.manager._inputEventManager.should_suppress_atspi_events_for_xterm.assert_called_once_with(
refresh=False,
)
def test_notification_daemon_window_create_is_processed_during_xterm_handoff(self) -> None:
event = FakeEvent("window:create", source=object())
notificationApp = object()
self.manager._isActivatableEvent = mock.Mock(return_value=(False, "test"))
with (
mock.patch.object(event_manager.debug, "printObjectEvent"),
mock.patch.object(event_manager.debug, "printMessage"),
mock.patch.object(
event_manager.AXObject,
"get_application",
return_value=notificationApp,
),
mock.patch.object(
event_manager.AXObject,
"get_name",
return_value="xfce4-notifyd",
),
mock.patch.object(event_manager.AXObject, "is_dead", return_value=False),
mock.patch.object(event_manager.AXUtilities, "is_defunct", return_value=False),
mock.patch.object(event_manager.AXUtilities, "is_iconified", return_value=False),
):
self.manager._processObjectEvent(event)
self.manager._get_scriptForEvent.assert_called_once_with(event)
self.manager._inputEventManager.should_suppress_atspi_events_for_xterm.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -130,6 +130,7 @@ class InputEventManagerKeyWatcherTests(unittest.TestCase):
),
mock.patch.object(input_event_manager.pyatspi.Registry, "registerKeystrokeListener") as register,
mock.patch.object(input_event_manager.pyatspi.Registry, "deregisterKeystrokeListener") as deregister,
mock.patch.object(manager, "_initialize_xterm_handoff") as initializeXtermHandoff,
):
manager.start_key_watcher()
manager.stop_key_watcher()
@@ -161,6 +162,7 @@ class InputEventManagerKeyWatcherTests(unittest.TestCase):
mask=expectedMasks,
kind=(0, 1),
)
initializeXtermHandoff.assert_called_once_with()
self.assertEqual(
device.connect_calls,
[
@@ -6,9 +6,20 @@ from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cthulhu import input_event_manager
from cthulhu import compositor_state_types
class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
def setUp(self):
self.compositorSnapshot = input_event_manager.cthulhu_state.compositorSnapshot
input_event_manager.cthulhu_state.compositorSnapshot = None
self.addCleanup(
setattr,
input_event_manager.cthulhu_state,
"compositorSnapshot",
self.compositorSnapshot,
)
def test_active_x11_window_differs_from_cached_atspi_window_by_pid(self):
manager = input_event_manager.InputEventManager()
cachedWindow = object()
@@ -530,19 +541,409 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
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(manager, "_active_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)
manager._on_active_x11_window_changed(mock.Mock(), None)
matcher.assert_called_once_with(activeWindow)
matcher.assert_called_once_with()
activeScript.addKeyGrabs.assert_called_once_with()
self.assertIsNone(manager._scriptWithSuspendedGrabsForXterm)
def test_active_x11_window_uses_ewmh_id_instead_of_stale_wnck_active_window(self):
manager = input_event_manager.InputEventManager()
wnck = mock.Mock()
screen = mock.Mock()
ewmhWindow = object()
wnck.Screen.get_default.return_value = screen
wnck.Window.get.return_value = ewmhWindow
with (
mock.patch.object(manager, "_get_wnck", return_value=wnck),
mock.patch.object(manager, "_ensure_xterm_focus_monitor"),
mock.patch.object(manager, "_get_ewmh_active_x11_window_id", return_value=0x1400010),
):
result = manager._get_active_x11_window()
self.assertIs(result, ewmhWindow)
wnck.Window.get.assert_called_once_with(0x1400010)
screen.get_active_window.assert_not_called()
def test_active_x11_window_does_not_fall_back_to_stale_wnck_window_for_known_ewmh_id(self):
manager = input_event_manager.InputEventManager()
wnck = mock.Mock()
screen = mock.Mock()
wnck.Screen.get_default.return_value = screen
wnck.Window.get.return_value = None
with (
mock.patch.object(manager, "_get_wnck", return_value=wnck),
mock.patch.object(manager, "_ensure_xterm_focus_monitor"),
mock.patch.object(manager, "_get_ewmh_active_x11_window_id", return_value=0x1400010),
mock.patch.object(input_event_manager.debug, "print_tokens"),
):
result = manager._get_active_x11_window()
self.assertIsNone(result)
screen.get_active_window.assert_not_called()
def test_active_x11_window_falls_back_to_wnck_when_ewmh_is_unavailable(self):
manager = input_event_manager.InputEventManager()
wnck = mock.Mock()
screen = mock.Mock()
fallbackWindow = object()
wnck.Screen.get_default.return_value = screen
screen.get_active_window.return_value = fallbackWindow
with (
mock.patch.object(manager, "_get_wnck", return_value=wnck),
mock.patch.object(manager, "_ensure_xterm_focus_monitor"),
mock.patch.object(manager, "_get_ewmh_active_x11_window_id", return_value=None),
):
result = manager._get_active_x11_window()
self.assertIs(result, fallbackWindow)
def test_xterm_match_reuses_cached_metadata_while_ewmh_window_id_is_unchanged(self):
manager = input_event_manager.InputEventManager()
with (
mock.patch.object(
manager,
"_get_ewmh_active_x11_window_id",
return_value=0x1400010,
),
mock.patch.object(
manager,
"_x11_window_id_xterm_match",
return_value=False,
) as matcher,
):
firstResult = manager._active_x11_window_xterm_match()
secondResult = manager._active_x11_window_xterm_match()
self.assertFalse(firstResult)
self.assertFalse(secondResult)
matcher.assert_called_once_with(0x1400010)
def test_xterm_match_prefers_i3_focused_window_id_over_stale_ewmh_id(self):
manager = input_event_manager.InputEventManager()
snapshot = compositor_state_types.DesktopContextSnapshot(
session_type="x11",
backend_name="i3-ipc",
focused_window_id=0x2600003,
focused_workspace_empty=False,
)
with (
mock.patch.object(input_event_manager.cthulhu_state, "compositorSnapshot", snapshot),
mock.patch.object(
manager,
"_get_ewmh_active_x11_window_id",
return_value=0x200000C,
) as getEwmhWindowId,
mock.patch.object(
manager,
"_x11_window_id_xterm_match",
return_value=False,
) as matcher,
):
result = manager._active_x11_window_xterm_match()
self.assertFalse(result)
matcher.assert_called_once_with(0x2600003)
getEwmhWindowId.assert_not_called()
def test_i3_exact_xid_initializes_x11_before_matching_xterm(self):
manager = input_event_manager.InputEventManager()
snapshot = compositor_state_types.DesktopContextSnapshot(
session_type="x11",
backend_name="i3-ipc",
focused_window_id=0x200000C,
focused_workspace_empty=False,
)
window = mock.Mock()
window.get_wm_class.return_value = ("xterm", "XTerm")
window.get_wm_name.return_value = "devel"
window.get_full_property.return_value = None
display = mock.Mock()
display.create_resource_object.return_value = window
def initializeX11():
manager._x11Display = display
manager._x11AnyPropertyType = object()
return True
with (
mock.patch.object(input_event_manager.cthulhu_state, "compositorSnapshot", snapshot),
mock.patch.object(
manager,
"_ensure_x11_display",
side_effect=initializeX11,
) as ensureX11,
mock.patch.object(manager, "_get_ewmh_active_x11_window_id") as getEwmhWindowId,
):
result = manager._active_x11_window_xterm_match()
self.assertTrue(result)
ensureX11.assert_called_once_with()
display.create_resource_object.assert_called_once_with("window", 0x200000C)
getEwmhWindowId.assert_not_called()
def test_confirmed_empty_i3_workspace_is_definitely_not_xterm(self):
manager = input_event_manager.InputEventManager()
snapshot = compositor_state_types.DesktopContextSnapshot(
session_type="x11",
backend_name="i3-ipc",
focused_workspace_empty=True,
)
with (
mock.patch.object(input_event_manager.cthulhu_state, "compositorSnapshot", snapshot),
mock.patch.object(manager, "_get_ewmh_active_x11_window_id") as getEwmhWindowId,
mock.patch.object(manager, "_x11_window_id_xterm_match") as matcher,
):
result = manager._active_x11_window_xterm_match()
self.assertFalse(result)
getEwmhWindowId.assert_not_called()
matcher.assert_not_called()
def test_unknown_i3_context_falls_back_to_generic_ewmh_window_id(self):
manager = input_event_manager.InputEventManager()
snapshot = compositor_state_types.DesktopContextSnapshot(
session_type="x11",
backend_name="i3-ipc",
focused_window_id=None,
focused_workspace_empty=None,
)
with (
mock.patch.object(input_event_manager.cthulhu_state, "compositorSnapshot", snapshot),
mock.patch.object(
manager,
"_get_ewmh_active_x11_window_id",
return_value=0x2600003,
),
mock.patch.object(
manager,
"_x11_window_id_xterm_match",
return_value=False,
) as matcher,
):
result = manager._active_x11_window_xterm_match()
self.assertFalse(result)
matcher.assert_called_once_with(0x2600003)
def test_ewmh_brave_window_does_not_use_stale_wnck_xterm_metadata(self):
manager = input_event_manager.InputEventManager()
with (
mock.patch.object(
manager,
"_get_ewmh_active_x11_window_id",
return_value=0x2600003,
),
mock.patch.object(
manager,
"_x11_window_id_xterm_match",
return_value=False,
) as directMatcher,
mock.patch.object(manager, "_get_active_x11_window") as getWnckWindow,
mock.patch.object(manager, "_x11_window_xterm_match") as wnckMatcher,
):
result = manager._active_x11_window_xterm_match()
self.assertFalse(result)
directMatcher.assert_called_once_with(0x2600003)
getWnckWindow.assert_not_called()
wnckMatcher.assert_not_called()
def test_direct_x11_metadata_identifies_brave_as_non_xterm(self):
manager = input_event_manager.InputEventManager()
with mock.patch.object(
manager,
"_get_x11_window_metadata",
return_value=(["brave-origin", "Brave-origin", "New Tab - Brave Origin"], 1234),
):
result = manager._x11_window_id_xterm_match(0x2600003)
self.assertFalse(result)
def test_direct_x11_metadata_identifies_xterm(self):
manager = input_event_manager.InputEventManager()
with mock.patch.object(
manager,
"_get_x11_window_metadata",
return_value=(["xterm", "XTerm", "devel"], 1234),
):
result = manager._x11_window_id_xterm_match(0x200000C)
self.assertTrue(result)
def test_xterm_focus_change_suspends_grabs_without_keyboard_event(self):
manager = input_event_manager.InputEventManager()
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(manager, "_active_x11_window_xterm_match", return_value=True) as matcher,
mock.patch.object(input_event_manager.debug, "print_tokens"),
):
manager._on_active_x11_window_changed(mock.Mock(), None)
matcher.assert_called_once_with()
activeScript.removeKeyGrabs.assert_called_once_with()
self.assertIs(manager._scriptWithSuspendedGrabsForXterm, activeScript)
def test_initial_xterm_handoff_suspends_grabs(self):
manager = input_event_manager.InputEventManager()
manager._device = object()
with (
mock.patch.object(manager, "_active_x11_window_xterm_match", return_value=True),
mock.patch.object(manager, "_suspend_key_grabs_for_xterm") as suspend,
mock.patch.object(manager, "_ensure_xterm_recovery_timer") as ensureTimer,
mock.patch.object(input_event_manager.debug, "print_tokens"),
):
manager._initialize_xterm_handoff()
suspend.assert_called_once_with()
ensureTimer.assert_called_once_with()
def test_xterm_focus_poll_suspends_grabs_after_entering_xterm(self):
manager = input_event_manager.InputEventManager()
manager._device = object()
with (
mock.patch.object(manager, "_active_x11_window_xterm_match", return_value=True),
mock.patch.object(manager, "_suspend_key_grabs_for_xterm") as suspend,
mock.patch.object(manager, "_restore_key_grabs_after_xterm") as restore,
mock.patch.object(input_event_manager.debug, "print_tokens"),
):
result = manager._poll_xterm_grab_recovery()
self.assertTrue(result)
suspend.assert_called_once_with()
restore.assert_not_called()
def test_xterm_focus_poll_restores_grabs_after_leaving_xterm(self):
manager = input_event_manager.InputEventManager()
manager._device = object()
with (
mock.patch.object(manager, "_active_x11_window_xterm_match", return_value=False),
mock.patch.object(manager, "_suspend_key_grabs_for_xterm") as suspend,
mock.patch.object(manager, "_restore_key_grabs_after_xterm") as restore,
mock.patch.object(input_event_manager.debug, "print_tokens"),
):
result = manager._poll_xterm_grab_recovery()
self.assertTrue(result)
self.assertEqual(manager._xtermRecoveryPollCount, 1)
restore.assert_called_once_with()
suspend.assert_not_called()
def test_xterm_match_records_last_raw_ewmh_window_id(self):
manager = input_event_manager.InputEventManager()
with (
mock.patch.object(
manager,
"_get_ewmh_active_x11_window_id",
return_value=0x1400010,
),
mock.patch.object(
manager,
"_x11_window_id_xterm_match",
return_value=False,
),
):
result = manager._active_x11_window_xterm_match()
self.assertFalse(result)
self.assertEqual(manager._lastEwmhReadWindowId, 0x1400010)
def test_xterm_focus_poll_survives_transient_matcher_failure(self):
manager = input_event_manager.InputEventManager()
manager._device = object()
manager._xtermRecoverySourceId = 23
with (
mock.patch.object(
manager,
"_active_x11_window_xterm_match",
side_effect=RuntimeError("transient matcher failure"),
),
mock.patch.object(input_event_manager.debug, "print_message") as printMessage,
):
result = manager._poll_xterm_grab_recovery()
self.assertTrue(result)
self.assertEqual(manager._xtermRecoverySourceId, 23)
printMessage.assert_called_once()
def test_ewmh_read_failure_reconnects_on_next_check(self):
manager = input_event_manager.InputEventManager()
x11Display = mock.Mock()
x11Root = mock.Mock()
x11Root.get_full_property.side_effect = RuntimeError("display connection failed")
manager._x11Display = x11Display
manager._x11Root = x11Root
manager._netActiveWindowAtom = object()
manager._x11AnyPropertyType = object()
manager._didAttemptX11Display = True
with mock.patch.object(input_event_manager.debug, "print_message"):
result = manager._get_ewmh_active_x11_window_id()
self.assertIsNone(result)
x11Display.close.assert_called_once_with()
self.assertFalse(manager._didAttemptX11Display)
def test_xterm_suppression_does_not_check_x11_without_active_handoff(self):
manager = input_event_manager.InputEventManager()
with mock.patch.object(manager, "_active_x11_window_xterm_match") as matcher:
result = manager.should_suppress_atspi_events_for_xterm(refresh=True)
self.assertFalse(result)
matcher.assert_not_called()
def test_xterm_suppression_ignores_background_event_without_x11_refresh(self):
manager = input_event_manager.InputEventManager()
manager._scriptWithSuspendedGrabsForXterm = object()
with mock.patch.object(manager, "_active_x11_window_xterm_match") as matcher:
result = manager.should_suppress_atspi_events_for_xterm(refresh=False)
self.assertTrue(result)
matcher.assert_not_called()
def test_xterm_suppression_releases_handoff_for_real_focus_return(self):
manager = input_event_manager.InputEventManager()
activeScript = mock.Mock()
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(manager, "_active_x11_window_xterm_match", return_value=False),
mock.patch.object(input_event_manager.debug, "print_tokens"),
):
result = manager.should_suppress_atspi_events_for_xterm(refresh=True)
self.assertFalse(result)
activeScript.addKeyGrabs.assert_called_once_with()
self.assertIsNone(manager._scriptWithSuspendedGrabsForXterm)
@@ -550,14 +951,13 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
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(manager, "_active_x11_window_xterm_match", return_value=None),
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._on_active_x11_window_changed(screen, None)
manager._on_active_x11_window_changed(mock.Mock(), None)
self.assertEqual(manager._xtermRecoverySourceId, 23)
@@ -619,6 +1019,9 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
manager._xtermFocusHandlerId = 17
manager._xtermRecoverySourceId = 23
manager._scriptWithSuspendedGrabsForXterm = object()
x11Display = mock.Mock()
manager._x11Display = x11Display
manager._didAttemptX11Display = True
with (
mock.patch.object(input_event_manager.GLib, "source_remove") as sourceRemove,
@@ -632,6 +1035,8 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
self.assertEqual(manager._xtermFocusHandlerId, 0)
self.assertEqual(manager._xtermRecoverySourceId, 0)
self.assertIsNone(manager._scriptWithSuspendedGrabsForXterm)
x11Display.close.assert_called_once_with()
self.assertFalse(manager._didAttemptX11Display)
def test_identifier_is_xterm_matches_exact_xterm_only(self):
self.assertTrue(input_event_manager.InputEventManager._identifier_is_xterm("xterm"))
+27 -1
View File
@@ -80,10 +80,25 @@ class RuntimeStateSnapshotTests(unittest.TestCase):
manager._grabbed_bindings = {10: object(), 11: object()}
manager._paused = True
manager._scriptWithSuspendedGrabsForXterm = object()
manager._xtermFocusHandlerId = 17
manager._xtermRecoverySourceId = 23
manager._xtermRecoveryPollCount = 42
manager._xtermHandoffHistory = [
"ewmh:0x200000c->0x1400010",
"restore:success",
]
manager._lastEwmhReadWindowId = 0x1400010
manager._lastEwmhActiveWindowId = 0x1400010
manager._lastEwmhXtermMatch = False
manager._last_input_event = object()
manager._last_non_modifier_key_event = None
snapshot = manager.get_debug_snapshot()
with mock.patch.object(
manager,
"_xterm_recovery_source_is_present",
return_value=True,
):
snapshot = manager.get_debug_snapshot()
self.assertTrue(snapshot["device_active"])
self.assertTrue(snapshot["pointer_watcher_active"])
@@ -91,6 +106,17 @@ class RuntimeStateSnapshotTests(unittest.TestCase):
self.assertEqual(1, snapshot["mapped_keysyms"])
self.assertEqual(2, snapshot["grabbed_bindings"])
self.assertTrue(snapshot["suspended_xterm_script_present"])
self.assertTrue(snapshot["xterm_focus_monitor_active"])
self.assertTrue(snapshot["xterm_recovery_timer_active"])
self.assertTrue(snapshot["xterm_recovery_source_present"])
self.assertEqual(42, snapshot["xterm_recovery_poll_count"])
self.assertEqual(
"ewmh:0x200000c->0x1400010 | restore:success",
snapshot["xterm_handoff_history"],
)
self.assertEqual("0x1400010", snapshot["last_ewmh_read_window_id"])
self.assertEqual("0x1400010", snapshot["last_ewmh_active_window_id"])
self.assertFalse(snapshot["last_ewmh_xterm_match"])
self.assertFalse(snapshot["last_non_modifier_key_event_present"])
def test_script_manager_snapshot_reports_cache_counts(self) -> None:
+132
View File
@@ -177,5 +177,137 @@ class CheckedStateRoutingContractTests(unittest.TestCase):
)
class ExpandedStateRoutingContractTests(unittest.TestCase):
def test_shared_web_owns_expanded_state_without_default_fallback(self):
for toolkit_script in (chromium_script, gecko_script):
with self.subTest(toolkit=toolkit_script.__name__):
test_script = toolkit_script.Script.__new__(toolkit_script.Script)
test_script._eventRouter = WebEventRouter()
event = object()
with (
mock.patch.object(
web.Script,
"onExpandedChanged",
return_value=True,
) as web_handler,
mock.patch.object(
default.Script,
"onExpandedChanged",
) as default_handler,
mock.patch(
"cthulhu.scripts.web.event_router.debug.printMessage"
) as print_message,
):
toolkit_script.Script.onExpandedChanged(test_script, event)
web_handler.assert_called_once_with(test_script, event)
default_handler.assert_not_called()
messages = [call.args[1] for call in print_message.call_args_list]
self.assertIn(
"WEB EVENT ROUTER: SHARED_WEB -> HANDLED",
messages,
)
def test_unhandled_expanded_state_reaches_default_once(self):
for toolkit_script in (chromium_script, gecko_script):
with self.subTest(toolkit=toolkit_script.__name__):
test_script = toolkit_script.Script.__new__(toolkit_script.Script)
test_script._eventRouter = WebEventRouter()
event = object()
with (
mock.patch.object(
web.Script,
"onExpandedChanged",
return_value=False,
) as web_handler,
mock.patch.object(
default.Script,
"onExpandedChanged",
) as default_handler,
mock.patch(
"cthulhu.scripts.web.event_router.debug.printMessage"
) as print_message,
):
toolkit_script.Script.onExpandedChanged(test_script, event)
web_handler.assert_called_once_with(test_script, event)
default_handler.assert_called_once_with(test_script, event)
messages = [call.args[1] for call in print_message.call_args_list]
self.assertIn(
"WEB EVENT ROUTER: DEFAULT -> HANDLED",
messages,
)
class TableReorderRoutingContractTests(unittest.TestCase):
def test_shared_web_owns_table_reorder_without_default_fallback(self):
for toolkit_script in (chromium_script, gecko_script):
for handler_name in ("onColumnReordered", "onRowReordered"):
with self.subTest(
toolkit=toolkit_script.__name__,
handler=handler_name,
):
test_script = toolkit_script.Script.__new__(toolkit_script.Script)
test_script._eventRouter = WebEventRouter()
event = object()
with (
mock.patch.object(
web.Script,
handler_name,
return_value=True,
) as web_handler,
mock.patch.object(
default.Script,
handler_name,
) as default_handler,
mock.patch(
"cthulhu.scripts.web.event_router.debug.printMessage"
) as print_message,
):
getattr(toolkit_script.Script, handler_name)(test_script, event)
web_handler.assert_called_once_with(test_script, event)
default_handler.assert_not_called()
messages = [call.args[1] for call in print_message.call_args_list]
self.assertIn(
"WEB EVENT ROUTER: SHARED_WEB -> HANDLED",
messages,
)
def test_unhandled_table_reorder_reaches_default_once(self):
for toolkit_script in (chromium_script, gecko_script):
for handler_name in ("onColumnReordered", "onRowReordered"):
with self.subTest(
toolkit=toolkit_script.__name__,
handler=handler_name,
):
test_script = toolkit_script.Script.__new__(toolkit_script.Script)
test_script._eventRouter = WebEventRouter()
event = object()
with (
mock.patch.object(
web.Script,
handler_name,
return_value=False,
) as web_handler,
mock.patch.object(
default.Script,
handler_name,
) as default_handler,
mock.patch(
"cthulhu.scripts.web.event_router.debug.printMessage"
) as print_message,
):
getattr(toolkit_script.Script, handler_name)(test_script, event)
web_handler.assert_called_once_with(test_script, event)
default_handler.assert_called_once_with(test_script, event)
messages = [call.args[1] for call in print_message.call_args_list]
self.assertIn(
"WEB EVENT ROUTER: DEFAULT -> HANDLED",
messages,
)
if __name__ == "__main__":
unittest.main()
+57 -3
View File
@@ -622,6 +622,60 @@ class WebCaretMovedRegressionTests(unittest.TestCase):
setLocusOfFocus.assert_not_called()
class WebMultilineEditorLinePresentationRegressionTests(unittest.TestCase):
def test_native_multiline_editor_uses_visual_web_line(self):
testScript = web_script.Script.__new__(web_script.Script)
testScript._lastCommandWasCaretNav = False
testScript._lastCommandWasStructNav = False
testScript.pointOfReference = {}
testScript.utilities = mock.Mock()
testScript.utilities.isContentEditableWithEmbeddedObjects.return_value = False
testScript.utilities.inDocumentContent.return_value = True
testScript.utilities.getTopLevelDocumentForObject.return_value = "document"
testScript.utilities.getPriorContext.return_value = ("prior", 20)
testScript.utilities.getCaretContext.return_value = ("editor", 42)
visualLine = [["editor", 40, 55, "wrapped line"]]
testScript.utilities.getLineContentsAtOffset.return_value = visualLine
testScript.speakContents = mock.Mock()
with (
mock.patch.object(web_script.AXUtilities, "is_editable", return_value=True),
mock.patch.object(web_script.AXUtilities, "is_multi_line", return_value=True),
mock.patch.object(web_script.default.Script, "sayLine") as defaultSayLine,
):
web_script.Script.sayLine(testScript, "editor")
defaultSayLine.assert_not_called()
testScript.utilities.getLineContentsAtOffset.assert_called_once_with(
"editor",
42,
useCache=True,
)
testScript.speakContents.assert_called_once_with(
visualLine,
priorObj="prior",
alreadyFocused=True,
)
def test_native_multiline_editor_is_visual_line_boundary(self):
editor = object()
utilities = web_script_utilities.Utilities.__new__(
web_script_utilities.Utilities
)
utilities.isContentEditableWithEmbeddedObjects = mock.Mock(return_value=False)
utilities.inDocumentContent = mock.Mock(return_value=True)
with (
mock.patch.object(web_script_utilities.AXUtilities, "is_editable", return_value=True),
mock.patch.object(web_script_utilities.AXUtilities, "is_multi_line", return_value=True),
mock.patch.object(web_script_utilities.AXObject, "find_ancestor") as findAncestor,
):
boundary = utilities._getEditableLineBoundary(editor)
self.assertIs(boundary, editor)
findAncestor.assert_not_called()
class TextEventReasonRegressionTests(unittest.TestCase):
@staticmethod
def _make_input_manager():
@@ -1804,7 +1858,7 @@ class WebDynamicContentRecoveryRegressionTests(unittest.TestCase):
setLocusOfFocus.assert_called_once_with(event, source, False)
testScript.utilities.setCaretContext.assert_called_once_with(source, 0)
def test_focused_link_replaces_stale_section_context_after_caret_nav(self):
def test_delayed_focused_link_does_not_rewind_section_context_after_caret_nav(self):
testScript = self._make_dynamic_script()
document = object()
section = object()
@@ -1829,8 +1883,8 @@ class WebDynamicContentRecoveryRegressionTests(unittest.TestCase):
result = web_script.Script.onFocusedChanged(testScript, event)
self.assertTrue(result)
setLocusOfFocus.assert_called_once_with(event, link, False)
testScript.utilities.setCaretContext.assert_called_once_with(link, 0)
setLocusOfFocus.assert_not_called()
testScript.utilities.setCaretContext.assert_not_called()
testScript.utilities.searchForCaretContext.assert_not_called()
def test_browse_mode_sticky_blocks_web_app_descendant_focus_claim(self):
testScript = self._make_dynamic_script()
@@ -85,6 +85,7 @@ class WebLayoutModeLineRegressionTests(unittest.TestCase):
utilities.findPreviousCaretInOrder = mock.Mock(return_value=(None, -1))
utilities.findNextCaretInOrder = mock.Mock(return_value=(None, -1))
utilities.isContentEditableWithEmbeddedObjects = mock.Mock(return_value=False)
utilities.inDocumentContent = mock.Mock(return_value=False)
return utilities, obj, contents
def _get_line_for_setting(self, layoutMode):
+167 -1
View File
@@ -34,6 +34,7 @@ class WindowTitleFallbackRegressionTests(unittest.TestCase):
activeWindow = mock.Mock(id=200)
with (
mock.patch.object(plugin, "_ensure_x11_display", return_value=True),
mock.patch.object(plugin, "_get_active_window", return_value=activeWindow),
mock.patch.object(plugin, "_get_current_title", return_value="XTerm"),
mock.patch.object(plugin, "_schedule_fallback_title") as scheduleFallback,
@@ -47,7 +48,10 @@ class WindowTitleFallbackRegressionTests(unittest.TestCase):
plugin._pollSourceId = 1
plugin._lastActiveWindowId = 100
with mock.patch.object(plugin, "_get_active_window", return_value=None):
with (
mock.patch.object(plugin, "_ensure_x11_display", return_value=True),
mock.patch.object(plugin, "_get_active_window", return_value=None),
):
self.assertTrue(plugin._poll_window_title())
self.assertEqual(plugin._lastActiveWindowId, 100)
@@ -58,6 +62,7 @@ class WindowTitleFallbackRegressionTests(unittest.TestCase):
activeWindow = mock.Mock()
with (
mock.patch.object(plugin, "_ensure_x11_display", return_value=True),
mock.patch.object(plugin, "_get_active_window", return_value=activeWindow),
mock.patch.object(plugin, "_get_current_title", return_value="Terminal"),
):
@@ -69,11 +74,172 @@ class WindowTitleFallbackRegressionTests(unittest.TestCase):
activeWindow = mock.Mock()
with (
mock.patch.object(plugin, "_ensure_x11_display", return_value=True),
mock.patch.object(plugin, "_get_active_window", return_value=activeWindow),
mock.patch.object(plugin, "_get_current_title", return_value="Game Window"),
):
self.assertEqual(plugin.get_fallback_title("Wine Desktop"), "Game Window")
def test_fallback_prefers_i3_title_and_window_id(self):
plugin = WindowTitleReader()
plugin.app = mock.Mock()
snapshot = mock.Mock(
backend_name="i3-ipc",
focused_workspace_empty=False,
focused_window_id=0x200000C,
focused_window_title="Accurate i3 title",
)
plugin.app.getCompositorStateAdapter.return_value.get_snapshot.return_value = snapshot
with mock.patch.object(plugin, "_get_active_window") as getX11Window:
self.assertEqual(plugin.get_fallback_title("Incomplete title"), "Accurate i3 title")
getX11Window.assert_not_called()
def test_i3_wine_desktop_title_uses_existing_child_window_resolution(self):
plugin = WindowTitleReader()
plugin.app = mock.Mock()
snapshot = mock.Mock(
backend_name="i3-ipc",
focused_workspace_empty=False,
focused_window_id=0x2400007,
focused_window_title="Wine Desktop",
)
desktopWindow = mock.Mock()
plugin.app.getCompositorStateAdapter.return_value.get_snapshot.return_value = snapshot
plugin._display = mock.Mock()
plugin._display.create_resource_object.return_value = desktopWindow
with (
mock.patch.object(plugin, "_ensure_x11_display", return_value=True),
mock.patch.object(
plugin,
"_get_window_title",
return_value="Wine Desktop",
),
mock.patch.object(
plugin,
"_get_wine_desktop_title",
return_value="Actual Game Window",
) as getWineTitle,
):
self.assertEqual(
plugin.get_fallback_title("Wine Desktop"),
"Actual Game Window",
)
plugin._display.create_resource_object.assert_called_once_with("window", 0x2400007)
getWineTitle.assert_called_once_with(desktopWindow)
def test_wine_title_resolution_ignores_unrelated_x11_focus(self):
plugin = WindowTitleReader()
desktopWindow = mock.Mock(id=10)
unrelatedFocus = mock.Mock(id=20)
with (
mock.patch.object(plugin, "_get_focus_window", return_value=unrelatedFocus),
mock.patch.object(
plugin,
"_window_is_descendant_of",
return_value=False,
) as isDescendant,
mock.patch.object(plugin, "_get_window_title", return_value="Unrelated Terminal"),
mock.patch.object(
plugin,
"_find_child_title",
return_value="Actual Game Window",
) as findChildTitle,
):
self.assertEqual(
plugin._get_wine_desktop_title(desktopWindow),
"Actual Game Window",
)
isDescendant.assert_called_once_with(unrelatedFocus, desktopWindow)
findChildTitle.assert_called_once_with(desktopWindow)
def test_wine_child_title_filter_rejects_infrastructure_windows(self):
plugin = WindowTitleReader()
helperWindow = mock.Mock()
helperWindow.get_wm_class.return_value = (
"cthulhu-wine-access.exe",
"cthulhu-wine-access.exe",
)
gameWindow = mock.Mock()
gameWindow.get_wm_class.return_value = ("game.exe", "game.exe")
self.assertFalse(plugin._is_usable_wine_child_title(gameWindow, "Default IME"))
self.assertFalse(plugin._is_usable_wine_child_title(helperWindow, "NVDA"))
self.assertTrue(plugin._is_usable_wine_child_title(gameWindow, "Actual Game Window"))
def test_confirmed_empty_i3_workspace_does_not_fall_back_to_stale_x11_title(self):
plugin = WindowTitleReader()
plugin.app = mock.Mock()
snapshot = mock.Mock(
backend_name="i3-ipc",
focused_workspace_empty=True,
focused_window_id=None,
focused_window_title="",
)
plugin.app.getCompositorStateAdapter.return_value.get_snapshot.return_value = snapshot
with mock.patch.object(plugin, "_get_active_window") as getX11Window:
self.assertEqual(plugin.get_fallback_title("Browser"), "")
getX11Window.assert_not_called()
def test_unknown_i3_context_falls_back_to_generic_x11_title(self):
plugin = WindowTitleReader()
plugin.app = mock.Mock()
snapshot = mock.Mock(
backend_name="i3-ipc",
focused_workspace_empty=None,
focused_window_id=None,
focused_window_title="",
)
plugin.app.getCompositorStateAdapter.return_value.get_snapshot.return_value = snapshot
plugin._pollSourceId = 1
activeWindow = mock.Mock()
with (
mock.patch.object(plugin, "_ensure_x11_display", return_value=True) as ensureX11,
mock.patch.object(plugin, "_get_active_window", return_value=activeWindow),
mock.patch.object(plugin, "_get_current_title", return_value="Generic X title"),
):
self.assertEqual(plugin.get_fallback_title("Incomplete"), "Generic X title")
ensureX11.assert_called_once_with()
def test_known_i3_xid_with_missing_title_uses_exact_x11_title(self):
plugin = WindowTitleReader()
plugin.app = mock.Mock()
snapshot = mock.Mock(
backend_name="i3-ipc",
focused_workspace_empty=False,
focused_window_id=0x2600003,
focused_window_title="",
)
activeWindow = mock.Mock()
plugin.app.getCompositorStateAdapter.return_value.get_snapshot.return_value = snapshot
plugin._display = mock.Mock()
plugin._display.create_resource_object.return_value = activeWindow
with (
mock.patch.object(plugin, "_ensure_x11_display", return_value=True),
mock.patch.object(
plugin,
"_get_current_title",
return_value="Exact X11 title",
) as getCurrentTitle,
):
self.assertEqual(
plugin.get_fallback_title("Incomplete"),
"Exact X11 title",
)
plugin._display.create_resource_object.assert_called_once_with("window", 0x2600003)
getCurrentTitle.assert_called_once_with(activeWindow)
def test_present_title_uses_fallback_instead_of_atspi_title(self):
presenter = where_am_i_presenter.WhereAmIPresenter()
script = mock.Mock()
@@ -19,6 +19,28 @@ class WineAccessControllerContractTests(unittest.TestCase):
match.group(0),
)
def test_helper_uses_windows_subsystem_without_a_console_window(self):
cmakePath = Path(__file__).resolve().parents[1] / "wine-access" / "CMakeLists.txt"
source = cmakePath.read_text(encoding="utf-8")
self.assertIn(
"target_link_options(cthulhu-wine-access PRIVATE -mwindows)",
source,
)
def test_compatibility_window_stays_hidden_zero_sized_and_nonactivating(self):
sourcePath = Path(__file__).resolve().parents[1] / "wine-access" / "main.cpp"
source = sourcePath.read_text(encoding="utf-8")
self.assertIn("WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW", source)
self.assertIsNotNone(re.search(
r"CreateWindowExW\(WS_EX_NOACTIVATE \| WS_EX_TOOLWINDOW,.*?"
r"L\"NVDA\", 0, 0, 0, 0, 0,",
source,
re.DOTALL,
))
self.assertIn("ShowWindow(window, SW_HIDE)", source)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -23,6 +23,7 @@ target_compile_features(cthulhu-wine-access PRIVATE cxx_std_20)
target_include_directories(cthulhu-wine-access PRIVATE "${generated_dir}")
target_link_libraries(cthulhu-wine-access PRIVATE
PkgConfig::GIO oleacc ole32 oleaut32 rpcrt4 user32)
target_link_options(cthulhu-wine-access PRIVATE -mwindows)
target_compile_options(cthulhu-wine-access PRIVATE
$<$<COMPILE_LANGUAGE:C,CXX>:-Wall -Wextra>)