Implement AT-SPI selection bridging groundwork

Add the document selection adapter, integrate it through script utilities and major callers, and package the clipboard fallback work that was needed during manual testing on Wayland.

Also include a handoff note for the still-open browser link-selection issue so other developers can continue from the current branch state without reconstructing the debug trail.
This commit is contained in:
2026-04-09 05:22:00 -04:00
parent 4a41b51d37
commit 04c79f2e0f
15 changed files with 2310 additions and 93 deletions

View File

@@ -20,7 +20,9 @@ if soundGeneratorModule is not None and not hasattr(soundGeneratorModule, "Sound
soundGeneratorModule.SoundGenerator = _StubSoundGenerator
from cthulhu import messages
from cthulhu.scripts.web import script as web_script
from cthulhu.scripts.web import script_utilities as web_script_utilities
class WebKeyGrabRegressionTests(unittest.TestCase):
@@ -100,3 +102,130 @@ class WebKeyGrabRegressionTests(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class WebSelectionRegressionTests(unittest.TestCase):
def _make_script(self):
testScript = web_script.Script.__new__(web_script.Script)
testScript.pointOfReference = {}
testScript.utilities = mock.Mock()
testScript.utilities.sanityCheckActiveWindow = mock.Mock()
testScript.utilities.isZombie.return_value = False
testScript.utilities.eventIsBrowserUINoise.return_value = False
testScript.utilities.eventIsBrowserUIAutocompleteNoise.return_value = False
testScript.utilities.lastInputEventWasTab.return_value = False
testScript.utilities.inFindContainer.return_value = False
testScript.utilities.eventIsFromLocusOfFocusDocument.return_value = True
testScript.utilities.textEventIsDueToInsertion.return_value = False
testScript.utilities.textEventIsDueToDeletion.return_value = False
testScript.utilities.isItemForEditableComboBox.return_value = False
testScript.utilities.eventIsAutocompleteNoise.return_value = False
testScript.utilities.eventIsSpinnerNoise.return_value = False
testScript.utilities.caretMovedOutsideActiveGrid.return_value = False
testScript.utilities.treatEventAsSpinnerValueChange.return_value = False
testScript.utilities.queryNonEmptyText.return_value = "Darkest Dungeon"
testScript.utilities.lastInputEventWasCaretNavWithSelection.return_value = True
testScript.utilities.getCharacterContentsAtOffset.return_value = [[None, 0, 1, "D"]]
testScript.utilities.pathComparison.return_value = -1
testScript.updateBraille = mock.Mock()
testScript.speakContents = mock.Mock()
testScript.speakMessage = mock.Mock()
testScript._lastCommandWasCaretNav = True
testScript._lastCommandWasStructNav = False
testScript._lastCommandWasMouseButton = False
testScript._lastMouseButtonContext = (None, -1)
testScript._inFocusMode = False
testScript._focusModeIsSticky = False
testScript._browseModeIsSticky = False
return testScript
def test_broken_web_caret_selection_synthesizes_selection_from_caret_context(self):
testScript = self._make_script()
document = object()
section = object()
event = mock.Mock(source=section, detail1=-1)
manager = mock.Mock()
manager.last_event_was_forward_caret_selection.return_value = True
manager.last_event_was_backward_caret_selection.return_value = False
testScript.utilities.getTopLevelDocumentForObject.return_value = document
testScript.utilities.getCaretContext.return_value = (section, 0)
testScript.utilities.nextContext.return_value = (section, 1)
testScript.utilities.getCharacterContentsAtOffset.return_value = [[section, 0, 1, "D"]]
with (
mock.patch.object(web_script.cthulhu_state, "locusOfFocus", section),
mock.patch.object(web_script.input_event_manager, "get_manager", return_value=manager),
mock.patch.object(web_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus,
):
result = web_script.Script.onCaretMoved(testScript, event)
self.assertTrue(result)
testScript.utilities.setCaretContext.assert_called_once_with(section, 1, document)
setLocusOfFocus.assert_called_once_with(event, section, False, True)
testScript.speakContents.assert_called_once_with([[section, 0, 1, "D"]])
testScript.speakMessage.assert_called_once_with(messages.TEXT_SELECTED, interrupt=False)
self.assertEqual(
testScript.pointOfReference["syntheticWebSelection"]["string"],
"D",
)
def test_broken_web_text_selection_event_synthesizes_selection_from_event_source(self):
testScript = self._make_script()
document = object()
anchor = object()
link = object()
event = mock.Mock(source=link)
testScript.utilities.getTopLevelDocumentForObject.return_value = document
testScript.utilities.getCaretContext.return_value = (anchor, 0)
testScript.utilities.nextContext.side_effect = [(anchor, 0)]
testScript.utilities.getCharacterContentsAtOffset.side_effect = (
lambda obj, _offset: [[link, 0, 1, "S"]] if obj is link else []
)
testScript.utilities.textEventIsForNonNavigableTextObject.return_value = False
testScript.utilities.isContentEditableWithEmbeddedObjects.return_value = False
testScript._compareCaretContexts = mock.Mock(return_value=1)
with (
mock.patch.object(web_script.cthulhu_state, "locusOfFocus", anchor),
mock.patch.object(web_script.AXText, "get_caret_offset", return_value=0),
mock.patch.object(web_script.AXText, "get_selected_ranges", return_value=[]),
mock.patch.object(web_script.AXText, "get_substring", return_value="S"),
mock.patch.object(web_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus,
):
result = web_script.Script.onTextSelectionChanged(testScript, event)
self.assertTrue(result)
testScript.utilities.setCaretContext.assert_called_once_with(link, 0, document)
setLocusOfFocus.assert_called_once_with(event, link, False, True)
testScript.speakContents.assert_called_once_with([[link, 0, 1, "S"]])
testScript.speakMessage.assert_called_once_with(messages.TEXT_SELECTED, interrupt=False)
self.assertEqual(
testScript.pointOfReference["syntheticWebSelection"]["string"],
"S",
)
def test_web_utilities_all_selected_text_falls_back_to_synthetic_selection(self):
testScript = mock.Mock(pointOfReference={})
utilities = web_script_utilities.Utilities.__new__(web_script_utilities.Utilities)
utilities._script = testScript
document = object()
obj = object()
testScript.pointOfReference["syntheticWebSelection"] = {
"document": document,
"string": "Dark",
}
with (
mock.patch.object(
web_script_utilities.script_utilities.Utilities,
"allSelectedText",
return_value=["", 0, 0],
),
mock.patch.object(utilities, "inDocumentContent", return_value=True),
mock.patch.object(utilities, "getTopLevelDocumentForObject", return_value=document),
):
result = web_script_utilities.Utilities.allSelectedText(utilities, obj)
self.assertEqual(result, ["Dark", 0, 4])