diff --git a/src/cthulhu/scripts/default.py b/src/cthulhu/scripts/default.py index b25adf8..ec74186 100644 --- a/src/cthulhu/scripts/default.py +++ b/src/cthulhu/scripts/default.py @@ -3001,7 +3001,13 @@ class Script(script.Script): self.pointOfReference["lastTextUnitSpoken"] = "word" def presentObject(self, obj, **args): - interrupt = args.get("interrupt", False) + has_interrupt_request = "interrupt" in args + interrupt = self.should_interrupt_for_object_presentation( + args.get("interrupt", False), + args.pop("interrupt_already_handled", False), + ) + if has_interrupt_request: + args["interrupt"] = interrupt tokens = ["DEFAULT: Presenting object", obj, ". Interrupt:", interrupt] debug.printTokens(debug.LEVEL_INFO, tokens, True) @@ -3421,6 +3427,20 @@ class Script(script.Script): if self.utilities.shouldInterruptForLocusOfFocusChange(oldFocus, newFocus, event): self.presentationInterrupt() + def should_interrupt_for_object_presentation( + self, + requested: bool, + already_handled: bool = False, + ) -> bool: + """Returns whether object presentation should interrupt prior output.""" + + if requested and already_handled: + msg = "DEFAULT: Object presentation interruption already handled by input dispatch" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return False + + return requested + def presentKeyboardEvent(self, event): """Convenience method to present the KeyboardEvent event. Returns True if we fully present the event; False otherwise.""" diff --git a/src/cthulhu/scripts/web/script.py b/src/cthulhu/scripts/web/script.py index 4e12d85..4e3e8b0 100644 --- a/src/cthulhu/scripts/web/script.py +++ b/src/cthulhu/scripts/web/script.py @@ -1183,7 +1183,13 @@ class Script(default.Script): super().presentObject(obj, **args) return - interrupt = args.get("interrupt", False) + has_interrupt_request = "interrupt" in args + interrupt = self.should_interrupt_for_object_presentation( + args.get("interrupt", False), + args.pop("interrupt_already_handled", False), + ) + if has_interrupt_request: + args["interrupt"] = interrupt tokens = ["WEB: Presenting object", obj, ". Interrupt:", interrupt] debug.printTokens(debug.LEVEL_INFO, tokens, True) diff --git a/src/cthulhu/structural_navigation.py b/src/cthulhu/structural_navigation.py index b595da3..6f30e0c 100644 --- a/src/cthulhu/structural_navigation.py +++ b/src/cthulhu/structural_navigation.py @@ -1221,7 +1221,13 @@ class StructuralNavigation: return AXEventSynthesizer.scroll_to_top_edge(obj) - self._script.presentObject(obj, offset=offset, priorObj=priorObj, interrupt=True) + self._script.presentObject( + obj, + offset=offset, + priorObj=priorObj, + interrupt=True, + interrupt_already_handled=True, + ) def _presentWithSayAll(self, obj, offset): if self._script.inSayAll() \ diff --git a/tests/test_structural_navigation_table_regressions.py b/tests/test_structural_navigation_table_regressions.py index de49dd3..987c9e1 100644 --- a/tests/test_structural_navigation_table_regressions.py +++ b/tests/test_structural_navigation_table_regressions.py @@ -17,6 +17,25 @@ from cthulhu import structural_navigation class StructuralNavigationTableRegressionTests(unittest.TestCase): + def test_object_presentation_marks_keyboard_interruption_as_already_handled(self): + navigator = structural_navigation.StructuralNavigation.__new__( + structural_navigation.StructuralNavigation + ) + navigator._script = mock.Mock() + navigator._presentWithSayAll = mock.Mock(return_value=False) + target = object() + + with mock.patch.object(structural_navigation.AXEventSynthesizer, "scroll_to_top_edge"): + navigator._presentObject(target, 3, "prior") + + navigator._script.presentObject.assert_called_once_with( + target, + offset=3, + priorObj="prior", + interrupt=True, + interrupt_already_handled=True, + ) + def test_table_cell_coordinates_do_not_interrupt_cell_contents(self): navigator = structural_navigation.StructuralNavigation.__new__( structural_navigation.StructuralNavigation diff --git a/tests/test_web_input_regressions.py b/tests/test_web_input_regressions.py index 0e6fd74..a4e484b 100644 --- a/tests/test_web_input_regressions.py +++ b/tests/test_web_input_regressions.py @@ -278,6 +278,78 @@ class WebPresentationModeSpeechRegressionTests(unittest.TestCase): testScript.presentMessage.assert_called_once_with(messages.MODE_FOCUS, interrupt=True) +class WebObjectPresentationInterruptionRegressionTests(unittest.TestCase): + @staticmethod + def _make_script(): + testScript = web_script.Script.__new__(web_script.Script) + testScript._lastCommandWasCaretNav = False + testScript._loadingDocumentContent = False + testScript.utilities = mock.Mock() + testScript.utilities.inDocumentContent.return_value = True + testScript.utilities.getTable.return_value = None + testScript.utilities.get_objectContentsAtOffset.return_value = ["target"] + testScript.displayContents = mock.Mock() + testScript.speakContents = mock.Mock() + return testScript + + def test_keyboard_owned_object_presentation_does_not_interrupt_again(self): + testScript = self._make_script() + target = object() + + with ( + mock.patch.object(web_script.AXUtilities, "is_document", return_value=False), + mock.patch.object(web_script.AXUtilities, "is_status_bar", return_value=False), + mock.patch.object(web_script.AXUtilities, "is_entry", return_value=False), + ): + web_script.Script.presentObject( + testScript, + target, + interrupt=True, + interrupt_already_handled=True, + ) + + testScript.speakContents.assert_called_once_with(["target"], interrupt=False) + + def test_non_keyboard_object_presentation_still_interrupts(self): + testScript = self._make_script() + target = object() + + with ( + mock.patch.object(web_script.AXUtilities, "is_document", return_value=False), + mock.patch.object(web_script.AXUtilities, "is_status_bar", return_value=False), + mock.patch.object(web_script.AXUtilities, "is_entry", return_value=False), + ): + web_script.Script.presentObject(testScript, target, interrupt=True) + + testScript.speakContents.assert_called_once_with(["target"], interrupt=True) + + def test_web_alert_keeps_non_keyboard_interrupt_request(self): + testScript = self._make_script() + testScript._lastCommandWasStructNav = False + testScript._lastMouseButtonContext = (None, -1) + testScript.lastMouseRoutingTime = 0 + testScript.utilities.eventIsBrowserUINoise.return_value = False + testScript.utilities.isLiveRegion.return_value = False + testScript.utilities.getTopLevelDocumentForObject.return_value = "document" + testScript.utilities.isZombie.return_value = False + testScript.utilities.handleEventFromContextReplicant.return_value = False + testScript.utilities.lastQueuedLiveRegion.return_value = None + testScript.presentObject = mock.Mock() + alert = object() + event = mock.Mock(source=object(), any_data=alert) + + with ( + mock.patch.object(web_script.AXObject, "clear_cache_now"), + mock.patch.object(web_script.AXUtilities, "is_busy", return_value=False), + mock.patch.object(web_script.AXUtilities, "is_alert", return_value=True), + mock.patch.object(web_script.AXUtilities, "get_focused_object", return_value=None), + ): + result = web_script.Script.onChildrenAdded(testScript, event) + + self.assertTrue(result) + testScript.presentObject.assert_called_once_with(alert, interrupt=True) + + class WebFocusSpeechInterruptionRegressionTests(unittest.TestCase): def _make_script(self): testScript = web_script.Script.__new__(web_script.Script)