diff --git a/src/fenrirscreenreader/core/screenManager.py b/src/fenrirscreenreader/core/screenManager.py index f13c9b82..470ef8b3 100644 --- a/src/fenrirscreenreader/core/screenManager.py +++ b/src/fenrirscreenreader/core/screenManager.py @@ -356,32 +356,37 @@ class ScreenManager: # Not typing - handle as line-by-line content change # This catches: incoming messages, screen updates, # application output, etc. + appended_text = self._get_recent_cursor_line_append() + if appended_text is not None: + diff_list = ["+ " + appended_text] + typing = True + else: - # Pre-process screen text for comparison - collapse multiple spaces to single space - # This normalization prevents spurious diffs from spacing - # inconsistencies - old_screen_text = self._space_normalize_regex.sub( - " ", - self.env["runtime"][ - "ScreenManager" - ].get_window_area_in_text( - self.env["screen"]["old_content_text"] - ), - ) - new_screen_text = self._space_normalize_regex.sub( - " ", - self.env["runtime"][ - "ScreenManager" - ].get_window_area_in_text( - self.env["screen"]["new_content_text"] - ), - ) + # Pre-process screen text for comparison - collapse multiple spaces to single space + # This normalization prevents spurious diffs from spacing + # inconsistencies + old_screen_text = self._space_normalize_regex.sub( + " ", + self.env["runtime"][ + "ScreenManager" + ].get_window_area_in_text( + self.env["screen"]["old_content_text"] + ), + ) + new_screen_text = self._space_normalize_regex.sub( + " ", + self.env["runtime"][ + "ScreenManager" + ].get_window_area_in_text( + self.env["screen"]["new_content_text"] + ), + ) - diff = self.differ.compare( - old_screen_text.split("\n"), - new_screen_text.split("\n"), - ) - diff_list = list(diff) + diff = self.differ.compare( + old_screen_text.split("\n"), + new_screen_text.split("\n"), + ) + diff_list = list(diff) # Extract added and removed content from diff results # Output format depends on whether this was detected as typing @@ -453,6 +458,44 @@ class ScreenManager: ) return screen in ignore_screens + def _get_recent_cursor_line_append(self): + try: + if time.time() - self.env["runtime"][ + "InputManager" + ].get_last_input_time() > 0.3: + return None + + old_lines = self.env["screen"]["old_content_text"].split("\n") + new_lines = self.env["screen"]["new_content_text"].split("\n") + cursor_y = self.env["screen"]["new_cursor"]["y"] + if ( + len(old_lines) != len(new_lines) + or cursor_y < 0 + or cursor_y >= len(new_lines) + ): + return None + + changed_lines = [ + index + for index, old_line in enumerate(old_lines) + if index >= len(new_lines) or old_line != new_lines[index] + ] + if changed_lines != [cursor_y]: + return None + + old_line = old_lines[cursor_y] + new_line = new_lines[cursor_y] + old_line = old_line.rstrip() + if not new_line.startswith(old_line): + return None + + appended_text = new_line[len(old_line):].strip() + if appended_text == "" or len(appended_text) > 4: + return None + return appended_text + except Exception: + return None + def is_screen_change(self): if not self.env["screen"]["oldTTY"]: return False diff --git a/tests/unit/test_incoming_command.py b/tests/unit/test_incoming_command.py index e84fec96..b3b2cccc 100644 --- a/tests/unit/test_incoming_command.py +++ b/tests/unit/test_incoming_command.py @@ -88,8 +88,8 @@ class TestIncomingCommand: [ "Status old", "Users old", - "alice: hi", - "bob: hello", + "UserA: hi", + "UserB: hello", "> ", ] ) @@ -97,19 +97,19 @@ class TestIncomingCommand: [ "Status new", "Users new", - "bob: hello", - "carol: test", + "UserB: hello", + "UserC: test", "> ", ] ) env["screen"]["new_delta"] = "\n".join( - ["Status new", "Users new", "carol: test"] + ["Status new", "Users new", "UserC: test"] ) command.run() output_manager.present_text.assert_called_once_with( - "carol: test", interrupt=False, flush=False + "UserC: test", interrupt=False, flush=False ) def test_keeps_header_update_when_no_lower_screen_insert_exists( @@ -120,16 +120,16 @@ class TestIncomingCommand: [ "Status old", "Users old", - "alice: hi", - "bob: hello", + "UserA: hi", + "UserB: hello", ] ) env["screen"]["new_content_text"] = "\n".join( [ "Status new", "Users new", - "alice: hi", - "bob: hello", + "UserA: hi", + "UserB: hello", ] ) env["screen"]["new_delta"] = "\n".join(["Status new", "Users new"]) diff --git a/tests/unit/test_screen_manager_typing.py b/tests/unit/test_screen_manager_typing.py index 21a93674..ea7a02d3 100644 --- a/tests/unit/test_screen_manager_typing.py +++ b/tests/unit/test_screen_manager_typing.py @@ -1,3 +1,4 @@ +import time from unittest.mock import Mock import pytest @@ -18,6 +19,7 @@ def _build_screen_manager(old_text, old_cursor): ), "CursorManager": Mock(is_application_window_set=Mock(return_value=False)), "DebugManager": Mock(write_debug_out=Mock()), + "InputManager": Mock(get_last_input_time=Mock(return_value=0)), "ScreenManager": manager, "SettingsManager": Mock(get_setting_as_bool=Mock(return_value=False)), }, @@ -61,3 +63,38 @@ def test_prompt_repaint_during_typing_keeps_only_typed_delta(): assert env["screen"]["new_delta"] == "h" assert env["screen"]["new_delta_is_typing"] is True + + +@pytest.mark.unit +def test_tui_input_line_append_is_typing_delta(): + manager, env = _build_screen_manager( + "\n".join( + [ + " hi".ljust(20), + "[Username] ".ljust(20), + ] + ), + {"x": 11, "y": 1}, + ) + env["runtime"]["InputManager"].get_last_input_time.return_value = time.time() + + manager.update( + { + "bytes": b"", + "lines": 2, + "columns": 20, + "textCursor": {"x": 12, "y": 1}, + "screen": "pty", + "text": "\n".join( + [ + " hi".ljust(20), + "[Username] l".ljust(20), + ] + ), + "attributes": [], + }, + "onScreenUpdate", + ) + + assert env["screen"]["new_delta"] == "l" + assert env["screen"]["new_delta_is_typing"] is True