diff --git a/src/cthulhu/scripts/default.py b/src/cthulhu/scripts/default.py index 4441c32..ec74186 100644 --- a/src/cthulhu/scripts/default.py +++ b/src/cthulhu/scripts/default.py @@ -1127,9 +1127,11 @@ class Script(script.Script): newLocusOfFocus, priorObj=oldLocusOfFocus) - if self.utilities.shouldInterruptForLocusOfFocusChange( - oldLocusOfFocus, newLocusOfFocus, event): - self.presentationInterrupt() + self.interrupt_presentation_for_focus_change( + oldLocusOfFocus, + newLocusOfFocus, + event, + ) speech.speak(utterances, interrupt=False) cthulhu.emitRegionChanged(newLocusOfFocus) self._saveFocusedObjectInfo(newLocusOfFocus) @@ -2999,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) @@ -3408,6 +3416,31 @@ class Script(script.Script): speech.stop() braille.killFlash() + def interrupt_presentation_for_focus_change( + self, + oldFocus: object, + newFocus: object, + event: object | None = None, + ) -> None: + """Interrupts presentation when a focus change requires it.""" + + 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 bb3c54c..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) @@ -1898,8 +1904,7 @@ class Script(default.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) return True - if self.utilities.shouldInterruptForLocusOfFocusChange(oldFocus, newFocus, event): - self.presentationInterrupt() + self.interrupt_presentation_for_focus_change(oldFocus, newFocus, event) args["interrupt"] = False if contents: 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 c879ee0..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) @@ -332,6 +404,28 @@ class WebFocusSpeechInterruptionRegressionTests(unittest.TestCase): testScript.presentationInterrupt.assert_called_once_with() speak.assert_called_once_with(["focus speech"], interrupt=False) + def test_focus_generated_speech_appends_when_interruption_is_not_needed(self): + testScript = self._make_script() + testScript.utilities.shouldInterruptForLocusOfFocusChange.return_value = False + oldFocus = object() + newFocus = object() + event = mock.Mock(type="object:state-changed:focused", source=newFocus) + + with ( + mock.patch.object(web_script.AXObject, "is_dead", return_value=False), + mock.patch.object(web_script.AXUtilities, "is_unknown_or_redundant", return_value=False), + mock.patch.object(web_script.AXUtilities, "is_heading", return_value=False), + mock.patch.object(web_script.speech, "speak") as speak, + mock.patch.object(web_script.cthulhu, "emitRegionChanged"), + ): + result = web_script.Script.locus_of_focus_changed( + testScript, event, oldFocus, newFocus + ) + + self.assertTrue(result) + testScript.presentationInterrupt.assert_not_called() + speak.assert_called_once_with(["focus speech"], interrupt=False) + class WebCaretMovedRegressionTests(unittest.TestCase): def _make_script(self):