Speech history added, bound to fenrir+control+h.

This commit is contained in:
Storm Dragon
2026-05-20 21:02:56 -04:00
parent 4caef89f6b
commit ac7348895f
16 changed files with 611 additions and 3 deletions
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+21 -3
View File
@@ -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):
@@ -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",
],
}
@@ -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
@@ -16,6 +16,7 @@ runtime_data = {
"CommandManager": None,
"ScreenManager": None,
"OutputManager": None,
"SpeechHistoryManager": None,
"DebugManager": None,
"SettingsManager": None,
"FenrirManager": None,
@@ -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,
@@ -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)
@@ -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
@@ -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"
)
+37
View File
@@ -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()
+147
View File
@@ -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"]]}