Add i3 focus tracking and harden X11 handoff
This commit is contained in:
1 parent
f4ee5e4468
commit
c39f3bda47
17 files changed
+1379
-76
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user