diff --git a/src/fenrirscreenreader/commands/onCursorChange/55000-large_insertion_echo.py b/src/fenrirscreenreader/commands/onCursorChange/55000-large_insertion_echo.py new file mode 100644 index 00000000..d5afabef --- /dev/null +++ b/src/fenrirscreenreader/commands/onCursorChange/55000-large_insertion_echo.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +import time + +from fenrirscreenreader.core.i18n import _ + + +class command: + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def get_description(self): + return _("Announces large text insertions at the cursor") + + def run(self): + if self.env["runtime"]["ScreenManager"].is_screen_change(): + return + if not self.env["runtime"]["ScreenManager"].is_delta(): + return + + x_move = ( + self.env["screen"]["new_cursor"]["x"] + - self.env["screen"]["old_cursor"]["x"] + ) + if x_move < 5: + return + if self._is_recent_tab_input(): + return + + delta_text = self.env["screen"]["new_delta"] + if not self._matches_cursor_insert(x_move, delta_text): + return + + curr_delta = delta_text + if ( + len(curr_delta.strip()) != len(curr_delta) + and curr_delta.strip() != "" + ): + curr_delta = curr_delta.strip() + if not curr_delta: + return + + do_interrupt = True + if self.env["runtime"]["SettingsManager"].get_setting_as_bool( + "speech", "auto_read_incoming" + ): + do_interrupt = False + + self.env["runtime"]["OutputManager"].present_text( + curr_delta, + interrupt=do_interrupt, + announce_capital=True, + flush=False, + ) + + def _is_recent_tab_input(self): + input_manager = self.env["runtime"].get("InputManager") + if not input_manager: + return False + if input_manager.get_last_deepest_input() in [["KEY_TAB"]]: + return True + + last_event = input_manager.get_last_event() + if not last_event or last_event.get("event_name") != "KEY_TAB": + return False + try: + return time.time() - input_manager.get_last_input_time() <= 0.5 + except Exception: + return False + + def _matches_cursor_insert(self, x_move, delta_text): + if not delta_text or "\n" in delta_text: + return False + + delta_len = len(delta_text) + if x_move == delta_len: + return True + if abs(x_move - delta_len) <= 2: + return True + return delta_len > 10 and abs(x_move - delta_len) <= (delta_len * 0.2) + + def set_callback(self, callback): + pass diff --git a/src/fenrirscreenreader/core/tabCompletionManager.py b/src/fenrirscreenreader/core/tabCompletionManager.py index c81b0a72..f8d74e4f 100644 --- a/src/fenrirscreenreader/core/tabCompletionManager.py +++ b/src/fenrirscreenreader/core/tabCompletionManager.py @@ -41,7 +41,9 @@ class TabCompletionManager: state = self.env["commandBuffer"]["tabCompletion"] pending = state.get("pending") if not pending: - return "" + pending = self._build_pending_from_recent_tab_update() + if not pending: + return "" if time.time() - pending["timestamp"] > self.timeout: state["pending"] = None @@ -60,6 +62,33 @@ class TabCompletionManager: state["lastProcessedTime"] = time.time() return spoken_text + def _build_pending_from_recent_tab_update(self): + if not self._recent_tab_input(): + return None + return { + "timestamp": time.time(), + "cursor": self.env["screen"]["old_cursor"].copy(), + "content": self.env["screen"]["old_content_text"], + "screen": self.env["screen"]["newTTY"], + } + + def _recent_tab_input(self): + input_manager = self.env["runtime"]["InputManager"] + if input_manager.get_last_deepest_input() in [["KEY_TAB"]]: + return True + + last_event = input_manager.get_last_event() + if not last_event or last_event.get("event_name") != "KEY_TAB": + return False + + try: + return ( + time.time() - input_manager.get_last_input_time() + <= self.timeout + ) + except Exception: + return False + def _last_event_is_tab_press(self): input_manager = self.env["runtime"]["InputManager"] last_event = input_manager.get_last_event() diff --git a/tests/unit/test_tab_completion_manager.py b/tests/unit/test_tab_completion_manager.py index d6a105cf..b5ed1c56 100644 --- a/tests/unit/test_tab_completion_manager.py +++ b/tests/unit/test_tab_completion_manager.py @@ -1,4 +1,6 @@ +import importlib.util import time +from pathlib import Path from unittest.mock import Mock import pytest @@ -14,6 +16,8 @@ def _build_env(old_text="", cursor=None, screen="pty"): "event_name": "KEY_TAB", "event_state": 1, } + input_manager.get_last_deepest_input.return_value = [["KEY_TAB"]] + input_manager.get_last_input_time.return_value = time.time() env = { "runtime": { "InputManager": input_manager, @@ -32,6 +36,23 @@ def _build_env(old_text="", cursor=None, screen="pty"): return manager, env, input_manager +def _load_large_insertion_module(): + module_path = ( + Path(__file__).resolve().parents[2] + / "src" + / "fenrirscreenreader" + / "commands" + / "onCursorChange" + / "55000-large_insertion_echo.py" + ) + spec = importlib.util.spec_from_file_location( + "fenrir_large_insertion_echo", module_path + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def _set_screen_update(env, text, cursor, delta="", typing=False): env["screen"]["new_content_text"] = text env["screen"]["new_cursor"] = cursor.copy() @@ -124,7 +145,108 @@ def test_non_tab_key_does_not_capture(): "event_name": "KEY_A", "event_state": 1, } + input_manager.get_last_deepest_input.return_value = [["KEY_A"]] manager.capture_if_tab() assert env["commandBuffer"]["tabCompletion"]["pending"] is None + + +@pytest.mark.unit +def test_recent_tab_screen_update_works_without_key_input_snapshot(): + manager, env, input_manager = _build_env( + "cd Documents/".ljust(20), {"x": 13, "y": 0} + ) + input_manager.get_last_event.return_value = { + "event_name": "KEY_TAB", + "event_state": 0, + } + env["screen"]["old_content_text"] = "cd Do".ljust(20) + env["screen"]["old_cursor"] = {"x": 5, "y": 0} + env["commandBuffer"]["tabCompletion"]["pending"] = None + _set_screen_update( + env, + "cd Documents/".ljust(20), + {"x": 13, "y": 0}, + delta="cuments/", + typing=True, + ) + + assert manager.process_update() == "cuments/" + + +@pytest.mark.unit +def test_large_insertion_echo_speaks_pasted_cursor_text(): + large_insertion_module = _load_large_insertion_module() + output_manager = Mock(present_text=Mock()) + settings_manager = Mock() + settings_manager.get_setting_as_bool.return_value = False + input_manager = Mock() + input_manager.get_last_deepest_input.return_value = [] + input_manager.get_last_event.return_value = None + screen_manager = Mock( + is_screen_change=Mock(return_value=False), + is_delta=Mock(return_value=True), + ) + env = { + "runtime": { + "InputManager": input_manager, + "OutputManager": output_manager, + "ScreenManager": screen_manager, + "SettingsManager": settings_manager, + }, + "screen": { + "old_cursor": {"x": 2, "y": 0}, + "new_cursor": {"x": 13, "y": 0}, + "new_delta": "hello world", + }, + } + command = large_insertion_module.command() + command.initialize(env) + + command.run() + + output_manager.present_text.assert_called_once_with( + "hello world", + interrupt=True, + announce_capital=True, + flush=False, + ) + + +@pytest.mark.unit +def test_large_insertion_echo_defers_recent_tab_to_tab_completion(): + large_insertion_module = _load_large_insertion_module() + output_manager = Mock(present_text=Mock()) + settings_manager = Mock() + settings_manager.get_setting_as_bool.return_value = False + input_manager = Mock() + input_manager.get_last_deepest_input.return_value = [["KEY_TAB"]] + input_manager.get_last_event.return_value = { + "event_name": "KEY_TAB", + "event_state": 1, + } + input_manager.get_last_input_time.return_value = time.time() + screen_manager = Mock( + is_screen_change=Mock(return_value=False), + is_delta=Mock(return_value=True), + ) + env = { + "runtime": { + "InputManager": input_manager, + "OutputManager": output_manager, + "ScreenManager": screen_manager, + "SettingsManager": settings_manager, + }, + "screen": { + "old_cursor": {"x": 5, "y": 0}, + "new_cursor": {"x": 13, "y": 0}, + "new_delta": "cuments/", + }, + } + command = large_insertion_module.command() + command.initialize(env) + + command.run() + + output_manager.present_text.assert_not_called()