Add i3 focus tracking and harden X11 handoff
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -599,7 +610,6 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
||||
|
||||
def test_xterm_match_reuses_cached_metadata_while_ewmh_window_id_is_unchanged(self):
|
||||
manager = input_event_manager.InputEventManager()
|
||||
activeWindow = object()
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
@@ -609,12 +619,7 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
||||
),
|
||||
mock.patch.object(
|
||||
manager,
|
||||
"_get_active_x11_window",
|
||||
return_value=activeWindow,
|
||||
) as getActiveWindow,
|
||||
mock.patch.object(
|
||||
manager,
|
||||
"_x11_window_xterm_match",
|
||||
"_x11_window_id_xterm_match",
|
||||
return_value=False,
|
||||
) as matcher,
|
||||
):
|
||||
@@ -623,8 +628,165 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
||||
|
||||
self.assertFalse(firstResult)
|
||||
self.assertFalse(secondResult)
|
||||
getActiveWindow.assert_called_once_with(ewmhWindowId=0x1400010)
|
||||
matcher.assert_called_once_with(activeWindow)
|
||||
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()
|
||||
@@ -687,9 +849,30 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
||||
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()
|
||||
|
||||
@@ -82,12 +82,23 @@ class RuntimeStateSnapshotTests(unittest.TestCase):
|
||||
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"])
|
||||
@@ -97,6 +108,13 @@ class RuntimeStateSnapshotTests(unittest.TestCase):
|
||||
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"])
|
||||
|
||||
@@ -1858,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()
|
||||
@@ -1883,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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user