From f4ee5e44687fc54b899836dbf29fad2140254297 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 29 Jul 2026 03:22:10 -0400 Subject: [PATCH] Fixed multiline edit box weirdness. --- src/cthulhu/input_event_manager.py | 16 ++++-- src/cthulhu/scripts/web/script.py | 13 ++++- src/cthulhu/scripts/web/script_utilities.py | 32 ++++++----- ...put_event_manager_x11_focus_regressions.py | 37 +++++++++++++ tests/test_web_input_regressions.py | 54 +++++++++++++++++++ tests/test_web_layout_mode_regressions.py | 1 + 6 files changed, 133 insertions(+), 20 deletions(-) diff --git a/src/cthulhu/input_event_manager.py b/src/cthulhu/input_event_manager.py index 7119485..3c7bebf 100644 --- a/src/cthulhu/input_event_manager.py +++ b/src/cthulhu/input_event_manager.py @@ -608,6 +608,8 @@ class InputEventManager: except Exception as error: msg = f"INPUT EVENT MANAGER: Could not read EWMH active window: {error}" debug.print_message(debug.LEVEL_INFO, msg, True) + self._close_x11_display() + self._didAttemptX11Display = False return None def _close_x11_display(self) -> None: @@ -769,11 +771,15 @@ class InputEventManager: self._xtermRecoverySourceId = 0 return False - match = self._active_x11_window_xterm_match() - if match is True: - self._suspend_key_grabs_for_xterm() - elif match is False: - self._restore_key_grabs_after_xterm() + try: + match = self._active_x11_window_xterm_match() + if match is True: + self._suspend_key_grabs_for_xterm() + elif match is False: + self._restore_key_grabs_after_xterm() + except Exception as error: + msg = f"INPUT EVENT MANAGER: XTerm recovery poll failed: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) return True def _on_active_x11_window_changed(self, screen: Any, _previousWindow: Any) -> None: diff --git a/src/cthulhu/scripts/web/script.py b/src/cthulhu/scripts/web/script.py index df08dc0..db73dec 100644 --- a/src/cthulhu/scripts/web/script.py +++ b/src/cthulhu/scripts/web/script.py @@ -1152,7 +1152,13 @@ class Script(default.Script): def sayLine(self, obj): """Speaks the line at the current caret position.""" - isEditable = self.utilities.isContentEditableWithEmbeddedObjects(obj) + isNativeMultilineEditable = ( + self.utilities.inDocumentContent(obj) + and AXUtilities.is_editable(obj) + and AXUtilities.is_multi_line(obj) + ) + isEditable = self.utilities.isContentEditableWithEmbeddedObjects(obj) \ + or isNativeMultilineEditable if not (self._lastCommandWasCaretNav or self._lastCommandWasStructNav) and not isEditable: super().sayLine(obj) return @@ -1164,7 +1170,10 @@ class Script(default.Script): obj, offset = self.utilities.getCaretContext(documentFrame=document) contents = self.utilities.getLineContentsAtOffset(obj, offset, useCache=True) - self.speakContents(contents, priorObj=priorObj) + if isNativeMultilineEditable: + self.speakContents(contents, priorObj=priorObj, alreadyFocused=True) + else: + self.speakContents(contents, priorObj=priorObj) self.pointOfReference["lastTextUnitSpoken"] = "line" def presentObject(self, obj, **args): diff --git a/src/cthulhu/scripts/web/script_utilities.py b/src/cthulhu/scripts/web/script_utilities.py index 8ec4d95..7dd3420 100644 --- a/src/cthulhu/scripts/web/script_utilities.py +++ b/src/cthulhu/scripts/web/script_utilities.py @@ -1822,6 +1822,17 @@ class Utilities(script_utilities.Utilities): string = AXText.get_substring(obj, offset, firstEnd) return [[firstObj, offset, firstEnd, string]] + contents[1:] + def _getEditableLineBoundary(self, obj): + if self.isContentEditableWithEmbeddedObjects(obj): + return obj + + if self.inDocumentContent(obj) \ + and AXUtilities.is_editable(obj) \ + and AXUtilities.is_multi_line(obj): + return obj + + return AXObject.find_ancestor(obj, self.isContentEditableWithEmbeddedObjects) + def _getLineContentsAtOffset(self, obj, offset, layoutMode=None, useCache=True): startTime = time.time() if not obj: @@ -1930,15 +1941,10 @@ class Utilities(script_utilities.Utilities): prevObj, pOffset = self.findPreviousCaretInOrder(firstObj, firstStart) nextObj, nOffset = self.findNextCaretInOrder(lastObj, lastEnd - 1) - # If we're inside a content editable, don't expand line contents beyond - # its boundaries (e.g. don't include a "More options" button adjacent to - # a message entry just because it's on the same visual line). - contentEditableBoundary = None - if self.isContentEditableWithEmbeddedObjects(obj): - contentEditableBoundary = obj - else: - contentEditableBoundary = AXObject.find_ancestor( - obj, self.isContentEditableWithEmbeddedObjects) + # If we're inside an editable, don't expand line contents beyond its + # boundaries (e.g. don't include an adjacent button just because it's + # on the same visual line). + editableBoundary = self._getEditableLineBoundary(obj) # Check for things on the same line to the left of this object. prevStartTime = time.time() @@ -1954,8 +1960,8 @@ class Utilities(script_utilities.Utilities): if objRow != AXObject.find_ancestor(prevObj, AXUtilities.is_table_row): break - if contentEditableBoundary and prevObj != contentEditableBoundary \ - and not AXObject.find_ancestor(prevObj, lambda x: x == contentEditableBoundary): + if editableBoundary and prevObj != editableBoundary \ + and not AXObject.find_ancestor(prevObj, lambda x: x == editableBoundary): break onLeft = self._getContentsForObj(prevObj, pOffset, boundary) @@ -1988,8 +1994,8 @@ class Utilities(script_utilities.Utilities): if objRow != AXObject.find_ancestor(nextObj, AXUtilities.is_table_row): break - if contentEditableBoundary and nextObj != contentEditableBoundary \ - and not AXObject.find_ancestor(nextObj, lambda x: x == contentEditableBoundary): + if editableBoundary and nextObj != editableBoundary \ + and not AXObject.find_ancestor(nextObj, lambda x: x == editableBoundary): break onRight = self._getContentsForObj(nextObj, nOffset, boundary) diff --git a/tests/test_input_event_manager_x11_focus_regressions.py b/tests/test_input_event_manager_x11_focus_regressions.py index df988ae..cdbb955 100644 --- a/tests/test_input_event_manager_x11_focus_regressions.py +++ b/tests/test_input_event_manager_x11_focus_regressions.py @@ -690,6 +690,43 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase): restore.assert_called_once_with() suspend.assert_not_called() + def test_xterm_focus_poll_survives_transient_matcher_failure(self): + manager = input_event_manager.InputEventManager() + manager._device = object() + manager._xtermRecoverySourceId = 23 + + with ( + mock.patch.object( + manager, + "_active_x11_window_xterm_match", + side_effect=RuntimeError("transient matcher failure"), + ), + mock.patch.object(input_event_manager.debug, "print_message") as printMessage, + ): + result = manager._poll_xterm_grab_recovery() + + self.assertTrue(result) + self.assertEqual(manager._xtermRecoverySourceId, 23) + printMessage.assert_called_once() + + def test_ewmh_read_failure_reconnects_on_next_check(self): + manager = input_event_manager.InputEventManager() + x11Display = mock.Mock() + x11Root = mock.Mock() + x11Root.get_full_property.side_effect = RuntimeError("display connection failed") + manager._x11Display = x11Display + manager._x11Root = x11Root + manager._netActiveWindowAtom = object() + manager._x11AnyPropertyType = object() + manager._didAttemptX11Display = True + + with mock.patch.object(input_event_manager.debug, "print_message"): + result = manager._get_ewmh_active_x11_window_id() + + self.assertIsNone(result) + x11Display.close.assert_called_once_with() + self.assertFalse(manager._didAttemptX11Display) + def test_xterm_suppression_does_not_check_x11_without_active_handoff(self): manager = input_event_manager.InputEventManager() diff --git a/tests/test_web_input_regressions.py b/tests/test_web_input_regressions.py index 434dfbb..1582de5 100644 --- a/tests/test_web_input_regressions.py +++ b/tests/test_web_input_regressions.py @@ -622,6 +622,60 @@ class WebCaretMovedRegressionTests(unittest.TestCase): setLocusOfFocus.assert_not_called() +class WebMultilineEditorLinePresentationRegressionTests(unittest.TestCase): + def test_native_multiline_editor_uses_visual_web_line(self): + testScript = web_script.Script.__new__(web_script.Script) + testScript._lastCommandWasCaretNav = False + testScript._lastCommandWasStructNav = False + testScript.pointOfReference = {} + testScript.utilities = mock.Mock() + testScript.utilities.isContentEditableWithEmbeddedObjects.return_value = False + testScript.utilities.inDocumentContent.return_value = True + testScript.utilities.getTopLevelDocumentForObject.return_value = "document" + testScript.utilities.getPriorContext.return_value = ("prior", 20) + testScript.utilities.getCaretContext.return_value = ("editor", 42) + visualLine = [["editor", 40, 55, "wrapped line"]] + testScript.utilities.getLineContentsAtOffset.return_value = visualLine + testScript.speakContents = mock.Mock() + + with ( + mock.patch.object(web_script.AXUtilities, "is_editable", return_value=True), + mock.patch.object(web_script.AXUtilities, "is_multi_line", return_value=True), + mock.patch.object(web_script.default.Script, "sayLine") as defaultSayLine, + ): + web_script.Script.sayLine(testScript, "editor") + + defaultSayLine.assert_not_called() + testScript.utilities.getLineContentsAtOffset.assert_called_once_with( + "editor", + 42, + useCache=True, + ) + testScript.speakContents.assert_called_once_with( + visualLine, + priorObj="prior", + alreadyFocused=True, + ) + + def test_native_multiline_editor_is_visual_line_boundary(self): + editor = object() + utilities = web_script_utilities.Utilities.__new__( + web_script_utilities.Utilities + ) + utilities.isContentEditableWithEmbeddedObjects = mock.Mock(return_value=False) + utilities.inDocumentContent = mock.Mock(return_value=True) + + with ( + mock.patch.object(web_script_utilities.AXUtilities, "is_editable", return_value=True), + mock.patch.object(web_script_utilities.AXUtilities, "is_multi_line", return_value=True), + mock.patch.object(web_script_utilities.AXObject, "find_ancestor") as findAncestor, + ): + boundary = utilities._getEditableLineBoundary(editor) + + self.assertIs(boundary, editor) + findAncestor.assert_not_called() + + class TextEventReasonRegressionTests(unittest.TestCase): @staticmethod def _make_input_manager(): diff --git a/tests/test_web_layout_mode_regressions.py b/tests/test_web_layout_mode_regressions.py index 69a9bee..8ca0276 100644 --- a/tests/test_web_layout_mode_regressions.py +++ b/tests/test_web_layout_mode_regressions.py @@ -85,6 +85,7 @@ class WebLayoutModeLineRegressionTests(unittest.TestCase): utilities.findPreviousCaretInOrder = mock.Mock(return_value=(None, -1)) utilities.findNextCaretInOrder = mock.Mock(return_value=(None, -1)) utilities.isContentEditableWithEmbeddedObjects = mock.Mock(return_value=False) + utilities.inDocumentContent = mock.Mock(return_value=False) return utilities, obj, contents def _get_line_for_setting(self, layoutMode):