From 243854b76118501cafc7e31b38da319e27997f7d Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 26 Jul 2026 17:06:11 -0400 Subject: [PATCH] Make page layout for browsers off by default. --- src/cthulhu/scripts/default.py | 14 ++ src/cthulhu/scripts/web/script.py | 12 +- src/cthulhu/settings.py | 2 +- tests/test_web_input_regressions.py | 24 +++- tests/test_web_layout_mode_regressions.py | 163 ++++++++++++++++++++++ 5 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 tests/test_web_layout_mode_regressions.py diff --git a/src/cthulhu/scripts/default.py b/src/cthulhu/scripts/default.py index ec74186..2cf258f 100644 --- a/src/cthulhu/scripts/default.py +++ b/src/cthulhu/scripts/default.py @@ -3441,6 +3441,20 @@ class Script(script.Script): return requested + def should_interrupt_for_mode_announcement( + self, + requested: bool, + already_handled: bool = False, + ) -> bool: + """Returns whether a mode announcement should interrupt prior output.""" + + if requested and already_handled: + msg = "DEFAULT: Mode announcement 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 4e3e8b0..fb6de01 100644 --- a/src/cthulhu/scripts/web/script.py +++ b/src/cthulhu/scripts/web/script.py @@ -1523,7 +1523,11 @@ class Script(default.Script): self.utilities.setCaretContext(AXObject.get_parent(parent), -1) if not self._loadingDocumentContent: if inputEvent is not None: - self.presentMessage(messages.MODE_BROWSE, interrupt=True) + interrupt = self.should_interrupt_for_mode_announcement( + True, + already_handled=True, + ) + self.presentMessage(messages.MODE_BROWSE, interrupt=interrupt) else: self.presentMessage(messages.MODE_BROWSE) if not self._shouldSuppressBrowseModeSound(obj, inputEvent): @@ -1536,7 +1540,11 @@ class Script(default.Script): self.utilities.grabFocus(obj) if inputEvent is not None: - self.presentMessage(messages.MODE_FOCUS, interrupt=True) + interrupt = self.should_interrupt_for_mode_announcement( + True, + already_handled=True, + ) + self.presentMessage(messages.MODE_FOCUS, interrupt=interrupt) else: self.presentMessage(messages.MODE_FOCUS) sound_theme_manager.getManager().playFocusModeSound() diff --git a/src/cthulhu/settings.py b/src/cthulhu/settings.py index d6bcb92..b65634c 100644 --- a/src/cthulhu/settings.py +++ b/src/cthulhu/settings.py @@ -485,7 +485,7 @@ structNavTriggersFocusMode = False caretNavTriggersFocusMode = False nativeNavTriggersFocusMode = True -layoutMode = True +layoutMode = False rewindAndFastForwardInSayAll = False structNavInSayAll = False diff --git a/tests/test_web_input_regressions.py b/tests/test_web_input_regressions.py index a4e484b..434dfbb 100644 --- a/tests/test_web_input_regressions.py +++ b/tests/test_web_input_regressions.py @@ -269,13 +269,33 @@ class WebPresentationModeSpeechRegressionTests(unittest.TestCase): self.assertFalse(testScript._browseModeIsSticky) testScript.refreshKeyGrabs.assert_called_once_with() - def test_manual_presentation_mode_toggle_still_interrupts(self): + def test_manual_focus_mode_announcement_does_not_interrupt_after_keyboard_dispatch(self): testScript = self._make_script(inFocusMode=False) with mock.patch.object(web_script.sound_theme_manager, "getManager", return_value=mock.Mock()): web_script.Script.togglePresentationMode(testScript, object(), "document") - testScript.presentMessage.assert_called_once_with(messages.MODE_FOCUS, interrupt=True) + testScript.presentMessage.assert_called_once_with(messages.MODE_FOCUS, interrupt=False) + + def test_manual_browse_mode_announcement_does_not_interrupt_after_keyboard_dispatch(self): + testScript = self._make_script(inFocusMode=True) + + with ( + mock.patch.object(web_script.AXObject, "get_parent", return_value=None), + mock.patch.object(web_script.AXUtilities, "is_list_box", return_value=False), + mock.patch.object(web_script.AXUtilities, "is_menu", return_value=False), + mock.patch.object(web_script.sound_theme_manager, "getManager", return_value=mock.Mock()), + ): + web_script.Script.togglePresentationMode(testScript, object(), "document") + + testScript.presentMessage.assert_called_once_with(messages.MODE_BROWSE, interrupt=False) + + def test_non_keyboard_mode_announcement_keeps_interrupt_request(self): + testScript = self._make_script(inFocusMode=False) + + result = web_script.Script.should_interrupt_for_mode_announcement(testScript, True) + + self.assertTrue(result) class WebObjectPresentationInterruptionRegressionTests(unittest.TestCase): diff --git a/tests/test_web_layout_mode_regressions.py b/tests/test_web_layout_mode_regressions.py new file mode 100644 index 0000000..69a9bee --- /dev/null +++ b/tests/test_web_layout_mode_regressions.py @@ -0,0 +1,163 @@ +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import gi + +gi.require_version("Atspi", "2.0") + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from cthulhu import settings +from cthulhu import settings_manager +from cthulhu.scripts.web import script_utilities as web_script_utilities + + +class WebLayoutModeSettingsRegressionTests(unittest.TestCase): + def test_layout_mode_defaults_to_object_mode(self): + self.assertFalse(settings.layoutMode) + + def test_explicit_persisted_layout_mode_true_is_preserved(self): + with tempfile.TemporaryDirectory() as tempDir: + with mock.patch.object(settings, "layoutMode", False): + manager = settings_manager.SettingsManager(mock.Mock()) + manager.activate(prefsDir=tempDir) + settingsPath = Path(tempDir, "user-settings.toml") + settingsText = settingsPath.read_text(encoding="utf-8") + settingsPath.write_text( + settingsText.replace("layoutMode = false", "layoutMode = true"), + encoding="utf-8", + ) + + reloadedManager = settings_manager.SettingsManager(mock.Mock()) + reloadedManager.activate(prefsDir=tempDir) + + self.assertTrue(reloadedManager.getSetting("layoutMode")) + + def test_application_specific_layout_mode_true_is_preserved(self): + with tempfile.TemporaryDirectory() as tempDir: + with mock.patch.object(settings, "layoutMode", False): + manager = settings_manager.SettingsManager(mock.Mock()) + manager.activate(prefsDir=tempDir) + manager._backend.saveAppSettings( + "Browser", + manager.profile, + {"layoutMode": True}, + {}, + {}, + ) + script = mock.Mock() + script.app = object() + script.getKeyBindings.return_value = mock.Mock() + + with mock.patch.object( + settings_manager.AXObject, + "get_name", + return_value="Browser", + ): + manager.loadAppSettings(script) + + self.assertTrue(manager.getSetting("layoutMode")) + + +class WebLayoutModeLineRegressionTests(unittest.TestCase): + def _make_utilities(self): + obj = object() + contents = [[obj, 0, 4, "Text"]] + utilities = web_script_utilities.Utilities.__new__( + web_script_utilities.Utilities + ) + utilities._script = mock.Mock() + utilities._script.inFocusMode.return_value = False + utilities._currentLineContents = [] + utilities._debugContentsInfo = mock.Mock() + utilities.findObjectInContents = mock.Mock(return_value=-1) + utilities.treatAsEndOfLine = mock.Mock(return_value=False) + utilities.getExtents = mock.Mock(return_value=[0, 0, 10, 10]) + utilities.isInlineListDescendant = mock.Mock(return_value=False) + utilities._getContentsForObj = mock.Mock(return_value=contents) + utilities.isLink = mock.Mock(return_value=False) + utilities.isMath = mock.Mock(return_value=False) + utilities.isMathTopLevel = mock.Mock(return_value=False) + utilities.getDocumentForObject = mock.Mock(return_value=object()) + utilities.findPreviousCaretInOrder = mock.Mock(return_value=(None, -1)) + utilities.findNextCaretInOrder = mock.Mock(return_value=(None, -1)) + utilities.isContentEditableWithEmbeddedObjects = mock.Mock(return_value=False) + return utilities, obj, contents + + def _get_line_for_setting(self, layoutMode): + utilities, obj, contents = self._make_utilities() + settingsManager = mock.Mock() + settingsManager.getSetting.return_value = layoutMode + + with ( + mock.patch.object( + web_script_utilities.cthulhu.cthulhuApp, + "settingsManager", + settingsManager, + ), + mock.patch.object( + web_script_utilities.AXObject, + "is_dead", + return_value=False, + ), + mock.patch.object( + web_script_utilities.AXObject, + "find_ancestor", + return_value=None, + ), + mock.patch.object( + web_script_utilities.AXUtilities, + "is_tool_bar", + return_value=False, + ), + mock.patch.object( + web_script_utilities.AXUtilities, + "is_menu_bar", + return_value=False, + ), + mock.patch.object( + web_script_utilities.AXUtilities, + "is_table_row", + return_value=False, + ), + ): + result = web_script_utilities.Utilities._getLineContentsAtOffset( + utilities, + obj, + 0, + layoutMode=None, + useCache=False, + ) + + return utilities, obj, contents, result + + def test_unset_profile_setting_uses_object_mode_for_web_line(self): + utilities, obj, contents, result = self._get_line_for_setting(False) + + self.assertEqual(result, contents) + utilities.getDocumentForObject.assert_not_called() + utilities._debugContentsInfo.assert_called_once_with( + obj, + 0, + contents, + "Line (not layout mode)", + ) + + def test_explicit_layout_mode_true_uses_visual_web_line(self): + utilities, obj, contents, result = self._get_line_for_setting(True) + + self.assertEqual(result, contents) + utilities.getDocumentForObject.assert_called_once_with(obj) + utilities._debugContentsInfo.assert_called_once_with( + obj, + 0, + contents, + "Line (layout mode)", + ) + + +if __name__ == "__main__": + unittest.main()