diff --git a/src/fenrirscreenreader/commands/commands/speech_history.py b/src/fenrirscreenreader/commands/commands/speech_history.py new file mode 100644 index 00000000..ab877e31 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/speech_history.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +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 _("opens speech history") + + def run(self): + self.env["runtime"]["SpeechHistoryManager"].open_history() + + def set_callback(self, callback): + pass diff --git a/src/fenrirscreenreader/commands/speech-history/speech_history_close.py b/src/fenrirscreenreader/commands/speech-history/speech_history_close.py new file mode 100644 index 00000000..71c58721 --- /dev/null +++ b/src/fenrirscreenreader/commands/speech-history/speech_history_close.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +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 _("closes speech history") + + def run(self): + self.env["runtime"]["SpeechHistoryManager"].close_history() + + def set_callback(self, callback): + pass diff --git a/src/fenrirscreenreader/commands/speech-history/speech_history_copy.py b/src/fenrirscreenreader/commands/speech-history/speech_history_copy.py new file mode 100644 index 00000000..36ee1c16 --- /dev/null +++ b/src/fenrirscreenreader/commands/speech-history/speech_history_copy.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +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 _("copies current speech history item to the clipboard") + + def run(self): + self.env["runtime"]["SpeechHistoryManager"].copy_current_to_clipboard() + + def set_callback(self, callback): + pass diff --git a/src/fenrirscreenreader/commands/speech-history/speech_history_current.py b/src/fenrirscreenreader/commands/speech-history/speech_history_current.py new file mode 100644 index 00000000..a5376a79 --- /dev/null +++ b/src/fenrirscreenreader/commands/speech-history/speech_history_current.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +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 _("speaks current speech history item") + + def run(self): + self.env["runtime"]["SpeechHistoryManager"].present_current() + + def set_callback(self, callback): + pass diff --git a/src/fenrirscreenreader/commands/speech-history/speech_history_next.py b/src/fenrirscreenreader/commands/speech-history/speech_history_next.py new file mode 100644 index 00000000..405fb446 --- /dev/null +++ b/src/fenrirscreenreader/commands/speech-history/speech_history_next.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +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 _("selects the next speech history item") + + def run(self): + self.env["runtime"]["SpeechHistoryManager"].next_entry() + + def set_callback(self, callback): + pass diff --git a/src/fenrirscreenreader/commands/speech-history/speech_history_prev.py b/src/fenrirscreenreader/commands/speech-history/speech_history_prev.py new file mode 100644 index 00000000..9e950611 --- /dev/null +++ b/src/fenrirscreenreader/commands/speech-history/speech_history_prev.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +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 _("selects the previous speech history item") + + def run(self): + self.env["runtime"]["SpeechHistoryManager"].prev_entry() + + def set_callback(self, callback): + pass diff --git a/src/fenrirscreenreader/core/fenrirManager.py b/src/fenrirscreenreader/core/fenrirManager.py index ee272ef8..6ae697f3 100644 --- a/src/fenrirscreenreader/core/fenrirManager.py +++ b/src/fenrirscreenreader/core/fenrirManager.py @@ -103,6 +103,10 @@ class FenrirManager: self.environment["runtime"][ "InputManager" ].clear_event_buffer() + if self.environment["runtime"]["SpeechHistoryManager"].is_active(): + self.environment["runtime"][ + "InputManager" + ].clear_event_buffer() self.detect_shortcut_command() @@ -159,6 +163,14 @@ class FenrirManager: current_command, "vmenu-navigation" ) return + elif self.environment["runtime"]["SpeechHistoryManager"].is_active(): + if self.environment["runtime"]["CommandManager"].command_exists( + current_command, "speech-history" + ): + self.environment["runtime"]["CommandManager"].execute_command( + current_command, "speech-history" + ) + return # default self.environment["runtime"]["CommandManager"].execute_command( @@ -298,12 +310,18 @@ class FenrirManager: if self.command != "": self.singleKeyCommand = True elif ( - self.environment["runtime"]["DiffReviewManager"].is_active() + ( + self.environment["runtime"]["DiffReviewManager"].is_active() + or self.environment["runtime"][ + "SpeechHistoryManager" + ].is_active() + ) and self.command != "" ): - # Diff mode uses non-Fenrir modified bindings (Shift/Ctrl). + # Modal modes use non-Fenrir modified bindings. # Promote resolved shortcuts to executable commands so - # combinations like Shift+H and Ctrl+Right are dispatched. + # combinations like Shift+H, Ctrl+Right, and plain arrows + # are dispatched. self.singleKeyCommand = True if not (self.singleKeyCommand or self.modifierInput): diff --git a/src/fenrirscreenreader/core/generalData.py b/src/fenrirscreenreader/core/generalData.py index 8ef7a1b0..f58ac208 100644 --- a/src/fenrirscreenreader/core/generalData.py +++ b/src/fenrirscreenreader/core/generalData.py @@ -20,6 +20,7 @@ general_data = { "ScreenManager", "InputManager", "OutputManager", + "SpeechHistoryManager", "HelpManager", "MemoryManager", "EventManager", @@ -48,5 +49,6 @@ general_data = { "onSwitchApplicationProfile", "help", "vmenu-navigation", + "speech-history", ], } diff --git a/src/fenrirscreenreader/core/outputManager.py b/src/fenrirscreenreader/core/outputManager.py index 8aa2af4c..7fb55c66 100644 --- a/src/fenrirscreenreader/core/outputManager.py +++ b/src/fenrirscreenreader/core/outputManager.py @@ -75,6 +75,14 @@ class OutputManager: return if (len(text) > 1) and (text.strip(string.whitespace) == ""): return + if self.env["runtime"]["SettingsManager"].get_setting_as_bool( + "speech", "enabled" + ): + speech_history_manager = self.env["runtime"].get( + "SpeechHistoryManager" + ) + if speech_history_manager: + speech_history_manager.add_text(text) is_capital = self._should_announce_capital(text, announce_capital) use_pitch_for_capital = False diff --git a/src/fenrirscreenreader/core/runtimeData.py b/src/fenrirscreenreader/core/runtimeData.py index 13937d0d..980744e2 100644 --- a/src/fenrirscreenreader/core/runtimeData.py +++ b/src/fenrirscreenreader/core/runtimeData.py @@ -16,6 +16,7 @@ runtime_data = { "CommandManager": None, "ScreenManager": None, "OutputManager": None, + "SpeechHistoryManager": None, "DebugManager": None, "SettingsManager": None, "FenrirManager": None, diff --git a/src/fenrirscreenreader/core/settingsData.py b/src/fenrirscreenreader/core/settingsData.py index 79fcefa2..5f738206 100644 --- a/src/fenrirscreenreader/core/settingsData.py +++ b/src/fenrirscreenreader/core/settingsData.py @@ -32,6 +32,7 @@ settings_data = { "hardware_baud_rate": 9600, "auto_read_incoming": True, "read_numbers_as_digits": False, + "history_size": 50, "rapid_update_threshold": 5, "rapid_update_window": 0.3, "batch_flush_interval": 0.5, diff --git a/src/fenrirscreenreader/core/settingsManager.py b/src/fenrirscreenreader/core/settingsManager.py index 0cdb5d78..185da871 100644 --- a/src/fenrirscreenreader/core/settingsManager.py +++ b/src/fenrirscreenreader/core/settingsManager.py @@ -29,6 +29,7 @@ from fenrirscreenreader.core import readAllManager from fenrirscreenreader.core import remoteManager from fenrirscreenreader.core import sayAllManager from fenrirscreenreader.core import screenManager +from fenrirscreenreader.core import speechHistoryManager from fenrirscreenreader.core import tableManager from fenrirscreenreader.core import textManager from fenrirscreenreader.core import vmenuManager @@ -738,6 +739,11 @@ class SettingsManager: environment["runtime"]["OutputManager"] = outputManager.OutputManager() environment["runtime"]["OutputManager"].initialize(environment) + environment["runtime"][ + "SpeechHistoryManager" + ] = speechHistoryManager.SpeechHistoryManager() + environment["runtime"]["SpeechHistoryManager"].initialize(environment) + environment["runtime"]["InputManager"] = inputManager.InputManager() environment["runtime"]["InputManager"].initialize(environment) self.load_keyboard_layout(environment) diff --git a/src/fenrirscreenreader/core/speechHistoryManager.py b/src/fenrirscreenreader/core/speechHistoryManager.py new file mode 100644 index 00000000..5fa82fb9 --- /dev/null +++ b/src/fenrirscreenreader/core/speechHistoryManager.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +from fenrirscreenreader.core.i18n import _ + + +class SpeechHistoryManager: + def __init__(self): + self.env = None + self.history = [] + self.curr_index = -1 + self.active = False + self.bindings_backup = None + self.raw_bindings_backup = None + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + self.set_active(False) + + def is_active(self): + return self.active + + def add_text(self, text): + if self.active: + return False + if not isinstance(text, str): + return False + if text == "": + return False + text_key = self._get_history_key(text) + if text_key == "": + return False + if text_key in [self._get_history_key(item) for item in self.history]: + return False + history_size = self._get_history_size() + if history_size <= 0: + return False + self.history.insert(0, text) + del self.history[history_size:] + if self.curr_index >= len(self.history): + self.curr_index = len(self.history) - 1 + return True + + def open_history(self): + if not self.history: + self.env["runtime"]["OutputManager"].present_text( + _("speech history empty"), interrupt=True + ) + return False + self.curr_index = -1 + self.set_active(True) + self.env["runtime"]["OutputManager"].present_text( + _("Speech history"), interrupt=True + ) + return True + + def close_history(self, announce=True): + if announce: + self.env["runtime"]["OutputManager"].present_text( + _("speech history closed"), interrupt=True + ) + self.set_active(False) + + def next_entry(self): + if not self._has_history(): + return + if self.curr_index == -1: + self.curr_index = 0 + self.present_current() + return + if self.curr_index <= 0: + self.curr_index = 0 + self.env["runtime"]["OutputManager"].present_text( + _("First speech history item"), interrupt=True + ) + self.present_current(interrupt=False) + return + self.curr_index -= 1 + self.present_current() + + def prev_entry(self): + if not self._has_history(): + return + if self.curr_index == -1: + self.curr_index = 0 + self.present_current() + return + if self.curr_index >= len(self.history) - 1: + self.curr_index = len(self.history) - 1 + self.env["runtime"]["OutputManager"].present_text( + _("Last speech history item"), interrupt=True + ) + self.present_current(interrupt=False) + return + self.curr_index += 1 + self.present_current() + + def present_current(self, interrupt=True): + if not self._has_history(): + self.env["runtime"]["OutputManager"].present_text( + _("speech history empty"), interrupt=True + ) + return + self.env["runtime"]["OutputManager"].present_text( + self.history[self.curr_index], interrupt=interrupt + ) + + def copy_current_to_clipboard(self): + if not self._has_history(): + self.close_history() + return + text = self.history[self.curr_index] + self.env["runtime"]["MemoryManager"].add_value_to_first_index( + "clipboardHistory", text + ) + self.env["runtime"]["OutputManager"].present_text( + _("copied to clipboard"), + sound_icon="CopyToClipboard", + interrupt=True, + ) + self.set_active(False) + + def set_active(self, active): + if active == self.active: + return + self.active = active + if self.active: + self._install_bindings() + else: + self._restore_bindings() + + def _has_history(self): + if not self.history: + self.curr_index = -1 + return False + if self.curr_index >= len(self.history): + self.curr_index = len(self.history) - 1 + return True + + def _get_history_size(self): + try: + return self.env["runtime"]["SettingsManager"].get_setting_as_int( + "speech", "history_size" + ) + except Exception: + return 50 + + def _get_history_key(self, text): + return " ".join(text.split()) + + def _install_bindings(self): + self.bindings_backup = self.env["bindings"].copy() + self.raw_bindings_backup = self.env["rawBindings"].copy() + self.env["bindings"] = { + str([1, ["KEY_UP"]]): "SPEECH_HISTORY_PREV", + str([1, ["KEY_DOWN"]]): "SPEECH_HISTORY_NEXT", + str([1, ["KEY_SPACE"]]): "SPEECH_HISTORY_CURRENT", + str([1, ["KEY_ENTER"]]): "SPEECH_HISTORY_COPY", + str([1, ["KEY_KPENTER"]]): "SPEECH_HISTORY_COPY", + str([1, ["KEY_ESC"]]): "SPEECH_HISTORY_CLOSE", + } + self.env["rawBindings"] = { + str([1, ["KEY_UP"]]): [1, ["KEY_UP"]], + str([1, ["KEY_DOWN"]]): [1, ["KEY_DOWN"]], + str([1, ["KEY_SPACE"]]): [1, ["KEY_SPACE"]], + str([1, ["KEY_ENTER"]]): [1, ["KEY_ENTER"]], + str([1, ["KEY_KPENTER"]]): [1, ["KEY_KPENTER"]], + str([1, ["KEY_ESC"]]): [1, ["KEY_ESC"]], + } + + def _restore_bindings(self): + if self.bindings_backup is not None: + self.env["bindings"] = self.bindings_backup + if self.raw_bindings_backup is not None: + self.env["rawBindings"] = self.raw_bindings_backup + self.bindings_backup = None + self.raw_bindings_backup = None diff --git a/tests/unit/test_fenrir_manager_speech_history.py b/tests/unit/test_fenrir_manager_speech_history.py new file mode 100644 index 00000000..a00f9d03 --- /dev/null +++ b/tests/unit/test_fenrir_manager_speech_history.py @@ -0,0 +1,44 @@ +from unittest.mock import Mock + +import pytest + +from fenrirscreenreader.core.eventData import FenrirEventType +from fenrirscreenreader.core.fenrirManager import FenrirManager + + +@pytest.mark.unit +def test_speech_history_plain_key_modal_command_is_dispatched(): + manager = FenrirManager.__new__(FenrirManager) + manager.modifierInput = False + manager.singleKeyCommand = False + manager.command = "" + + event_manager = Mock(put_to_event_queue=Mock()) + input_manager = Mock( + is_key_press=Mock(return_value=False), + no_key_pressed=Mock(return_value=False), + get_curr_shortcut=Mock(return_value=str([1, ["KEY_UP"]])), + get_command_for_shortcut=Mock(return_value="SPEECH_HISTORY_PREV"), + ) + speech_history_manager = Mock(is_active=Mock(return_value=True)) + diff_review_manager = Mock(is_active=Mock(return_value=False)) + + manager.environment = { + "input": { + "key_forward": 0, + "prev_input": ["KEY_UP"], + "curr_input": ["KEY_UP"], + }, + "runtime": { + "InputManager": input_manager, + "EventManager": event_manager, + "DiffReviewManager": diff_review_manager, + "SpeechHistoryManager": speech_history_manager, + }, + } + + manager.detect_shortcut_command() + + event_manager.put_to_event_queue.assert_called_once_with( + FenrirEventType.execute_command, "SPEECH_HISTORY_PREV" + ) diff --git a/tests/unit/test_output_manager.py b/tests/unit/test_output_manager.py index a1660cb8..82947cb7 100644 --- a/tests/unit/test_output_manager.py +++ b/tests/unit/test_output_manager.py @@ -164,6 +164,43 @@ def test_speak_text_drops_speech_when_cancel_holds_driver_lock(): speech_driver.speak.assert_not_called() +@pytest.mark.unit +def test_present_text_records_speech_history_when_enabled(): + output_manager, _sound_driver, speech_driver = build_output_manager() + speech_history_manager = Mock(add_text=Mock()) + output_manager.env["runtime"]["SpeechHistoryManager"] = ( + speech_history_manager + ) + + output_manager.present_text("hello history", interrupt=False) + + speech_history_manager.add_text.assert_called_once_with("hello history") + speech_driver.speak.assert_called_once() + + +@pytest.mark.unit +def test_present_text_does_not_record_when_speech_disabled(): + output_manager, _sound_driver, speech_driver = build_output_manager() + speech_history_manager = Mock(add_text=Mock()) + output_manager.env["runtime"]["SpeechHistoryManager"] = ( + speech_history_manager + ) + + def _get_setting_as_bool(section, setting): + if (section, setting) == ("speech", "enabled"): + return False + return True + + output_manager.env["runtime"][ + "SettingsManager" + ].get_setting_as_bool.side_effect = _get_setting_as_bool + + output_manager.present_text("hello history", interrupt=False) + + speech_history_manager.add_text.assert_not_called() + speech_driver.speak.assert_not_called() + + @pytest.mark.unit def test_key_interrupt_command_uses_nonblocking_interrupt(): module = load_key_interrupt_module() diff --git a/tests/unit/test_speech_history_manager.py b/tests/unit/test_speech_history_manager.py new file mode 100644 index 00000000..18a432d8 --- /dev/null +++ b/tests/unit/test_speech_history_manager.py @@ -0,0 +1,147 @@ +from unittest.mock import Mock + +import pytest + +from fenrirscreenreader.core.speechHistoryManager import SpeechHistoryManager + + +def build_speech_history_manager(history_size=3): + spoken_messages = [] + output_manager = Mock() + + def _capture_message(message, **_kwargs): + spoken_messages.append(message) + + output_manager.present_text.side_effect = _capture_message + settings_manager = Mock() + settings_manager.get_setting_as_int.return_value = history_size + memory_manager = Mock(add_value_to_first_index=Mock()) + env = { + "runtime": { + "OutputManager": output_manager, + "SettingsManager": settings_manager, + "MemoryManager": memory_manager, + }, + "bindings": {"original": "COMMAND"}, + "rawBindings": {"original": [1, ["KEY_FENRIR"]]}, + } + manager = SpeechHistoryManager() + manager.initialize(env) + return manager, env, spoken_messages, memory_manager + + +@pytest.mark.unit +def test_speech_history_keeps_configured_number_of_items(): + manager, _env, _spoken_messages, _memory_manager = ( + build_speech_history_manager(history_size=2) + ) + + assert manager.add_text("one") + assert manager.add_text("two") + assert manager.add_text("three") + + assert manager.history == ["three", "two"] + + +@pytest.mark.unit +def test_speech_history_suppresses_exact_duplicates_until_item_drops(): + manager, _env, _spoken_messages, _memory_manager = ( + build_speech_history_manager(history_size=2) + ) + + assert manager.add_text("hello world") + assert not manager.add_text("hello world") + assert manager.add_text("other") + assert manager.add_text("third") + assert manager.add_text("hello world") + + assert manager.history == ["hello world", "third"] + + +@pytest.mark.unit +def test_speech_history_dedupe_keeps_case_and_suppresses_whitespace_variants(): + manager, _env, _spoken_messages, _memory_manager = ( + build_speech_history_manager() + ) + + assert manager.add_text("hello") + assert manager.add_text("Hello") + assert not manager.add_text("hello ") + assert not manager.add_text("hello ") + + assert manager.history == ["Hello", "hello"] + + +@pytest.mark.unit +def test_open_empty_history_announces_empty_without_modal_bindings(): + manager, env, spoken_messages, _memory_manager = ( + build_speech_history_manager() + ) + + assert not manager.open_history() + + assert not manager.is_active() + assert spoken_messages == ["speech history empty"] + assert env["bindings"] == {"original": "COMMAND"} + + +@pytest.mark.unit +def test_open_history_installs_modal_bindings_and_replay_is_not_recorded(): + manager, env, spoken_messages, _memory_manager = ( + build_speech_history_manager() + ) + manager.add_text("first") + manager.add_text("second") + + assert manager.open_history() + manager.add_text("replayed") + + assert manager.is_active() + assert spoken_messages == ["Speech history"] + assert manager.curr_index == -1 + assert manager.history == ["second", "first"] + assert "original" not in env["bindings"] + assert "original" not in env["rawBindings"] + assert env["bindings"][str([1, ["KEY_UP"]])] == "SPEECH_HISTORY_PREV" + assert env["bindings"][str([1, ["KEY_ENTER"]])] == "SPEECH_HISTORY_COPY" + assert env["bindings"][str([1, ["KEY_ESC"]])] == "SPEECH_HISTORY_CLOSE" + + +@pytest.mark.unit +def test_navigation_moves_between_newer_and_older_items(): + manager, _env, spoken_messages, _memory_manager = ( + build_speech_history_manager() + ) + manager.add_text("oldest") + manager.add_text("middle") + manager.add_text("newest") + manager.open_history() + + manager.prev_entry() + manager.prev_entry() + manager.prev_entry() + manager.next_entry() + + assert spoken_messages[-4:] == ["newest", "middle", "oldest", "middle"] + + +@pytest.mark.unit +def test_copy_current_adds_clipboard_and_restores_bindings(): + manager, env, spoken_messages, memory_manager = ( + build_speech_history_manager() + ) + manager.add_text("first") + manager.add_text("second") + manager.open_history() + manager.prev_entry() + manager.prev_entry() + + manager.copy_current_to_clipboard() + + memory_manager.add_value_to_first_index.assert_called_once_with( + "clipboardHistory", "first" + ) + assert spoken_messages[-1] == "copied to clipboard" + assert not manager.is_active() + assert env["bindings"] == {"original": "COMMAND"} + assert env["rawBindings"] == {"original": [1, ["KEY_FENRIR"]]}