diff --git a/src/cthulhu/scripts/web/script.py b/src/cthulhu/scripts/web/script.py index 93902e6..bb3c54c 100644 --- a/src/cthulhu/scripts/web/script.py +++ b/src/cthulhu/scripts/web/script.py @@ -173,8 +173,12 @@ class Script(default.Script): obj, offset, focusOverride=None, - ): - if not self.utilities.lastInputEventWasCaretNavWithSelection(): + isCaretSelection: bool | None = None, + ) -> bool: + if isCaretSelection is None: + isCaretSelection = self.utilities.lastInputEventWasCaretNavWithSelection() + + if not isCaretSelection: return False if focusOverride is not None: @@ -3181,6 +3185,8 @@ class Script(default.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) return False + reason = AXUtilitiesEvent.get_text_event_reason(event) + if not self.utilities.inDocumentContent(cthulhu_state.locusOfFocus): tokens = ["WEB: Event ignored: locusOfFocus", cthulhu_state.locusOfFocus, "is not in document content"] @@ -3208,8 +3214,18 @@ class Script(default.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) return True - if self.utilities.lastInputEventWasCaretNavWithSelection() \ - and not AXText.get_selected_ranges(event.source): + selectionReasons = ( + TextEventReason.SELECTION_BY_CHARACTER, + TextEventReason.SELECTION_BY_LINE, + TextEventReason.SELECTION_BY_PARAGRAPH, + TextEventReason.SELECTION_BY_PAGE, + TextEventReason.SELECTION_BY_WORD, + TextEventReason.SELECTION_TO_FILE_BOUNDARY, + TextEventReason.SELECTION_TO_LINE_BOUNDARY, + TextEventReason.UNSPECIFIED_SELECTION, + ) + isSelectionReason = reason in selectionReasons + if isSelectionReason and not AXText.get_selected_ranges(event.source): document = self.utilities.getTopLevelDocumentForObject(event.source) obj, offset = self.utilities.getCaretContext(document, False, False) focusOffset = AXText.get_caret_offset(event.source) @@ -3219,6 +3235,7 @@ class Script(default.Script): obj, offset, focusOverride=(event.source, focusOffset), + isCaretSelection=True, ): msg = "WEB: Event handled: synthetic selection from event source" debug.printMessage(debug.LEVEL_INFO, msg, True) @@ -3239,7 +3256,7 @@ class Script(default.Script): offset = AXText.get_caret_offset(event.source) char = AXText.get_substring(event.source, offset, offset + 1) if char == self.EMBEDDED_OBJECT_CHARACTER \ - and not self.utilities.lastInputEventWasCaretNavWithSelection() \ + and not isSelectionReason \ and not self.utilities.lastInputEventWasCommand(): msg = "WEB: Ignoring: Not selecting and event offset is at embedded object" debug.printMessage(debug.LEVEL_INFO, msg, True) diff --git a/tests/test_web_input_regressions.py b/tests/test_web_input_regressions.py index 0ee84ce..c879ee0 100644 --- a/tests/test_web_input_regressions.py +++ b/tests/test_web_input_regressions.py @@ -529,11 +529,63 @@ class TextEventReasonRegressionTests(unittest.TestCase): "last_event_was_middle_release", "last_event_was_up_or_down", "last_event_was_page_up_or_page_down", + "last_event_was_caret_selection", + "last_event_was_caret_navigation", + "last_event_was_line_navigation", + "last_event_was_word_navigation", + "last_event_was_character_navigation", + "last_event_was_page_navigation", + "last_event_was_line_boundary_navigation", + "last_event_was_file_boundary_navigation", + "last_event_was_select_all", + "last_event_was_primary_click_or_release", ) for method in methods: getattr(manager, method).return_value = False return manager + def _classify_selection( + self, + event, + manager, + *, + focus=None, + focusIsSearch=False, + isEditable=False, + spinAncestor=None, + ): + with ( + mock.patch("cthulhu.input_event_manager.get_manager", return_value=manager), + mock.patch( + "cthulhu.ax_utilities_event.focus_manager.get_manager" + ) as getFocusManager, + mock.patch( + "cthulhu.ax_utilities_event.AXUtilitiesRole.is_text_input_search", + return_value=focusIsSearch, + ), + mock.patch( + "cthulhu.ax_utilities_event.AXUtilitiesState.is_editable", + return_value=isEditable, + ), + mock.patch( + "cthulhu.ax_utilities_event.AXUtilitiesRole.is_terminal", + return_value=False, + ), + mock.patch( + "cthulhu.ax_utilities_event.AXUtilitiesRole.is_spin_button", + return_value=False, + ), + mock.patch.object( + web_script.AXObject, + "find_ancestor", + return_value=spinAncestor, + ), + ): + getFocusManager.return_value.get_locus_of_focus.return_value = ( + event.source if focus is None else focus + ) + return AXUtilitiesEvent._get_text_selection_changed_event_reason(event) + def _classify_insertion( self, event, @@ -764,6 +816,92 @@ class TextEventReasonRegressionTests(unittest.TestCase): self.assertEqual(reason, expected) + def test_selection_classifier_covers_handler_reason_families(self): + source = object() + cases = ( + ( + "search presentable", + TextEventReason.SEARCH_PRESENTABLE, + {"focus": object(), "focusIsSearch": True}, + {}, + ), + ( + "search unpresentable", + TextEventReason.SEARCH_UNPRESENTABLE, + {"focus": object(), "focusIsSearch": True}, + {"last_event_was_backspace": True}, + ), + ( + "selection by word", + TextEventReason.SELECTION_BY_WORD, + {}, + { + "last_event_was_caret_selection": True, + "last_event_was_word_navigation": True, + }, + ), + ( + "unspecified selection", + TextEventReason.UNSPECIFIED_SELECTION, + {}, + {"last_event_was_caret_selection": True}, + ), + ( + "navigation by character", + TextEventReason.NAVIGATION_BY_CHARACTER, + {}, + { + "last_event_was_caret_navigation": True, + "last_event_was_character_navigation": True, + }, + ), + ( + "select all", + TextEventReason.SELECT_ALL, + {}, + {"last_event_was_select_all": True}, + ), + ( + "primary mouse", + TextEventReason.MOUSE_PRIMARY_BUTTON, + {}, + {"last_event_was_primary_click_or_release": True}, + ), + ( + "typing", + TextEventReason.TYPING, + {"isEditable": True}, + {"last_event_was_printable_key": True}, + ), + ( + "editing command", + TextEventReason.PASTE, + {"isEditable": True}, + {"last_event_was_paste": True}, + ), + ( + "spin button", + TextEventReason.SPIN_BUTTON_VALUE_CHANGE, + {"isEditable": True, "spinAncestor": object()}, + {"last_event_was_up_or_down": True}, + ), + ) + + for name, expected, classifierKwargs, managerResults in cases: + with self.subTest(name=name): + manager = self._make_input_manager() + for method, result in managerResults.items(): + getattr(manager, method).return_value = result + event = mock.Mock(type="object:text-selection-changed", source=source) + + reason = self._classify_selection( + event, + manager, + **classifierKwargs, + ) + + self.assertEqual(reason, expected) + def test_tab_is_classified_as_focus_change_before_editable_state(self): oldFocus = object() source = object() @@ -1727,6 +1865,11 @@ class WebSelectionRegressionTests(unittest.TestCase): with ( mock.patch.object(web_script.cthulhu_state, "locusOfFocus", anchor), + mock.patch.object( + web_script.AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.SELECTION_BY_CHARACTER, + ), 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"), @@ -1744,6 +1887,214 @@ class WebSelectionRegressionTests(unittest.TestCase): "S", ) + def test_selection_reason_owns_synthetic_selection_recovery(self): + testScript = self._make_script() + event = mock.Mock(source=object()) + testScript.utilities.textEventIsForNonNavigableTextObject.return_value = False + testScript.utilities.lastInputEventWasCaretNavWithSelection.return_value = False + testScript.utilities.isContentEditableWithEmbeddedObjects.return_value = False + testScript._presentSyntheticCaretSelection = mock.Mock(return_value=True) + testScript.utilities.getTopLevelDocumentForObject.return_value = object() + testScript.utilities.getCaretContext.return_value = (object(), 0) + + with ( + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", object()), + mock.patch.object( + web_script.AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.SELECTION_BY_CHARACTER, + ) as getReason, + mock.patch.object(web_script.AXText, "get_selected_ranges", return_value=[]), + mock.patch.object(web_script.AXText, "get_caret_offset", return_value=0), + mock.patch.object(web_script.AXText, "get_substring", return_value="x"), + mock.patch.object(web_script.AXUtilities, "is_text_input", return_value=False), + ): + result = web_script.Script.onTextSelectionChanged(testScript, event) + + self.assertTrue(result) + getReason.assert_called_once_with(event) + testScript._presentSyntheticCaretSelection.assert_called_once() + + def test_navigation_reason_does_not_claim_synthetic_selection_recovery(self): + testScript = self._make_script() + event = mock.Mock(source=object()) + testScript.utilities.textEventIsForNonNavigableTextObject.return_value = False + testScript.utilities.lastInputEventWasCaretNavWithSelection.return_value = True + testScript.utilities.isContentEditableWithEmbeddedObjects.return_value = False + testScript._presentSyntheticCaretSelection = mock.Mock(return_value=True) + testScript.utilities.getTopLevelDocumentForObject.return_value = object() + testScript.utilities.getCaretContext.return_value = (object(), 0) + + with ( + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", object()), + mock.patch.object( + web_script.AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.NAVIGATION_BY_CHARACTER, + ) as getReason, + mock.patch.object(web_script.AXText, "get_selected_ranges", return_value=[]), + mock.patch.object(web_script.AXText, "get_caret_offset", return_value=0), + mock.patch.object(web_script.AXText, "get_substring", return_value="x"), + mock.patch.object(web_script.AXUtilities, "is_text_input", return_value=False), + ): + result = web_script.Script.onTextSelectionChanged(testScript, event) + + self.assertFalse(result) + getReason.assert_called_once_with(event) + testScript._presentSyntheticCaretSelection.assert_not_called() + + def test_search_reason_preserves_focus_outside_document_suppression(self): + testScript = self._make_script() + source = object() + focus = object() + event = mock.Mock(source=source) + testScript.utilities.inDocumentContent.side_effect = lambda obj: obj is source + + with ( + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", focus), + mock.patch.object( + web_script.AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.SEARCH_PRESENTABLE, + ) as getReason, + ): + result = web_script.Script.onTextSelectionChanged(testScript, event) + + self.assertTrue(result) + getReason.assert_called_once_with(event) + + def test_editing_reason_preserves_default_selection_owner(self): + testScript = self._make_script() + source = object() + event = mock.Mock(source=source) + testScript.utilities.textEventIsForNonNavigableTextObject.return_value = False + testScript.utilities.lastInputEventWasCaretNavWithSelection.return_value = False + testScript.utilities.isContentEditableWithEmbeddedObjects.return_value = False + + with ( + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", source), + mock.patch.object( + web_script.AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.PASTE, + ) as getReason, + mock.patch.object(web_script.AXText, "get_caret_offset", return_value=0), + mock.patch.object(web_script.AXText, "get_substring", return_value="x"), + mock.patch.object(web_script.AXUtilities, "is_text_input", return_value=False), + ): + result = web_script.Script.onTextSelectionChanged(testScript, event) + + self.assertFalse(result) + getReason.assert_called_once_with(event) + + def test_autocomplete_noise_still_precedes_reason_specific_ownership(self): + testScript = self._make_script() + event = mock.Mock(source=object()) + testScript.utilities.eventIsAutocompleteNoise.return_value = True + + with ( + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", object()), + mock.patch.object( + web_script.AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.SELECTION_BY_WORD, + ) as getReason, + ): + result = web_script.Script.onTextSelectionChanged(testScript, event) + + self.assertTrue(result) + getReason.assert_called_once_with(event) + testScript.utilities.lastInputEventWasCaretNavWithSelection.assert_not_called() + + def test_spinner_noise_still_precedes_reason_specific_ownership(self): + testScript = self._make_script() + event = mock.Mock(source=object()) + testScript.utilities.eventIsSpinnerNoise.return_value = True + + with ( + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", object()), + mock.patch.object( + web_script.AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.SPIN_BUTTON_VALUE_CHANGE, + ) as getReason, + ): + result = web_script.Script.onTextSelectionChanged(testScript, event) + + self.assertTrue(result) + getReason.assert_called_once_with(event) + testScript.utilities.lastInputEventWasCaretNavWithSelection.assert_not_called() + + def test_non_navigable_text_still_suppresses_selection_reason(self): + testScript = self._make_script() + event = mock.Mock(source=object()) + testScript.utilities.textEventIsForNonNavigableTextObject.return_value = True + + with ( + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", object()), + mock.patch.object( + web_script.AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.SELECTION_BY_LINE, + ) as getReason, + ): + result = web_script.Script.onTextSelectionChanged(testScript, event) + + self.assertTrue(result) + getReason.assert_called_once_with(event) + testScript.utilities.lastInputEventWasCaretNavWithSelection.assert_not_called() + + def test_selection_reason_at_embedded_object_offset_defers_to_default(self): + testScript = self._make_script() + source = object() + event = mock.Mock(source=source) + testScript.utilities.textEventIsForNonNavigableTextObject.return_value = False + testScript.utilities.lastInputEventWasCaretNavWithSelection.return_value = False + testScript.utilities.isContentEditableWithEmbeddedObjects.return_value = False + + with ( + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", source), + mock.patch.object( + web_script.AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.SELECTION_BY_WORD, + ), + mock.patch.object(web_script.AXText, "get_selected_ranges", return_value=[(0, 1)]), + mock.patch.object(web_script.AXText, "get_caret_offset", return_value=0), + mock.patch.object( + web_script.AXText, + "get_substring", + return_value=web_script.Script.EMBEDDED_OBJECT_CHARACTER, + ), + mock.patch.object(web_script.AXUtilities, "is_text_input", return_value=False), + ): + result = web_script.Script.onTextSelectionChanged(testScript, event) + + self.assertFalse(result) + testScript.utilities.lastInputEventWasCaretNavWithSelection.assert_not_called() + + def test_contenteditable_embedded_objects_still_defer_to_default(self): + testScript = self._make_script() + source = object() + event = mock.Mock(source=source) + testScript.utilities.textEventIsForNonNavigableTextObject.return_value = False + testScript.utilities.lastInputEventWasCaretNavWithSelection.return_value = False + testScript.utilities.isContentEditableWithEmbeddedObjects.return_value = True + + with ( + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", source), + mock.patch.object( + web_script.AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.TYPING, + ) as getReason, + mock.patch.object(web_script.AXUtilities, "is_text_input", return_value=False), + ): + result = web_script.Script.onTextSelectionChanged(testScript, event) + + self.assertFalse(result) + getReason.assert_called_once_with(event) + 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)