Release candidate.

This commit is contained in:
Storm Dragon
2026-08-15 17:55:05 -04:00
52 changed files with 3857 additions and 1452 deletions
+3 -7
View File
@@ -15,15 +15,12 @@ KEY_FENRIR,KEY_ALT,KEY_2=present_last_line
KEY_KP5=review_curr_word
KEY_KP4=review_prev_word
KEY_KP6=review_next_word
KEY_FENRIR,KEY_SHIFT,KEY_KP5=review_curr_word_phonetic
KEY_FENRIR,KEY_SHIFT,KEY_KP4=review_prev_word_phonetic
KEY_FENRIR,KEY_SHIFT,KEY_KP6=review_next_word_phonetic
2,KEY_KP5=review_curr_word_spell
3,KEY_KP5=review_curr_word_phonetic
KEY_KP2=review_curr_char
KEY_KP1=review_prev_char
KEY_KP3=review_next_char
KEY_FENRIR,KEY_SHIFT,KEY_KP2=review_curr_char_phonetic
KEY_FENRIR,KEY_SHIFT,KEY_KP1=review_prev_char_phonetic
KEY_FENRIR,KEY_SHIFT,KEY_KP3=review_next_char_phonetic
2,KEY_KP2=review_curr_char_phonetic
KEY_FENRIR,KEY_CTRL,KEY_KP8=review_up
KEY_FENRIR,KEY_CTRL,KEY_KP2=review_down
KEY_FENRIR,KEY_KPDOT=exit_review
@@ -133,4 +130,3 @@ KEY_FENRIR,KEY_F8=export_clipboard_to_x
KEY_FENRIR,KEY_CTRL,KEY_DOWN=read_all_by_line
KEY_FENRIR,KEY_CTRL,KEY_PAGEDOWN=read_all_by_page
KEY_FENRIR,KEY_SHIFT,KEY_V=announce_fenrir_version
KEY_FENRIR,KEY_LEFTCTRL,KEY_F4=cycle_keyboard_layout
+3 -7
View File
@@ -15,15 +15,12 @@ KEY_FENRIR,KEY_ALT,KEY_2=present_last_line
KEY_FENRIR,KEY_K=review_curr_word
KEY_FENRIR,KEY_J=review_prev_word
KEY_FENRIR,KEY_L=review_next_word
KEY_FENRIR,KEY_ALT,KEY_K=review_curr_word_phonetic
KEY_FENRIR,KEY_ALT,KEY_J=review_prev_word_phonetic
KEY_FENRIR,KEY_ALT,KEY_L=review_next_word_phonetic
2,KEY_FENRIR,KEY_K=review_curr_word_spell
3,KEY_FENRIR,KEY_K=review_curr_word_phonetic
KEY_FENRIR,KEY_COMMA=review_curr_char
KEY_FENRIR,KEY_M=review_prev_char
KEY_FENRIR,KEY_DOT=review_next_char
KEY_FENRIR,KEY_ALT,KEY_COMMA=curr_char_phonetic
KEY_FENRIR,KEY_ALT,KEY_M=prev_char_phonetic
KEY_FENRIR,KEY_ALT,KEY_DOT=next_char_phonetic
2,KEY_FENRIR,KEY_COMMA=review_curr_char_phonetic
KEY_FENRIR,KEY_CTRL,KEY_I=review_up
KEY_FENRIR,KEY_CTRL,KEY_COMMA=review_down
KEY_FENRIR,KEY_SLASH=exit_review
@@ -133,4 +130,3 @@ KEY_FENRIR,KEY_F8=export_clipboard_to_x
KEY_FENRIR,KEY_CTRL,KEY_DOWN=read_all_by_line
KEY_FENRIR,KEY_CTRL,KEY_PAGEDOWN=read_all_by_page
KEY_FENRIR,KEY_SHIFT,KEY_V=announce_fenrir_version
KEY_FENRIR,KEY_LEFTCTRL,KEY_F4=cycle_keyboard_layout
@@ -13,6 +13,8 @@ from fenrirscreenreader.core.i18n import _
class command:
help_visible = False
def __init__(self):
pass
@@ -34,6 +34,7 @@ class command:
module = self.env["commandBuffer"]["lastTestedModule"]
voice = self.env["commandBuffer"]["lastTestedVoice"]
language = self.env["commandBuffer"].get("lastTestedLanguage")
self.env["runtime"]["OutputManager"].present_text(
f"Applying {voice} from {module}", interrupt=True
@@ -46,6 +47,7 @@ class command:
old_driver = SettingsManager.get_setting("speech", "driver")
old_module = SettingsManager.get_setting("speech", "module")
old_voice = SettingsManager.get_setting("speech", "voice")
old_language = SettingsManager.get_setting("speech", "language")
try:
# Apply new settings to runtime only (use set_setting to update
@@ -55,6 +57,10 @@ class command:
)
SettingsManager.set_setting("speech", "module", module)
SettingsManager.set_setting("speech", "voice", voice)
if language:
SettingsManager.set_setting(
"speech", "language", language
)
# Apply to speech driver instance directly
if "SpeechDriver" in self.env["runtime"]:
@@ -62,6 +68,8 @@ class command:
# Set the module and voice on the driver instance
SpeechDriver.set_module(module)
if language:
SpeechDriver.set_language(language)
SpeechDriver.set_voice(voice)
self.env["runtime"]["OutputManager"].present_text(
@@ -77,6 +85,9 @@ class command:
SettingsManager.set_setting("speech", "driver", old_driver)
SettingsManager.set_setting("speech", "module", old_module)
SettingsManager.set_setting("speech", "voice", old_voice)
SettingsManager.set_setting(
"speech", "language", old_language
)
self.env["runtime"]["OutputManager"].present_text(
f"Failed to apply voice, reverted: {str(e)}",
@@ -1,105 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import os
from fenrirscreenreader.core import debug
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 _("cycles between available keyboard layouts")
def get_available_layouts(self):
"""Get list of available keyboard layout files"""
layouts = []
# Check standard locations for keyboard layouts
settings_root = "/etc/fenrirscreenreader/"
if not os.path.exists(settings_root):
# Fallback to source directory
import fenrirscreenreader
fenrir_path = os.path.dirname(fenrirscreenreader.__file__)
settings_root = fenrir_path + "/../../config/"
keyboard_path = settings_root + "keyboard/"
if os.path.exists(keyboard_path):
for file in os.listdir(keyboard_path):
if (
file.endswith(".conf")
and not file.startswith("__")
and not file.lower().startswith("pty")
):
layout_name = file.replace(".conf", "")
if layout_name not in layouts:
layouts.append(layout_name)
# Ensure we have at least basic layouts
if not layouts:
layouts = ["desktop", "laptop"]
else:
layouts.sort()
return layouts
def run(self):
current_layout = self.env["runtime"]["SettingsManager"].get_setting(
"keyboard", "keyboard_layout"
)
# Extract layout name from full path if needed
if "/" in current_layout:
current_layout = os.path.basename(current_layout).replace(
".conf", ""
)
# Get available layouts
available_layouts = self.get_available_layouts()
# Find next layout in cycle
try:
current_index = available_layouts.index(current_layout)
next_index = (current_index + 1) % len(available_layouts)
except ValueError:
# If current layout not found, start from beginning
next_index = 0
next_layout = available_layouts[next_index]
# Update setting and reload shortcuts
self.env["runtime"]["SettingsManager"].set_setting(
"keyboard", "keyboard_layout", next_layout
)
# Reload shortcuts with new layout
try:
self.env["runtime"]["InputManager"].reload_shortcuts()
self.env["runtime"]["OutputManager"].present_text(
_("Switched to {} keyboard layout").format(next_layout),
interrupt=True,
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"Error reloading shortcuts: " + str(e), debug.DebugLevel.ERROR
)
self.env["runtime"]["OutputManager"].present_text(
_("Error switching keyboard layout"), interrupt=True
)
def set_callback(self, callback):
pass
@@ -4,30 +4,35 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import _thread
from fenrirscreenreader.core import debug
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils import x_clipboard
def write_clipboard(clipboard):
"""Write the graphical clipboard without accessing Fenrir state."""
return x_clipboard.write_text(clipboard, scan_displays=True)
class command:
def __init__(self):
pass
self._task_ids = set()
def initialize(self, environment, script_path=""):
self.env = environment
self.script_path = script_path
def shutdown(self):
pass
task_manager = self.env["runtime"].get("BackgroundTaskManager")
if task_manager:
for task_id in self._task_ids:
task_manager.cancel_task(task_id)
self._task_ids.clear()
def get_description(self):
return _("Export current fenrir clipboard to X or GUI clipboard")
def run(self):
_thread.start_new_thread(self._thread_run, ())
def _thread_run(self):
try:
# Check if clipboard is empty
if self.env["runtime"]["MemoryManager"].is_index_list_empty(
@@ -43,36 +48,43 @@ class command:
"MemoryManager"
].get_index_list_element("clipboardHistory")
try:
success = x_clipboard.write_text(
clipboard, scan_displays=True
)
except Exception:
success = False
# Notify the user of the result
if success:
sync_manager = self.env["runtime"].get(
"ClipboardSyncManager"
)
if sync_manager:
sync_manager.mark_written_to_x(clipboard)
self.env["runtime"]["OutputManager"].present_text(
_("exported to the X session."), interrupt=True
)
else:
self.env["runtime"]["OutputManager"].present_text(
_(
"failed to export to X clipboard. No available display "
"found."
),
interrupt=True,
)
task_id = self.env["runtime"][
"BackgroundTaskManager"
].submit_task(
write_clipboard,
lambda result: self._handle_result(clipboard, result),
clipboard,
)
if task_id is not None:
self._task_ids.add(task_id)
except Exception as e:
self.env["runtime"]["OutputManager"].present_text(
str(e), sound_icon="", interrupt=False
)
def _handle_result(self, clipboard, result):
self._task_ids.discard(result.get("task_id"))
if result.get("succeeded") and result.get("value"):
sync_manager = self.env["runtime"].get("ClipboardSyncManager")
if sync_manager:
sync_manager.mark_written_to_x(clipboard)
self.env["runtime"]["OutputManager"].present_text(
_("exported to the X session."), interrupt=True
)
return
if not result.get("succeeded") and result.get("error"):
self.env["runtime"]["DebugManager"].write_debug_out(
"Clipboard export failed: " + result["error"],
debug.DebugLevel.ERROR,
)
self.env["runtime"]["OutputManager"].present_text(
_(
"failed to export to X clipboard. No available display found."
),
interrupt=True,
)
def set_callback(self, callback):
pass
@@ -4,60 +4,68 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import _thread
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils import x_clipboard
def read_clipboard():
"""Read the graphical clipboard without accessing Fenrir state."""
return x_clipboard.read_text(scan_displays=True)
class command:
def __init__(self):
pass
self._task_ids = set()
def initialize(self, environment, script_path=""):
self.env = environment
self.script_path = script_path
def shutdown(self):
pass
task_manager = self.env["runtime"].get("BackgroundTaskManager")
if task_manager:
for task_id in self._task_ids:
task_manager.cancel_task(task_id)
self._task_ids.clear()
def get_description(self):
return _("imports the graphical clipboard to Fenrir's clipboard")
def run(self):
_thread.start_new_thread(self._thread_run, ())
task_id = self.env["runtime"]["BackgroundTaskManager"].submit_task(
read_clipboard, self._handle_result
)
if task_id is not None:
self._task_ids.add(task_id)
def _thread_run(self):
try:
try:
clipboard_content = x_clipboard.read_text(
scan_displays=True
)
except Exception:
clipboard_content = None
# Process the clipboard content if we found any
if clipboard_content and isinstance(clipboard_content, str):
self.env["runtime"]["MemoryManager"].add_value_to_first_index(
"clipboardHistory", clipboard_content
)
self.env["runtime"]["OutputManager"].present_text(
"Import to Clipboard",
sound_icon="CopyToClipboard",
interrupt=True,
)
self.env["runtime"]["OutputManager"].present_text(
clipboard_content, sound_icon="", interrupt=False
)
else:
self.env["runtime"]["OutputManager"].present_text(
"No text found in clipboard or no accessible display",
interrupt=True,
)
except Exception as e:
def _handle_result(self, result):
self._task_ids.discard(result.get("task_id"))
clipboard_content = result.get("value")
if not result.get("succeeded"):
self.env["runtime"]["OutputManager"].present_text(
str(e), sound_icon="", interrupt=False
result.get("error", "Clipboard import failed"),
sound_icon="",
interrupt=False,
)
return
if not clipboard_content or not isinstance(clipboard_content, str):
self.env["runtime"]["OutputManager"].present_text(
"No text found in clipboard or no accessible display",
interrupt=True,
)
return
self.env["runtime"]["MemoryManager"].add_value_to_first_index(
"clipboardHistory", clipboard_content
)
self.env["runtime"]["OutputManager"].present_text(
"Import to Clipboard",
sound_icon="CopyToClipboard",
interrupt=True,
)
self.env["runtime"]["OutputManager"].present_text(
clipboard_content, sound_icon="", interrupt=False
)
def set_callback(self, callback):
pass
@@ -38,7 +38,7 @@ class command:
self.env["screen"]["new_content_text"],
)
if curr_word.isspace():
if not curr_word or curr_word.isspace():
self.env["runtime"]["OutputManager"].present_text(
_("blank"), interrupt=True, flush=False
)
@@ -20,7 +20,7 @@ class command:
pass
def get_description(self):
return _("Phonetically spells the next word and moves review to it")
return _("Spells the current word")
def run(self):
self.env["runtime"][
@@ -29,30 +29,28 @@ class command:
(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
next_word,
curr_word,
end_of_screen,
line_break,
) = word_utils.get_next_word(
) = word_utils.get_current_word(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
self.env["screen"]["new_content_text"],
)
if next_word.isspace():
if not curr_word or curr_word.isspace():
self.env["runtime"]["OutputManager"].present_text(
_("blank"), interrupt=True, flush=False
)
else:
first_sequence = True
for c in next_word:
curr_char = char_utils.get_phonetic(c)
self.env["runtime"]["OutputManager"].present_text(
for index, curr_char in enumerate(curr_word):
char_utils.present_char_for_review(
self.env,
curr_char,
interrupt=first_sequence,
interrupt=index == 0,
announce_capital=True,
flush=False,
)
first_sequence = False
if end_of_screen:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"review", "end_of_screen"
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils import char_utils
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _(
"phonetically presents the next character and set review to it"
)
def run(self):
self.env["runtime"][
"CursorManager"
].enter_review_mode_curr_text_cursor()
(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
next_char,
end_of_screen,
line_break,
) = char_utils.get_next_char(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
self.env["screen"]["new_content_text"],
)
next_char = char_utils.get_phonetic(next_char)
self.env["runtime"]["OutputManager"].present_text(
next_char, interrupt=True, announce_capital=True, flush=False
)
if end_of_screen:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"review", "end_of_screen"
):
self.env["runtime"]["OutputManager"].present_text(
_("end of screen"),
interrupt=True,
sound_icon="EndOfScreen",
)
if line_break:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"review", "line_break"
):
self.env["runtime"]["OutputManager"].present_text(
_("line break"), interrupt=False, sound_icon="EndOfLine"
)
def set_callback(self, callback):
pass
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils import char_utils
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _(
"phonetically presents the previous character and set review to it"
)
def run(self):
self.env["runtime"][
"CursorManager"
].enter_review_mode_curr_text_cursor()
(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
prev_char,
end_of_screen,
line_break,
) = char_utils.get_prev_char(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
self.env["screen"]["new_content_text"],
)
prev_char = char_utils.get_phonetic(prev_char)
self.env["runtime"]["OutputManager"].present_text(
prev_char, interrupt=True, announce_capital=True, flush=False
)
if end_of_screen:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"review", "end_of_screen"
):
self.env["runtime"]["OutputManager"].present_text(
_("start of screen"),
interrupt=True,
sound_icon="StartOfScreen",
)
if line_break:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"review", "line_break"
):
self.env["runtime"]["OutputManager"].present_text(
_("line break"), interrupt=False, sound_icon="EndOfLine"
)
def set_callback(self, callback):
pass
@@ -1,76 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils import char_utils
from fenrirscreenreader.utils import word_utils
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _(
"Phonetically spells the previous word and moves review to it"
)
def run(self):
self.env["runtime"][
"CursorManager"
].enter_review_mode_curr_text_cursor()
(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
prev_word,
end_of_screen,
line_break,
) = word_utils.get_prev_word(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
self.env["screen"]["new_content_text"],
)
if prev_word.isspace():
self.env["runtime"]["OutputManager"].present_text(
_("blank"), interrupt=True, flush=False
)
else:
first_sequence = True
for c in prev_word:
curr_char = char_utils.get_phonetic(c)
self.env["runtime"]["OutputManager"].present_text(
curr_char,
interrupt=first_sequence,
announce_capital=True,
flush=False,
)
first_sequence = False
if end_of_screen:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"review", "end_of_screen"
):
self.env["runtime"]["OutputManager"].present_text(
_("start of screen"),
interrupt=True,
sound_icon="StartOfScreen",
)
if line_break:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"review", "line_break"
):
self.env["runtime"]["OutputManager"].present_text(
_("line break"), interrupt=False, sound_icon="EndOfLine"
)
def set_callback(self, callback):
pass
@@ -4,24 +4,42 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import _thread
import os
from subprocess import PIPE
from subprocess import Popen
import subprocess
from fenrirscreenreader.core.i18n import _
def run_script(script_path, current_user):
"""Run an external command without accessing Fenrir's shared state."""
process = subprocess.Popen(
[script_path, current_user],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
stdout, stderr = process.communicate()
return {
"return_code": process.returncode,
"stdout": stdout,
"stderr": stderr,
}
class command:
def __init__(self):
pass
self._task_ids = set()
def initialize(self, environment, script_path=""):
self.env = environment
self.script_path = script_path
def shutdown(self):
pass
task_manager = self.env["runtime"].get("BackgroundTaskManager")
if task_manager:
for task_id in self._task_ids:
task_manager.cancel_task(task_id)
self._task_ids.clear()
def get_description(self):
return _("script: {0} fullpath: {1}").format(
@@ -48,30 +66,39 @@ class command:
interrupt=False,
)
return
_thread.start_new_thread(self._thread_run, ())
def _thread_run(self):
try:
p = Popen(
[self.script_path, self.env["general"]["curr_user"]],
stdout=PIPE,
stderr=PIPE,
)
stdout, stderr = p.communicate()
stdout = stdout.decode("utf-8")
stderr = stderr.decode("utf-8")
self.env["runtime"]["OutputManager"].interrupt_output()
if stderr != "":
self.env["runtime"]["OutputManager"].present_text(
str(stderr), sound_icon="", interrupt=False
)
if stdout != "":
self.env["runtime"]["OutputManager"].present_text(
str(stdout), sound_icon="", interrupt=False
)
except Exception as e:
task_id = self.env["runtime"][
"BackgroundTaskManager"
].submit_external_task(
run_script,
self._handle_result,
self.script_path,
self.env["general"]["curr_user"],
)
if task_id is not None:
self._task_ids.add(task_id)
def _handle_result(self, result):
self._task_ids.discard(result.get("task_id"))
if not result.get("succeeded"):
self.env["runtime"]["OutputManager"].present_text(
str(e), sound_icon="", interrupt=False
result.get("error", _("Script failed")),
sound_icon="",
interrupt=False,
)
return
output = result.get("value") or {}
stderr = output.get("stderr", "")
stdout = output.get("stdout", "")
self.env["runtime"]["OutputManager"].interrupt_output()
if stderr:
self.env["runtime"]["OutputManager"].present_text(
stderr, sound_icon="", interrupt=False
)
if stdout:
self.env["runtime"]["OutputManager"].present_text(
stdout, sound_icon="", interrupt=False
)
def set_callback(self, callback):
@@ -19,19 +19,47 @@ class command:
pass
def get_description(self):
self.env["runtime"]["HelpManager"].toggle_tutorial_mode()
return _(
"Exiting tutorial mode. To enter tutorial mode again press Fenrir+f1"
)
return _("enter or leave tutorial mode")
def run(self):
self.env["runtime"]["HelpManager"].toggle_tutorial_mode()
if self.env["runtime"]["HelpManager"].is_tutorial_mode():
help_manager = self.env["runtime"]["HelpManager"]
was_active = help_manager.is_tutorial_mode()
help_manager.toggle_tutorial_mode()
is_active = help_manager.is_tutorial_mode()
if was_active and is_active:
self.env["runtime"]["OutputManager"].present_text(
_(
"Entering tutorial mode. In this mode commands are described but not "
"executed. You can move through the list of commands with the up and "
"down arrow keys. To Exit tutorial mode press Fenrir+f1."
"Unable to exit tutorial mode because exclusive keyboard "
"capture could not be released. Press Fenrir+F1 or Escape "
"to try again."
),
interrupt=True,
)
elif is_active:
self.env["runtime"]["OutputManager"].present_text(
_(
"Entering tutorial mode. In this mode commands are "
"described but not executed. Use up and down to browse "
"actions and left and right to switch between Active "
"actions, Unbound actions, and Plugins. Press Space to "
"repeat the current item. To exit tutorial mode press "
"Fenrir+F1 or Escape. Active actions."
),
interrupt=True,
)
if self.env["runtime"]["HelpManager"].is_capture_degraded():
self.env["runtime"]["OutputManager"].present_text(
_(
"Warning: full keyboard capture is unavailable. "
"Unbound keys may reach the active application."
),
interrupt=False,
)
else:
self.env["runtime"]["OutputManager"].present_text(
_(
"Exiting tutorial mode. To enter tutorial mode again "
"press Fenrir+F1"
),
interrupt=True,
)
@@ -19,7 +19,6 @@ class command:
pass
def get_description(self):
self.env["runtime"]["VmenuManager"].toggle_vmenu_mode()
return _("Entering or Leaving v menu mode.")
def run(self):
@@ -1,19 +1,20 @@
#!/usr/bin/env python3
import subprocess
import time
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
class command:
def __init__(self):
pass
self._request_generation = 0
self._loading = False
def initialize(self, environment):
self.env = environment
self.testMessage = "This is a voice test. The quick brown fox jumps over the lazy dog."
self.test_message = (
"This is a voice test. The quick brown fox jumps over the lazy dog."
)
self.modules = []
self.voices = []
self.module_index = 0
@@ -23,7 +24,9 @@ class command:
self.lastAnnounceTime = 0
def shutdown(self):
pass
self._request_generation += 1
self._loading = False
self._leave_voice_browser(False)
def get_description(self):
return "Interactive voice browser with arrow key navigation"
@@ -37,34 +40,59 @@ class command:
"Starting voice browser", interrupt=True
)
# Load modules
self.modules = self.get_speechd_modules()
if not self.modules:
if self._loading:
return
self._loading = True
self._request_generation += 1
generation = self._request_generation
self.env["runtime"]["SpeechDiscoveryManager"].request_modules(
lambda modules, error: self._modules_ready(
generation, modules, error
)
)
def _modules_ready(self, generation, modules, error):
if generation != self._request_generation:
return
if error or not modules:
self._loading = False
self.env["runtime"]["OutputManager"].present_text(
"No speech modules found", interrupt=True
)
self.env["runtime"]["OutputManager"].play_sound("Error")
return
# Set current module
self.modules = modules
current_module = self.env["runtime"]["SettingsManager"].get_setting(
"speech", "module"
)
if current_module and current_module in self.modules:
self.module_index = self.modules.index(current_module)
self._request_current_module_voices(generation, True)
# Load voices
self.load_voices_for_current_module()
def _request_current_module_voices(self, generation, enter_browser=False):
module = self.modules[self.module_index]
self.env["runtime"]["OutputManager"].present_text(
f"Loading voices for {module}", interrupt=True
)
self.env["runtime"]["SpeechDiscoveryManager"].request_voices(
module,
lambda _module, voices, error: self._voices_ready(
generation, voices, error, enter_browser
),
)
# Set current voice
def _voices_ready(self, generation, voices, error, enter_browser):
if generation != self._request_generation:
return
self._loading = False
self.voices = voices if not error else []
self.voice_index = 0
current_voice = self.env["runtime"]["SettingsManager"].get_setting(
"speech", "voice"
)
if current_voice and current_voice in self.voices:
self.voice_index = self.voices.index(current_voice)
# Enter browser mode
self.enter_voice_browser()
if enter_browser:
self.enter_voice_browser()
self.announce_current_selection()
def enter_voice_browser(self):
@@ -113,6 +141,9 @@ class command:
def exit_voice_browser(self):
"""Exit voice browser and restore normal key bindings"""
self._leave_voice_browser(True)
def _leave_voice_browser(self, announce):
if not self.browserActive:
return
@@ -125,16 +156,10 @@ class command:
if "voiceBrowserInstance" in self.env["runtime"]:
del self.env["runtime"]["voiceBrowserInstance"]
self.env["runtime"]["OutputManager"].present_text(
"Voice browser exited", interrupt=True
)
def load_voices_for_current_module(self):
"""Load voices for current module"""
if self.module_index < len(self.modules):
module = self.modules[self.module_index]
self.voices = self.get_module_voices(module)
self.voice_index = 0 # Reset to first voice when changing modules
if announce:
self.env["runtime"]["OutputManager"].present_text(
"Voice browser exited", interrupt=True
)
def announce_current_selection(self):
"""Announce current module and voice"""
@@ -149,7 +174,7 @@ class command:
module = self.modules[self.module_index]
if self.voices and self.voice_index < len(self.voices):
voice = self.voices[self.voice_index]
voice = self.voices[self.voice_index].split("|", 1)[0]
self.env["runtime"]["OutputManager"].present_text(
f"{module}: {voice} ({self.voice_index + 1}/{len(self.voices)})",
interrupt=True,
@@ -174,16 +199,19 @@ class command:
self.announce_current_selection()
def next_module(self):
"""Move to next module"""
self.module_index = (self.module_index + 1) % len(self.modules)
self.load_voices_for_current_module()
self.announce_current_selection()
self._change_module(1)
def prev_module(self):
"""Move to previous module"""
self.module_index = (self.module_index - 1) % len(self.modules)
self.load_voices_for_current_module()
self.announce_current_selection()
self._change_module(-1)
def _change_module(self, offset):
if self._loading or not self.modules:
return
self._loading = True
self._request_generation += 1
generation = self._request_generation
self.module_index = (self.module_index + offset) % len(self.modules)
self._request_current_module_voices(generation)
def test_voice(self):
"""Test current voice"""
@@ -194,15 +222,38 @@ class command:
return
module = self.modules[self.module_index]
voice = self.voices[self.voice_index]
encoded_voice = self.voices[self.voice_index]
voice_name = encoded_voice.split("|", 1)[0]
self.env["runtime"]["OutputManager"].present_text(
"Testing...", interrupt=True
)
if self.preview_voice(module, voice):
# Store for apply command
self._request_generation += 1
generation = self._request_generation
self._loading = True
self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test(
module,
voice_name,
self.test_message,
lambda succeeded, error: self._voice_test_ready(
generation, module, encoded_voice, succeeded, error
),
)
def _voice_test_ready(
self, generation, module, voice, succeeded, _error
):
if generation != self._request_generation:
return
self._loading = False
if succeeded:
self.env["commandBuffer"]["lastTestedModule"] = module
self.env["commandBuffer"]["lastTestedVoice"] = voice
voice_name, separator, language = voice.partition("|")
self.env["commandBuffer"]["lastTestedVoice"] = voice_name
if separator:
self.env["commandBuffer"]["lastTestedLanguage"] = language
else:
self.env["commandBuffer"].pop("lastTestedLanguage", None)
self.env["runtime"]["OutputManager"].play_sound("Accept")
else:
self.env["runtime"]["OutputManager"].play_sound("Error")
@@ -213,13 +264,17 @@ class command:
return
module = self.modules[self.module_index]
voice = self.voices[self.voice_index]
voice, separator, language = self.voices[self.voice_index].partition(
"|"
)
try:
SettingsManager = self.env["runtime"]["SettingsManager"]
SettingsManager.settings["speech"]["driver"] = "speechdDriver"
SettingsManager.settings["speech"]["module"] = module
SettingsManager.settings["speech"]["voice"] = voice
if separator:
SettingsManager.settings["speech"]["language"] = language
if "SpeechDriver" in self.env["runtime"]:
SpeechDriver = self.env["runtime"]["SpeechDriver"]
@@ -237,50 +292,5 @@ class command:
)
self.env["runtime"]["OutputManager"].play_sound("Error")
def preview_voice(self, module, voice):
"""Test voice with spd-say"""
try:
cmd = ["spd-say", "-o", module, "-y", voice, self.testMessage]
result = subprocess.run(cmd, timeout=10)
return result.returncode == 0
except Exception:
return False
def get_speechd_modules(self):
"""Get available speech modules"""
try:
result = subprocess.run(
["spd-say", "-O"], capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
return [line.strip() for line in lines[1:] if line.strip()]
except Exception:
pass
return []
def get_module_voices(self, module):
"""Get voices for module"""
try:
result = subprocess.run(
["spd-say", "-o", module, "-L"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
voices = []
for line in lines[1:]:
if not line.strip():
continue
voice = get_synthesis_voice_name(module, line)
if voice:
voices.append(voice)
return voices
except Exception:
pass
return []
def set_callback(self, callback):
pass
@@ -1,207 +1,160 @@
#!/usr/bin/env python3
import subprocess
import threading
import time
from fenrirscreenreader.core import debug
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
class command:
def __init__(self):
pass
self._request_generation = 0
self._loading = False
def initialize(self, environment):
self.env = environment
self.testMessage = (
self.test_message = (
"Voice test: The quick brown fox jumps over the lazy dog."
)
def shutdown(self):
pass
self._request_generation += 1
self._loading = False
def get_description(self):
return "Safe voice browser - cycles through voices without hanging"
return _("browse and test Speech Dispatcher voices")
def run(self):
try:
if self._loading:
return
self._loading = True
self._request_generation += 1
generation = self._request_generation
self.env["runtime"]["OutputManager"].present_text(
_("Loading speech modules"), interrupt=True
)
self.env["runtime"]["SpeechDiscoveryManager"].request_modules(
lambda modules, error: self._modules_ready(
generation, modules, error
)
)
def _modules_ready(self, generation, modules, error):
if generation != self._request_generation:
return
if error or not modules:
self._loading = False
self._report_error(_("No speech modules found"), error)
return
module_index = self.env["commandBuffer"].get(
"safeBrowserModuleIndex", 0
)
if module_index >= len(modules):
module_index = 0
module = modules[module_index]
self.env["runtime"]["OutputManager"].present_text(
_("Loading voices for {module}").format(module=module),
interrupt=True,
)
self.env["runtime"]["SpeechDiscoveryManager"].request_voices(
module,
lambda _module, voices, voice_error: self._voices_ready(
generation,
modules,
module_index,
voices,
voice_error,
),
)
def _voices_ready(
self, generation, modules, module_index, voices, error
):
if generation != self._request_generation:
return
if error or not voices:
self._loading = False
next_module = (module_index + 1) % len(modules)
self.env["commandBuffer"]["safeBrowserModuleIndex"] = next_module
self.env["commandBuffer"]["safeBrowserVoiceIndex"] = 0
self._report_error(_("No voices found"), error)
return
voice_index = self.env["commandBuffer"].get(
"safeBrowserVoiceIndex", 0
)
if voice_index >= len(voices):
voice_index = 0
module = modules[module_index]
voice = voices[voice_index]
voice_name = voice.split("|", 1)[0]
self.env["runtime"]["OutputManager"].present_text(
_("Module: {module}, voice: {voice}").format(
module=module, voice=voice_name
),
interrupt=True,
)
self.env["runtime"]["OutputManager"].present_text(
_("Testing voice"), interrupt=True
)
self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test(
module,
voice_name,
self.test_message,
lambda succeeded, test_error: self._test_ready(
generation,
modules,
module_index,
voices,
voice_index,
succeeded,
test_error,
),
)
def _test_ready(
self,
generation,
modules,
module_index,
voices,
voice_index,
succeeded,
error,
):
if generation != self._request_generation:
return
self._loading = False
module = modules[module_index]
voice_name, separator, language = voices[voice_index].partition("|")
if succeeded:
self.env["commandBuffer"]["lastTestedModule"] = module
self.env["commandBuffer"]["lastTestedVoice"] = voice_name
if separator:
self.env["commandBuffer"]["lastTestedLanguage"] = language
else:
self.env["commandBuffer"].pop("lastTestedLanguage", None)
self.env["runtime"]["OutputManager"].present_text(
"Starting safe voice browser", interrupt=True
_("Voice test completed successfully"), interrupt=True
)
else:
self._report_error(_("Voice test failed"), error)
# Get modules with timeout protection
modules = self.get_speechd_modules_with_timeout()
if not modules:
self.env["runtime"]["OutputManager"].present_text(
"No speech modules found", interrupt=True
)
return
voice_index += 1
if voice_index >= len(voices):
voice_index = 0
module_index = (module_index + 1) % len(modules)
self.env["commandBuffer"]["safeBrowserModuleIndex"] = module_index
self.env["commandBuffer"]["safeBrowserVoiceIndex"] = voice_index
# Get current position from commandBuffer or start fresh
module_index = self.env["commandBuffer"].get(
"safeBrowserModuleIndex", 0
def _report_error(self, message, detail):
if detail:
self.env["runtime"]["DebugManager"].write_debug_out(
f"voice_browser_safe: {detail}",
debug.DebugLevel.ERROR,
)
voice_index = self.env["commandBuffer"].get(
"safeBrowserVoiceIndex", 0
)
# Ensure valid module index
if module_index >= len(modules):
module_index = 0
current_module = modules[module_index]
self.env["runtime"]["OutputManager"].present_text(
f"Loading voices for {current_module}...", interrupt=True
)
# Get voices with timeout protection
voices = self.get_module_voices_with_timeout(current_module)
if not voices:
self.env["runtime"]["OutputManager"].present_text(
f"No voices in {current_module}, trying next module",
interrupt=True,
)
module_index = (module_index + 1) % len(modules)
self.env["commandBuffer"][
"safeBrowserModuleIndex"
] = module_index
self.env["commandBuffer"]["safeBrowserVoiceIndex"] = 0
return
# Ensure valid voice index
if voice_index >= len(voices):
voice_index = 0
current_voice = voices[voice_index]
# Announce current selection
self.env["runtime"]["OutputManager"].present_text(
f"Module: {current_module} ({module_index + 1}/{len(modules)})",
interrupt=True,
)
self.env["runtime"]["OutputManager"].present_text(
f"Voice: {current_voice} ({voice_index + 1}/{len(voices)})",
interrupt=True,
)
# Test voice in background thread to avoid blocking
self.env["runtime"]["OutputManager"].present_text(
"Testing voice...", interrupt=True
)
# Use threading to prevent freezing
test_thread = threading.Thread(
target=self.test_voice_async,
args=(current_module, current_voice),
)
test_thread.daemon = True
test_thread.start()
# Store tested voice for apply command
self.env["commandBuffer"]["lastTestedModule"] = current_module
self.env["commandBuffer"]["lastTestedVoice"] = current_voice
# Advance to next voice for next run
voice_index += 1
if voice_index >= len(voices):
voice_index = 0
module_index = (module_index + 1) % len(modules)
# Store position for next run
self.env["commandBuffer"]["safeBrowserModuleIndex"] = module_index
self.env["commandBuffer"]["safeBrowserVoiceIndex"] = voice_index
# Give instructions
self.env["runtime"]["OutputManager"].present_text(
"Run again for next voice, or use apply voice command",
interrupt=True,
)
except Exception as e:
self.env["runtime"]["OutputManager"].present_text(
f"Voice browser error: {str(e)}", interrupt=True
)
self.env["runtime"]["OutputManager"].play_sound("Error")
def test_voice_async(self, module, voice):
"""Test voice in background thread to avoid blocking"""
try:
# Run with strict timeout
cmd = ["spd-say", "-o", module, "-y", voice, self.testMessage]
result = subprocess.run(cmd, timeout=5, capture_output=True)
# Schedule success sound for main thread
if result.returncode == 0:
# We can't call OutputManager from background thread safely
# So we'll just let the main thread handle feedback
pass
except subprocess.TimeoutExpired:
# Voice test timed out - this is okay, don't crash
pass
except Exception:
# Any other error - also okay, don't crash
pass
def get_speechd_modules_with_timeout(self):
"""Get speech modules with timeout protection"""
try:
result = subprocess.run(
["spd-say", "-O"], capture_output=True, text=True, timeout=3
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
modules = [line.strip() for line in lines[1:] if line.strip()]
# Limit to first 10 modules to prevent overload
return modules[:10]
except subprocess.TimeoutExpired:
self.env["runtime"]["OutputManager"].present_text(
"Module detection timed out", interrupt=True
)
except Exception as e:
self.env["runtime"]["OutputManager"].present_text(
f"Module detection failed: {str(e)}", interrupt=True
)
return []
def get_module_voices_with_timeout(self, module):
"""Get voices with timeout and limits"""
try:
result = subprocess.run(
["spd-say", "-o", module, "-L"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
voices = []
for line in lines[1:]:
if not line.strip():
continue
voice = get_synthesis_voice_name(module, line)
if voice:
voices.append(voice)
# Limit voice count to prevent memory issues
if len(voices) > 1000:
self.env["runtime"]["OutputManager"].present_text(
f"found {len(voices)} voices, limiting to first 1000",
interrupt=True,
)
voices = voices[:1000]
return voices
except subprocess.TimeoutExpired:
self.env["runtime"]["OutputManager"].present_text(
f"Voice detection for {module} timed out", interrupt=True
)
except Exception as e:
self.env["runtime"]["OutputManager"].present_text(
f"Voice detection failed: {str(e)}", interrupt=True
)
return []
self.env["runtime"]["OutputManager"].present_text(
message, interrupt=True
)
def set_callback(self, callback):
pass
@@ -0,0 +1,30 @@
#!/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 _("show next help category")
def run(self):
text = self.env["runtime"]["HelpManager"].next_help_section()
self.env["runtime"]["OutputManager"].present_text(
text, interrupt=True
)
def set_callback(self, callback):
pass
@@ -0,0 +1,30 @@
#!/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 _("show previous help category")
def run(self):
text = self.env["runtime"]["HelpManager"].prev_help_section()
self.env["runtime"]["OutputManager"].present_text(
text, interrupt=True
)
def set_callback(self, callback):
pass
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import itertools
import queue
import threading
from fenrirscreenreader.core import debug
from fenrirscreenreader.core.eventData import FenrirEventType
class BackgroundTaskManager:
"""Run bounded auxiliary work without exposing Fenrir state to workers."""
def __init__(self, worker_count=4, external_worker_count=2):
self.worker_count = worker_count
self.external_worker_count = external_worker_count
self.env = None
self._accepting_tasks = False
self._callbacks = {}
self._callback_lock = threading.Lock()
self._task_ids = itertools.count(1)
self._task_queues = {
"default": queue.Queue(),
"external": queue.Queue(),
}
self._workers = []
def initialize(self, environment):
self.env = environment
self._accepting_tasks = True
self._start_workers("default", self.worker_count)
self._start_workers("external", self.external_worker_count)
def _start_workers(self, lane, worker_count):
task_queue = self._task_queues[lane]
for worker_index in range(worker_count):
worker = threading.Thread(
target=self._run_worker,
args=(task_queue,),
name=f"fenrir-{lane}-{worker_index + 1}",
daemon=True,
)
self._workers.append(worker)
worker.start()
def shutdown(self):
self._accepting_tasks = False
with self._callback_lock:
self._callbacks.clear()
for task_queue in self._task_queues.values():
try:
while True:
task_queue.get_nowait()
task_queue.task_done()
except queue.Empty:
pass
for _worker_index in range(self.worker_count):
self._task_queues["default"].put(None)
for _worker_index in range(self.external_worker_count):
self._task_queues["external"].put(None)
for worker in self._workers:
worker.join(timeout=0.25)
self._workers = []
def submit_task(self, function, callback, *args, **kwargs):
"""Schedule a pure worker function and return its task identifier."""
return self._submit_to_lane(
"default", function, callback, args, kwargs
)
def submit_external_task(self, function, callback, *args, **kwargs):
"""Schedule potentially long-running user scripts on an isolated lane."""
return self._submit_to_lane(
"external", function, callback, args, kwargs
)
def _submit_to_lane(self, lane, function, callback, args, kwargs):
if not self._accepting_tasks or not callable(function):
return None
if not callable(callback):
return None
task_id = next(self._task_ids)
with self._callback_lock:
self._callbacks[task_id] = callback
self._task_queues[lane].put((task_id, function, args, kwargs))
return task_id
def cancel_task(self, task_id):
"""Discard delivery for a task that is no longer relevant."""
if task_id is None:
return
with self._callback_lock:
self._callbacks.pop(task_id, None)
def handle_result(self, result):
"""Deliver a completed task on Fenrir's main event-loop thread."""
if not isinstance(result, dict):
return
task_id = result.get("task_id")
with self._callback_lock:
callback = self._callbacks.pop(task_id, None)
if callback is None:
return
try:
callback(result)
except Exception as error:
self.env["runtime"]["DebugManager"].write_debug_out(
"BackgroundTaskManager handle_result: " + str(error),
debug.DebugLevel.ERROR,
)
def _run_worker(self, task_queue):
while True:
task = task_queue.get()
try:
if task is None:
return
task_id, function, args, kwargs = task
try:
value = function(*args, **kwargs)
result = {
"task_id": task_id,
"succeeded": True,
"value": value,
"error": "",
}
except Exception as error:
result = {
"task_id": task_id,
"succeeded": False,
"value": None,
"error": str(error),
}
if self._accepting_tasks:
self.env["runtime"]["EventManager"].put_to_event_queue(
FenrirEventType.background_task_result,
result,
)
finally:
task_queue.task_done()
@@ -6,12 +6,64 @@
import os
import threading
import time
from fenrirscreenreader.core import debug
from fenrirscreenreader.core.eventData import FenrirEventType
from fenrirscreenreader.utils import x_clipboard
def synchronize_clipboards(
display, fenrir_text, last_observed_fenrir, last_observed_x
):
"""Perform one clipboard I/O cycle without accessing Fenrir managers."""
result = {}
try:
x_text = x_clipboard.read_text(display)
if not isinstance(x_text, str) or not x_text:
x_text = None
except Exception as error:
return {"error": "ClipboardSyncManager paste failed: " + str(error)}
if fenrir_text and x_text and fenrir_text == x_text:
return {
"last_observed_fenrir": fenrir_text,
"last_observed_x": x_text,
}
fenrir_changed = (
fenrir_text and fenrir_text != last_observed_fenrir
)
x_changed = x_text and x_text != last_observed_x
if fenrir_changed:
try:
written = x_clipboard.write_text(fenrir_text, display)
except Exception as error:
written = False
result["error"] = (
"ClipboardSyncManager copy failed: " + str(error)
)
result["last_observed_fenrir"] = fenrir_text
if written:
result["last_written_to_x"] = fenrir_text
result["last_observed_x"] = fenrir_text
return result
if x_changed:
return {
"import_text": x_text,
"last_imported_from_x": x_text,
"last_observed_fenrir": x_text,
"last_observed_x": x_text,
}
if fenrir_text:
result["last_observed_fenrir"] = fenrir_text
if x_text:
result["last_observed_x"] = x_text
return result
class ClipboardSyncManager:
def __init__(self):
self.env = None
@@ -19,7 +71,10 @@ class ClipboardSyncManager:
self.display = ""
self.interval = 0.5
self.running = False
self.thread = None
self._task_id = None
self._scheduler_stop = threading.Event()
self._sync_event_pending = threading.Event()
self._scheduler_thread = None
self.last_written_to_x = None
self.last_imported_from_x = None
self.last_observed_fenrir = None
@@ -82,19 +137,85 @@ class ClipboardSyncManager:
if self.running:
return
self.running = True
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
self._scheduler_stop.clear()
self._scheduler_thread = threading.Thread(
target=self._schedule_sync_events,
name="fenrir-clipboard-scheduler",
daemon=True,
)
self._scheduler_thread.start()
def stop(self):
self.running = False
if self.thread:
self.thread.join(timeout=1.0)
self.thread = None
task_manager = self.env["runtime"].get("BackgroundTaskManager")
if task_manager and self._task_id is not None:
task_manager.cancel_task(self._task_id)
self._task_id = None
self._scheduler_stop.set()
self._sync_event_pending.clear()
if self._scheduler_thread:
self._scheduler_thread.join(timeout=1.0)
self._scheduler_thread = None
def _run(self):
def _schedule_sync_events(self):
while self.running:
self.poll_once()
time.sleep(self.interval)
if not self._sync_event_pending.is_set():
self._sync_event_pending.set()
self.env["runtime"]["EventManager"].put_to_event_queue(
FenrirEventType.clipboard_sync, None
)
if self._scheduler_stop.wait(self.interval):
return
def handle_sync_event(self):
"""Schedule clipboard I/O while keeping manager state on the loop."""
if not self.running or self._task_id is not None:
return
fenrir_text = self._get_fenrir_clipboard_text()
self._task_id = self.env["runtime"][
"BackgroundTaskManager"
].submit_task(
synchronize_clipboards,
lambda result: self._handle_poll_result(fenrir_text, result),
self.display,
fenrir_text,
self.last_observed_fenrir,
self.last_observed_x,
)
if self._task_id is None:
self._sync_event_pending.clear()
def _handle_poll_result(self, fenrir_snapshot, task_result):
self._task_id = None
self._sync_event_pending.clear()
if not self.running:
return
if not task_result.get("succeeded"):
self._debug(
"ClipboardSyncManager poll failed: "
+ task_result.get("error", "unknown error")
)
return
result = task_result.get("value") or {}
if result.get("error"):
self._debug(result["error"])
import_text = result.get("import_text")
if import_text:
if self._get_fenrir_clipboard_text() != fenrir_snapshot:
return
self.env["runtime"]["MemoryManager"].add_value_to_first_index(
"clipboardHistory", import_text
)
for attribute in (
"last_written_to_x",
"last_imported_from_x",
"last_observed_fenrir",
"last_observed_x",
):
if attribute in result:
setattr(self, attribute, result[attribute])
def poll_once(self):
fenrir_text = self._get_fenrir_clipboard_text()
@@ -436,7 +436,10 @@ class CommandManager:
)
def execute_command(self, command, section="commands"):
if self.env["runtime"]["ScreenManager"].is_ignored_screen():
if (
self.env["runtime"]["ScreenManager"].is_ignored_screen()
and not self.env["runtime"]["HelpManager"].is_tutorial_mode()
):
return
if self.command_exists(command, section):
try:
+195 -180
View File
@@ -1,12 +1,6 @@
#!/usr/bin/env python3
import importlib.util
import os
import subprocess
import time
from fenrirscreenreader.core import debug
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
class DynamicVoiceCommand:
@@ -14,51 +8,40 @@ class DynamicVoiceCommand:
def __init__(self, module, voice, env):
self.module = module
self.voice = voice
self.voice, separator, self.language = voice.partition("|")
if not separator:
self.language = ""
self.env = env
self.testMessage = "This is a voice test. The quick brown fox jumps over the lazy dog."
self.test_message = (
"This is a voice test. The quick brown fox jumps over the lazy dog."
)
self._test_generation = 0
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
self._test_generation += 1
def get_description(self):
return f"Select voice: {self.voice}"
def run(self):
self._test_generation += 1
generation = self._test_generation
try:
self.env["runtime"]["OutputManager"].present_text(
f"Testing voice {self.voice} from {self.module}. Please wait.",
interrupt=True,
)
# Brief pause before testing to avoid speech overlap
time.sleep(0.5)
# Test voice
testResult, errorMsg = self.test_voice()
if testResult:
self.env["runtime"]["OutputManager"].present_text(
"Voice test completed successfully. Navigate to Apply Tested Voice to use this voice.",
interrupt=False,
flush=False,
)
# Store for confirmation (use same variables as
# apply_tested_voice.py)
self.env["commandBuffer"]["lastTestedModule"] = self.module
self.env["commandBuffer"]["lastTestedVoice"] = self.voice
self.env["commandBuffer"]["pendingVoiceModule"] = self.module
self.env["commandBuffer"]["pendingVoiceVoice"] = self.voice
self.env["commandBuffer"]["voiceTestCompleted"] = True
else:
self.env["runtime"]["OutputManager"].present_text(
f"Voice test failed: {errorMsg}",
interrupt=False,
flush=False,
)
self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test(
self.module,
self.voice,
self.test_message,
lambda succeeded, error: self._finish_voice_test(
generation, succeeded, error
),
)
except Exception as e:
self.env["runtime"]["OutputManager"].present_text(
@@ -67,35 +50,32 @@ class DynamicVoiceCommand:
flush=False,
)
def test_voice(self):
"""Test voice with spd-say"""
try:
cmd = [
"spd-say",
"-C",
"-w",
"-o",
self.module,
"-y",
self.voice,
self.testMessage,
]
result = subprocess.run(
cmd, timeout=8, capture_output=True, text=True
def _finish_voice_test(self, generation, succeeded, error):
if generation != self._test_generation:
return
if not succeeded:
self.env["runtime"]["OutputManager"].present_text(
f"Voice test failed: {error}",
interrupt=False,
flush=False,
)
if result.returncode == 0:
return True, "Voice test successful"
else:
error_msg = (
result.stderr.strip()
if result.stderr
else f"Command failed with return code {result.returncode}"
)
return False, error_msg
except subprocess.TimeoutExpired:
return False, "Voice test timed out"
except Exception as e:
return False, f"Error running voice test: {str(e)}"
return
self.env["commandBuffer"]["lastTestedModule"] = self.module
self.env["commandBuffer"]["lastTestedVoice"] = self.voice
self.env["commandBuffer"]["pendingVoiceModule"] = self.module
self.env["commandBuffer"]["pendingVoiceVoice"] = self.voice
if self.language:
self.env["commandBuffer"]["pendingVoiceLanguage"] = self.language
else:
self.env["commandBuffer"].pop("pendingVoiceLanguage", None)
self.env["commandBuffer"]["voiceTestCompleted"] = True
self.env["runtime"]["OutputManager"].present_text(
"Voice test completed successfully. "
"Navigate to Apply Tested Voice to use this voice.",
interrupt=False,
flush=False,
)
def set_callback(self, callback):
pass
@@ -126,6 +106,7 @@ class DynamicApplyVoiceCommand:
module = self.env["commandBuffer"]["pendingVoiceModule"]
voice = self.env["commandBuffer"]["pendingVoiceVoice"]
language = self.env["commandBuffer"].get("pendingVoiceLanguage")
self.env["runtime"]["OutputManager"].present_text(
f"Applying {voice} from {module}", interrupt=True
@@ -148,6 +129,7 @@ class DynamicApplyVoiceCommand:
old_driver = settings_manager.get_setting("speech", "driver")
old_module = settings_manager.get_setting("speech", "module")
old_voice = settings_manager.get_setting("speech", "voice")
old_language = settings_manager.get_setting("speech", "language")
try:
# Apply new settings to runtime only (use set_setting to update
@@ -157,51 +139,36 @@ class DynamicApplyVoiceCommand:
)
settings_manager.set_setting("speech", "module", module)
settings_manager.set_setting("speech", "voice", voice)
if language:
settings_manager.set_setting(
"speech", "language", language
)
# Apply settings to speech driver directly
if "SpeechDriver" in self.env["runtime"]:
SpeechDriver = self.env["runtime"]["SpeechDriver"]
speech_driver = self.env["runtime"]["SpeechDriver"]
# Get current module to see if we're changing modules
current_module = settings_manager.get_setting(
"speech", "module"
)
module_changing = current_module != module
# Set module and voice on driver instance first
SpeechDriver.set_module(module)
SpeechDriver.set_voice(voice)
if module_changing:
# Module change requires reinitializing the speech
# driver
self.env["runtime"]["OutputManager"].present_text(
f"Switching from {current_module} to {module} module",
interrupt=True,
)
SpeechDriver.shutdown()
SpeechDriver.initialize(self.env)
# Re-set after initialization
SpeechDriver.set_module(module)
SpeechDriver.set_voice(voice)
self.env["runtime"]["OutputManager"].present_text(
"Speech driver reinitialized", interrupt=True
)
# Set module and voice on the driver instance.
speech_driver.set_module(module)
if language:
speech_driver.set_language(language)
speech_driver.set_voice(voice)
# Debug: verify what was actually set
self.env["runtime"]["OutputManager"].present_text(
f"Speech driver now has module: {SpeechDriver.module}, voice: {SpeechDriver.voice}",
"Speech driver now has module: "
f"{speech_driver.module}, voice: "
f"{speech_driver.voice}",
interrupt=True,
)
# Force application by speaking a test message
self.env["runtime"]["OutputManager"].present_text(
"Voice applied successfully! You should hear this in the new voice.",
"Voice applied successfully! You should hear this in "
"the new voice.",
interrupt=True,
)
# Brief pause then more speech to test
time.sleep(1)
self.env["runtime"]["OutputManager"].present_text(
"Use save settings to make permanent", interrupt=True
)
@@ -214,16 +181,19 @@ class DynamicApplyVoiceCommand:
except Exception as e:
# Revert on failure
settings_manager.settings["speech"]["driver"] = old_driver
settings_manager.settings["speech"]["module"] = old_module
settings_manager.settings["speech"]["voice"] = old_voice
settings_manager.set_setting("speech", "driver", old_driver)
settings_manager.set_setting("speech", "module", old_module)
settings_manager.set_setting("speech", "voice", old_voice)
settings_manager.set_setting(
"speech", "language", old_language
)
# Try to reinitialize with old settings
if "SpeechDriver" in self.env["runtime"]:
try:
SpeechDriver = self.env["runtime"]["SpeechDriver"]
SpeechDriver.shutdown()
SpeechDriver.initialize(self.env)
speech_driver = self.env["runtime"]["SpeechDriver"]
speech_driver.shutdown()
speech_driver.initialize(self.env)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"dynamicVoiceMenu: Error reinitializing speech driver: "
@@ -246,56 +216,139 @@ class DynamicApplyVoiceCommand:
pass
def add_dynamic_voice_menus(VmenuManager):
"""Add dynamic voice menus to vmenu system"""
def add_dynamic_voice_menus(vmenu_manager):
"""Populate cached voice menus and start non-blocking discovery if needed."""
try:
env = VmenuManager.env
# Get speech modules
modules = get_speechd_modules()
if not modules:
return
# Create voice browser submenu
voice_browser_menu = {}
# Add apply voice command
apply_command = DynamicApplyVoiceCommand(env)
voice_browser_menu["Apply Tested Voice Action"] = apply_command
# Add modules as submenus
for module in modules[
:8
]: # Limit to 8 modules to keep menu manageable
module_menu = {}
# Get voices for this module
voices = get_module_voices(module)
if voices:
# Add voice commands
for voice in voices:
voice_command = DynamicVoiceCommand(module, voice, env)
module_menu[f"{voice} Action"] = voice_command
else:
module_menu["No voices available Action"] = (
create_info_command(f"No voices found for {module}", env)
env = vmenu_manager.env
discovery = env["runtime"]["SpeechDiscoveryManager"]
generation = getattr(vmenu_manager, "_voice_menu_generation", 0) + 1
vmenu_manager._voice_menu_generation = generation
modules = discovery.get_cached_modules()
if modules is None:
_install_loading_menu(vmenu_manager)
discovery.request_modules(
lambda found, error: _request_dynamic_voice_lists(
vmenu_manager, generation, found, error, False
)
voice_browser_menu[f"{module} Menu"] = module_menu
# Add to main menu dict
VmenuManager.menuDict["Voice Browser Menu"] = voice_browser_menu
except Exception as e:
# Use debug manager instead of print for error logging
)
return
_request_dynamic_voice_lists(
vmenu_manager, generation, modules, "", True
)
except Exception as error:
if "DebugManager" in env["runtime"]:
env["runtime"]["DebugManager"].write_debug_out(
f"Error creating dynamic voice menus: {e}",
f"Error creating dynamic voice menus: {error}",
debug.DebugLevel.ERROR,
)
else:
print(f"Error creating dynamic voice menus: {e}")
print(f"Error creating dynamic voice menus: {error}")
def _request_dynamic_voice_lists(
vmenu_manager, generation, modules, error, allow_active
):
if generation != getattr(vmenu_manager, "_voice_menu_generation", 0):
return
if error or not modules:
if vmenu_manager.get_active() and not allow_active:
vmenu_manager._voice_menu_refresh_pending = True
return
_install_discovery_error_menu(vmenu_manager)
return
discovery = vmenu_manager.env["runtime"]["SpeechDiscoveryManager"]
selected_modules = modules[:8]
voices_by_module = {}
missing_modules = []
for module in selected_modules:
voices = discovery.get_cached_voices(module)
if voices is None:
missing_modules.append(module)
else:
voices_by_module[module] = voices
if not missing_modules:
_install_dynamic_voice_menu(
vmenu_manager,
generation,
selected_modules,
voices_by_module,
allow_active,
)
return
_install_loading_menu(vmenu_manager)
remaining_modules = set(missing_modules)
def voices_ready(module, voices, _voice_error):
if generation != getattr(vmenu_manager, "_voice_menu_generation", 0):
return
voices_by_module[module] = voices
remaining_modules.discard(module)
if not remaining_modules:
_install_dynamic_voice_menu(
vmenu_manager,
generation,
selected_modules,
voices_by_module,
False,
)
for module in missing_modules:
discovery.request_voices(module, voices_ready)
def _install_dynamic_voice_menu(
vmenu_manager,
generation,
modules,
voices_by_module,
allow_active,
):
if generation != getattr(vmenu_manager, "_voice_menu_generation", 0):
return
if vmenu_manager.get_active() and not allow_active:
vmenu_manager._voice_menu_refresh_pending = True
return
env = vmenu_manager.env
voice_browser_menu = {
"Apply Tested Voice Action": DynamicApplyVoiceCommand(env)
}
for module in modules:
module_menu = {}
voices = voices_by_module.get(module, [])
for voice in voices:
voice_name = voice.split("|", 1)[0]
module_menu[f"{voice_name} Action"] = DynamicVoiceCommand(
module, voice, env
)
if not module_menu:
module_menu["No voices available Action"] = create_info_command(
f"No voices found for {module}", env
)
voice_browser_menu[f"{module} Menu"] = module_menu
vmenu_manager.menuDict["Voice Browser Menu"] = voice_browser_menu
vmenu_manager._voice_menu_refresh_pending = False
def _install_loading_menu(vmenu_manager):
env = vmenu_manager.env
vmenu_manager.menuDict["Voice Browser Menu"] = {
"Loading voices Action": create_info_command(
"Voice information is still loading", env
)
}
def _install_discovery_error_menu(vmenu_manager):
env = vmenu_manager.env
vmenu_manager.menuDict["Voice Browser Menu"] = {
"Voice discovery failed Action": create_info_command(
"Voice discovery failed", env
)
}
def create_info_command(message, env):
@@ -324,41 +377,3 @@ def create_info_command(message, env):
pass
return InfoCommand(message, env)
def get_speechd_modules():
"""Get available speech modules"""
try:
result = subprocess.run(
["spd-say", "-O"], capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
return [line.strip() for line in lines[1:] if line.strip()]
except Exception:
pass
return []
def get_module_voices(module):
"""Get voices for a module"""
try:
result = subprocess.run(
["spd-say", "-o", module, "-L"],
capture_output=True,
text=True,
timeout=8,
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
voices = []
for line in lines[1:]:
if not line.strip():
continue
voice = get_synthesis_voice_name(module, line)
if voice:
voices.append(voice)
return voices
except Exception:
pass
return []
+2
View File
@@ -19,6 +19,8 @@ class FenrirEventType(Enum):
heart_beat = 6
execute_command = 7
remote_incomming = 8
background_task_result = 9
clipboard_sync = 10
def __int__(self):
return self.value
@@ -63,6 +63,12 @@ class EventManager:
self.env["runtime"]["FenrirManager"].handle_execute_command(event)
elif event["Type"] == FenrirEventType.remote_incomming:
self.env["runtime"]["FenrirManager"].handle_remote_incomming(event)
elif event["Type"] == FenrirEventType.background_task_result:
self.env["runtime"]["FenrirManager"].handle_background_task_result(
event
)
elif event["Type"] == FenrirEventType.clipboard_sync:
self.env["runtime"]["FenrirManager"].handle_clipboard_sync(event)
def is_main_event_loop_running(self):
return self.running.value == 1
+63 -18
View File
@@ -46,6 +46,7 @@ class FenrirManager:
self.is_initialized = True
self.modifierInput = False
self.modifier_prefix_input = False
self.singleKeyCommand = False
self.command = ""
self.set_process_name()
@@ -81,19 +82,25 @@ class FenrirManager:
else:
return
if self.environment["runtime"]["InputManager"].no_key_pressed():
self.environment["runtime"]["InputManager"].clear_last_deep_input()
if self.environment["runtime"]["ScreenManager"].is_ignored_screen():
tutorial_mode = self.environment["runtime"][
"HelpManager"
].is_tutorial_mode()
if (
self.environment["runtime"]["ScreenManager"].is_ignored_screen()
and not tutorial_mode
):
self.environment["runtime"]["InputManager"].write_event_buffer()
else:
if self.environment["runtime"]["HelpManager"].is_tutorial_mode():
if tutorial_mode:
self.environment["runtime"][
"InputManager"
].clear_event_buffer()
self.environment["runtime"]["InputManager"].key_echo(
event["data"]
)
if self.environment["runtime"][
"ScreenManager"
].is_ignored_screen():
self.environment["runtime"]["InputManager"].key_echo(
event["data"]
)
if self.environment["runtime"]["VmenuManager"].get_active():
self.environment["runtime"][
@@ -121,13 +128,16 @@ class FenrirManager:
self.environment["runtime"][
"InputManager"
].clear_event_buffer()
else:
# Hold conventional modifier presses long enough to determine
# whether a Fenrir or Script modifier follows them.
elif not self.modifier_prefix_input:
self.environment["runtime"][
"InputManager"
].write_event_buffer()
if self.environment["runtime"]["InputManager"].no_key_pressed():
self.modifierInput = False
self.modifier_prefix_input = False
self.singleKeyCommand = False
self.environment["runtime"]["InputManager"].write_event_buffer()
self.environment["runtime"]["InputManager"].handle_device_grab()
@@ -151,10 +161,15 @@ class FenrirManager:
if self.environment["runtime"]["CommandManager"].command_exists(
current_command, "help"
):
self.environment["runtime"]["CommandManager"].execute_command(
self.environment["runtime"]["CommandManager"].run_command(
current_command, "help"
)
return
if current_command == "TOGGLE_TUTORIAL_MODE":
self.environment["runtime"]["CommandManager"].run_command(
current_command, "commands"
)
return
elif self.environment["runtime"]["VmenuManager"].get_active():
if self.environment["runtime"]["CommandManager"].command_exists(
current_command, "vmenu-navigation"
@@ -206,6 +221,11 @@ class FenrirManager:
event["data"]
)
def handle_background_task_result(self, event):
self.environment["runtime"]["BackgroundTaskManager"].handle_result(
event["data"]
)
def handle_screen_change(self, event):
self.environment["runtime"]["ScreenManager"].handle_screen_change(
event["data"]
@@ -272,6 +292,13 @@ class FenrirManager:
"onHeartBeat", force=True
)
def handle_clipboard_sync(self, event):
clipboard_sync_manager = self.environment["runtime"].get(
"ClipboardSyncManager"
)
if clipboard_sync_manager:
clipboard_sync_manager.handle_sync_event()
def detect_shortcut_command(self):
if self.environment["input"]["key_forward"] != 0:
return
@@ -281,14 +308,26 @@ class FenrirManager:
):
return
if self.environment["runtime"]["InputManager"].is_key_press():
self.modifierInput = self.environment["runtime"][
"InputManager"
].curr_key_is_modifier()
input_manager = self.environment["runtime"]["InputManager"]
if input_manager.is_key_press():
self.modifierInput = input_manager.curr_input_has_command_modifier()
self.modifier_prefix_input = (
not self.modifierInput
and input_manager.curr_input_is_modifier_prefix()
)
else:
if not self.environment["runtime"][
"InputManager"
].no_key_pressed():
if (
not self.modifierInput
and input_manager.curr_input_has_command_modifier()
):
self.modifierInput = True
self.modifier_prefix_input = False
elif (
self.modifier_prefix_input
and not input_manager.curr_input_is_modifier_prefix()
):
self.modifier_prefix_input = False
if not input_manager.no_key_pressed():
if self.singleKeyCommand:
self.singleKeyCommand = (
len(self.environment["input"]["curr_input"]) == 1
@@ -311,7 +350,13 @@ class FenrirManager:
self.singleKeyCommand = True
elif (
(
self.environment["runtime"]["VmenuManager"].get_active()
(
"HelpManager" in self.environment["runtime"]
and self.environment["runtime"][
"HelpManager"
].is_tutorial_mode()
)
or self.environment["runtime"]["VmenuManager"].get_active()
or self.environment["runtime"][
"DiffReviewManager"
].is_active()
@@ -24,6 +24,8 @@ general_data = {
"SpeechHistoryManager",
"HelpManager",
"MemoryManager",
"SpeechDiscoveryManager",
"BackgroundTaskManager",
"EventManager",
"ProcessManager",
"VmenuManager",
+237 -104
View File
@@ -4,99 +4,181 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import ast
import copy
from fenrirscreenreader.core import debug
from fenrirscreenreader.core.i18n import _
class HelpManager:
ACTIVE_SECTION = "active"
UNBOUND_SECTION = "unbound"
PLUGINS_SECTION = "plugins"
HELP_SECTIONS = (
ACTIVE_SECTION,
UNBOUND_SECTION,
PLUGINS_SECTION,
)
def __init__(self):
self.helpDict = {}
self.tutorialListIndex = None
self.env = None
self.help_lists = {
self.ACTIVE_SECTION: [],
self.UNBOUND_SECTION: [],
self.PLUGINS_SECTION: [],
}
self.help_section = self.ACTIVE_SECTION
self.tutorial_list_index = None
self.bindings_backup = None
self.raw_bindings_backup = None
self.capture_degraded = False
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
if self.is_tutorial_mode():
self.set_tutorial_mode(False)
def toggle_tutorial_mode(self):
self.set_tutorial_mode(not self.env["general"]["tutorialMode"])
self.set_tutorial_mode(not self.is_tutorial_mode())
def set_tutorial_mode(self, newTutorialMode):
if self.env["runtime"]["VmenuManager"].get_active():
return
self.env["general"]["tutorialMode"] = newTutorialMode
if newTutorialMode:
def set_tutorial_mode(self, tutorial_mode):
if tutorial_mode == self.is_tutorial_mode():
return True
if tutorial_mode and self.env["runtime"]["VmenuManager"].get_active():
return False
if tutorial_mode:
self.bindings_backup = self.env["bindings"].copy()
self.raw_bindings_backup = copy.deepcopy(
self.env["rawBindings"]
)
self.create_help_dict()
self.env["bindings"][
str([1, ["KEY_ESC"]])
] = "TOGGLE_TUTORIAL_MODE"
self.env["bindings"][str([1, ["KEY_UP"]])] = "PREV_HELP"
self.env["bindings"][str([1, ["KEY_DOWN"]])] = "NEXT_HELP"
self.env["bindings"][str([1, ["KEY_SPACE"]])] = "CURR_HELP"
else:
try:
self.env["bindings"] = self.env["runtime"][
"SettingsManager"
].get_binding_backup()
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"HelpManager set_tutorial_mode: Error restoring binding backup: "
+ str(e),
debug.DebugLevel.ERROR,
)
self.env["general"]["tutorialMode"] = True
self._install_help_bindings()
self._refresh_input_bindings()
self.capture_degraded = not self.env["runtime"][
"InputManager"
].set_help_capture(True)
return True
if not self.env["runtime"]["InputManager"].set_help_capture(False):
self.env["runtime"]["DebugManager"].write_debug_out(
"HelpManager could not release exclusive input capture",
debug.DebugLevel.ERROR,
)
return False
self._restore_bindings()
self.env["general"]["tutorialMode"] = False
self.capture_degraded = False
self.env["runtime"]["InputManager"].reset_input_state()
self._refresh_input_bindings()
return True
def _install_help_bindings(self):
help_bindings = {
str([1, ["KEY_ESC"]]): "TOGGLE_TUTORIAL_MODE",
str([1, ["KEY_UP"]]): "PREV_HELP",
str([1, ["KEY_DOWN"]]): "NEXT_HELP",
str([1, ["KEY_LEFT"]]): "PREV_HELP_SECTION",
str([1, ["KEY_RIGHT"]]): "NEXT_HELP_SECTION",
str([1, ["KEY_SPACE"]]): "CURR_HELP",
}
self.env["bindings"].update(help_bindings)
for shortcut in help_bindings:
self.env["rawBindings"][shortcut] = ast.literal_eval(shortcut)
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
def _refresh_input_bindings(self):
try:
refresh_grabs = getattr(
self.env["runtime"]["InputDriver"], "refresh_grabs", None
)
if refresh_grabs:
refresh_grabs(force=True)
except Exception as error:
self.env["runtime"]["DebugManager"].write_debug_out(
"HelpManager could not refresh input bindings: "
+ str(error),
debug.DebugLevel.ERROR,
)
def is_tutorial_mode(self):
if self.env is None:
return False
return self.env["general"]["tutorialMode"]
def get_formatted_shortcut_for_command(self, command):
shortcut = []
raw_shortcut = []
try:
raw_shortcut = list(self.env["bindings"].keys())[
list(self.env["bindings"].values()).index(command)
]
raw_shortcut = self.env["rawBindings"][raw_shortcut]
# prefer numbers for multitap
if raw_shortcut[0] in range(2, 9):
formatted_key = str(raw_shortcut[0]) + " times "
shortcut.append(formatted_key)
# prefer metha keys
for k in [
"KEY_FENRIR",
"KEY_SCRIPT",
"KEY_CTRL",
"KEY_SHIFT",
"KEY_ALT",
"KEY_META",
]:
if k in raw_shortcut[1]:
formatted_key = k
formatted_key = formatted_key.lower()
formatted_key = formatted_key.replace("key_kp", " keypad ")
formatted_key = formatted_key.replace("key_", " ")
shortcut.append(formatted_key)
raw_shortcut[1].remove(k)
# handle other keys
for k in raw_shortcut[1]:
formatted_key = k
formatted_key = formatted_key.lower()
formatted_key = formatted_key.replace("key_kp", " keypad ")
formatted_key = formatted_key.replace("key_", " ")
shortcut.append(formatted_key)
except Exception as e:
return ""
shortcut = str(shortcut)
shortcut = shortcut.replace("[", "")
shortcut = shortcut.replace("]", "")
shortcut = shortcut.replace("'", "")
return shortcut
def is_capture_degraded(self):
return self.capture_degraded
def get_command_help_text(self, command, section="commands"):
command_name = command.lower()
command_name = command_name.split("__-__")[0]
command_name = command_name.replace("_", " ")
command_name = command_name.replace("_", " ")
def _get_shortcuts_by_command(self):
bindings = (
self.bindings_backup
if self.bindings_backup is not None
else self.env["bindings"]
)
raw_bindings = (
self.raw_bindings_backup
if self.raw_bindings_backup is not None
else self.env["rawBindings"]
)
shortcuts_by_command = {}
for shortcut_key, command in bindings.items():
raw_shortcut = raw_bindings.get(shortcut_key)
if raw_shortcut is None:
try:
raw_shortcut = ast.literal_eval(shortcut_key)
except (SyntaxError, ValueError):
continue
shortcuts_by_command.setdefault(command, []).append(
copy.deepcopy(raw_shortcut)
)
return shortcuts_by_command
def _format_shortcut(self, raw_shortcut):
shortcut_repeat, raw_keys = raw_shortcut
keys = list(raw_keys)
formatted_keys = []
repeat_prefix = ""
if shortcut_repeat in range(2, 9):
repeat_prefix = _("{} times ").format(shortcut_repeat)
for key_name in [
"KEY_FENRIR",
"KEY_SCRIPT",
"KEY_CTRL",
"KEY_SHIFT",
"KEY_ALT",
"KEY_META",
]:
if key_name in keys:
formatted_keys.append(self._format_key_name(key_name))
keys.remove(key_name)
formatted_keys.extend(self._format_key_name(key) for key in keys)
return repeat_prefix + ", ".join(formatted_keys)
def _format_key_name(self, key_name):
formatted_key = key_name.lower()
formatted_key = formatted_key.replace("key_kp", "keypad ")
formatted_key = formatted_key.replace("key_", "")
return _(formatted_key.strip())
def get_formatted_shortcut_for_command(self, command):
shortcuts = self._get_shortcuts_by_command().get(command, [])
return "; ".join(
self._format_shortcut(shortcut) for shortcut in shortcuts
)
def get_command_help_text(self, command, shortcuts=None):
command_name = command.lower().split("__-__")[0].replace("_", " ")
if command == "TOGGLE_TUTORIAL_MODE":
command_description = _("toggles the tutorial mode")
else:
@@ -104,45 +186,96 @@ class HelpManager:
"CommandManager"
].get_command_description(command, section="commands")
if command_description == "":
command_description = "no Description available"
command_shortcut = self.get_formatted_shortcut_for_command(command)
if command_shortcut == "":
command_shortcut = "unbound"
helptext = (
command_name
+ ", Shortcut "
+ command_shortcut
+ ", Description "
+ command_description
command_description = _("no description available")
if shortcuts is None:
shortcuts = self._get_shortcuts_by_command().get(command, [])
command_shortcuts = "; ".join(
self._format_shortcut(shortcut) for shortcut in shortcuts
)
if command_shortcuts == "":
command_shortcuts = _("unbound")
return _(
"{command_name}, Shortcuts {command_shortcuts}, Description "
"{command_description}"
).format(
command_name=command_name,
command_shortcuts=command_shortcuts,
command_description=command_description,
)
return helptext
def create_help_dict(self, section="commands"):
self.helpDict = {}
for command in sorted(self.env["commands"][section].keys()):
self.helpDict[len(self.helpDict)] = self.get_command_help_text(
command, section
)
if len(self.helpDict) > 0:
self.tutorialListIndex = 0
else:
self.tutorialListIndex = None
shortcuts_by_command = self._get_shortcuts_by_command()
self.help_lists = {
self.ACTIVE_SECTION: [],
self.UNBOUND_SECTION: [],
self.PLUGINS_SECTION: [],
}
for command in sorted(self.env["commands"][section]):
command_instance = self.env["commands"][section][command]
if not getattr(command_instance, "help_visible", True):
continue
shortcuts = shortcuts_by_command.get(command, [])
help_text = self.get_command_help_text(command, shortcuts)
if any("KEY_SCRIPT" in shortcut[1] for shortcut in shortcuts):
target_section = self.PLUGINS_SECTION
elif shortcuts:
target_section = self.ACTIVE_SECTION
else:
target_section = self.UNBOUND_SECTION
self.help_lists[target_section].append(help_text)
self.help_section = self.ACTIVE_SECTION
self.tutorial_list_index = None
def get_help_section_name(self):
if self.help_section == self.PLUGINS_SECTION:
return _("Plugins")
if self.help_section == self.UNBOUND_SECTION:
return _("Unbound actions")
return _("Active actions")
def select_help_section(self, section):
if section not in self.help_lists:
return self.get_help_section_name()
self.help_section = section
self.tutorial_list_index = None
return self.get_help_section_name()
def next_help_section(self):
section_index = self.HELP_SECTIONS.index(self.help_section)
section_index = (section_index + 1) % len(self.HELP_SECTIONS)
return self.select_help_section(self.HELP_SECTIONS[section_index])
def prev_help_section(self):
section_index = self.HELP_SECTIONS.index(self.help_section)
section_index = (section_index - 1) % len(self.HELP_SECTIONS)
return self.select_help_section(self.HELP_SECTIONS[section_index])
def get_help_for_current_index(self):
if self.tutorialListIndex is None:
return ""
return self.helpDict[self.tutorialListIndex]
entries = self.help_lists[self.help_section]
if self.tutorial_list_index is None or not entries:
return self.get_help_section_name()
return entries[self.tutorial_list_index]
def next_index(self):
if self.tutorialListIndex is None:
entries = self.help_lists[self.help_section]
if not entries:
self.tutorial_list_index = None
return
self.tutorialListIndex += 1
if self.tutorialListIndex >= len(self.helpDict):
self.tutorialListIndex = 0
if self.tutorial_list_index is None:
self.tutorial_list_index = 0
return
self.tutorial_list_index = (self.tutorial_list_index + 1) % len(
entries
)
def prev_index(self):
if self.tutorialListIndex is None:
entries = self.help_lists[self.help_section]
if not entries:
self.tutorial_list_index = None
return
self.tutorialListIndex -= 1
if self.tutorialListIndex < 0:
self.tutorialListIndex = len(self.helpDict) - 1
if self.tutorial_list_index is None:
self.tutorial_list_index = len(entries) - 1
return
self.tutorial_list_index = (self.tutorial_list_index - 1) % len(
entries
)
@@ -54,6 +54,9 @@ class InputDriver:
return True
return True
def set_help_capture(self, enabled):
return True
def force_ungrab(self):
"""Emergency method to release grabbed devices in case of failure"""
if not self._initialized:
+38 -9
View File
@@ -18,6 +18,13 @@ currentdir = os.path.dirname(
)
fenrir_path = os.path.dirname(currentdir)
SHORTCUT_MODIFIER_KEYS = {
"KEY_ALT",
"KEY_CTRL",
"KEY_META",
"KEY_SHIFT",
}
class InputManager:
def __init__(self):
@@ -189,8 +196,6 @@ class InputManager:
self.env["input"]["curr_input"] = sorted(
self.env["input"]["curr_input"]
)
elif len(self.env["input"]["curr_input"]) == 0:
self.env["input"]["shortcut_repeat"] = 1
self.lastInputTime = time.time()
elif event_data["event_state"] == 1:
if not event_data["event_name"] in self.env["input"]["curr_input"]:
@@ -199,19 +204,18 @@ class InputManager:
self.env["input"]["curr_input"] = sorted(
self.env["input"]["curr_input"]
)
if len(self.lastDeepestInput) < len(
self.env["input"]["curr_input"]
):
self.set_last_deepest_input(
self.env["input"]["curr_input"].copy()
)
elif self.lastDeepestInput == self.env["input"]["curr_input"]:
if self.lastDeepestInput == self.env["input"]["curr_input"]:
if time.time() - self.lastInputTime <= self.env["runtime"][
"SettingsManager"
].get_setting_as_float("keyboard", "double_tap_timeout"):
self.env["input"]["shortcut_repeat"] += 1
else:
self.env["input"]["shortcut_repeat"] = 1
else:
self.env["input"]["shortcut_repeat"] = 1
self.set_last_deepest_input(
self.env["input"]["curr_input"].copy()
)
self.handle_led_states(event_data)
self.lastInputTime = time.time()
elif event_data["event_state"] == 2:
@@ -273,6 +277,18 @@ class InputManager:
return False
return True
def set_help_capture(self, enabled):
try:
return self.env["runtime"]["InputDriver"].set_help_capture(
enabled
)
except Exception as error:
self.env["runtime"]["DebugManager"].write_debug_out(
"InputManager could not change help capture: " + str(error),
debug.DebugLevel.ERROR,
)
return not enabled
def handle_plug_input_device(self, event_data):
for deviceEntry in event_data:
self.update_input_devices(deviceEntry["device"])
@@ -468,6 +484,16 @@ class InputManager:
self.env["input"]["curr_input"][0] == "KEY_SCRIPT"
)
def curr_input_has_command_modifier(self):
current_input = self.env["input"]["curr_input"]
return "KEY_FENRIR" in current_input or "KEY_SCRIPT" in current_input
def curr_input_is_modifier_prefix(self):
current_input = self.env["input"]["curr_input"]
return bool(current_input) and all(
key_name in SHORTCUT_MODIFIER_KEYS for key_name in current_input
)
def is_fenrir_key(self, event_name):
return event_name in self.env["input"]["fenrir_key"]
@@ -562,6 +588,9 @@ class InputManager:
self.env["bindings"][
str([1, ["KEY_F1", "KEY_FENRIR"]])
] = "TOGGLE_TUTORIAL_MODE"
self.env["rawBindings"][
str([1, ["KEY_F1", "KEY_FENRIR"]])
] = [1, ["KEY_F1", "KEY_FENRIR"]]
def is_valid_key(self, key):
return key in inputData.key_names
+175 -297
View File
@@ -4,135 +4,19 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import subprocess
import time
from fenrirscreenreader.core import debug
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.core.settingsData import settings_data
from fenrirscreenreader.utils.speechd_utils import (
get_synthesis_voice_name,
parse_synthesis_voice_line,
)
class SpeechHelperMixin:
"""Helper methods for querying speech-dispatcher modules and voices.
Provides caching and query functionality for speech-dispatcher module
and voice enumeration, reusing proven logic from voice_browser.py.
"""
class QuickMenuManager:
def __init__(self):
self._modules_cache = None
self._voices_cache = {} # {module_name: [voice_list]}
self._cache_timestamp = 0
self._cache_timeout = 300 # 5 minutes
def get_speechd_modules(self):
"""Get available speech-dispatcher modules (cached).
Returns:
list: Available module names (e.g., ['espeak-ng', 'festival'])
"""
now = time.time()
# Return cached if valid
if (self._modules_cache and
(now - self._cache_timestamp) < self._cache_timeout):
return self._modules_cache
# Query spd-say
try:
result = subprocess.run(
["spd-say", "-O"],
capture_output=True,
text=True,
timeout=8
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
self._modules_cache = [
line.strip() for line in lines[1:]
if line.strip() and line.strip().lower() != "dummy"
]
self._cache_timestamp = now
return self._modules_cache
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
(f"QuickMenuManager get_speechd_modules: "
f"Error querying modules: {e}"),
debug.DebugLevel.ERROR
)
return []
def get_module_voices(self, module):
"""Get voices for a specific module (cached per-module).
Args:
module (str): Module name (e.g., 'espeak-ng')
Returns:
list: Available voice names for this module
"""
# Return cached if available
if module in self._voices_cache:
return self._voices_cache[module]
# Query spd-say
try:
result = subprocess.run(
["spd-say", "-o", module, "-L"],
capture_output=True,
text=True,
timeout=8
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
voices = []
for line in lines[1:]:
if not line.strip():
continue
if module.lower() == "voxin":
# For Voxin, store voice name with language
voice_data = self._process_voxin_voice(line)
if voice_data:
voices.append(voice_data)
else:
voice = get_synthesis_voice_name(module, line)
if voice:
voices.append(voice)
self._voices_cache[module] = voices
return voices
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
(f"QuickMenuManager get_module_voices: "
f"Error querying voices for {module}: {e}"),
debug.DebugLevel.ERROR
)
return []
def _process_voxin_voice(self, voice_line):
"""Process Voxin voice format with language information.
Args:
voice_line (str): Raw line from spd-say -o voxin -L output
Format: NAME LANGUAGE VARIANT
Returns:
str: Voice name with language encoded (e.g., 'daniel-embedded-high|en-GB')
"""
voice_data = parse_synthesis_voice_line(voice_line)
if voice_data is None:
return None
voice_name, language, _variant = voice_data
if not language:
return None
# Encode language with voice for later extraction
return f"{voice_name}|{language}"
self.position = 0
self.quickMenu = []
self.settings = settings_data
self._module_request_pending = False
self._voice_requests_pending = set()
def _select_default_voice(self, voices):
"""Select a sensible default voice from list, preferring user's
@@ -202,18 +86,7 @@ class SpeechHelperMixin:
return voices[0]
def invalidate_speech_cache(self):
"""Clear cached module and voice data."""
self._modules_cache = None
self._voices_cache = {}
self._cache_timestamp = 0
class QuickMenuManager(SpeechHelperMixin):
def __init__(self):
SpeechHelperMixin.__init__(self)
self.position = 0
self.quickMenu = []
self.settings = settings_data
self.env["runtime"]["SpeechDiscoveryManager"].invalidate_cache()
def initialize(self, environment):
self.env = environment
@@ -420,197 +293,202 @@ class QuickMenuManager(SpeechHelperMixin):
return True
def cycle_speech_module(self, direction):
"""Cycle to next/previous speech-dispatcher module.
if self._module_request_pending:
return False
discovery = self.env["runtime"]["SpeechDiscoveryManager"]
modules = discovery.get_cached_modules()
if modules is not None:
return self._continue_module_cycle(direction, modules, False)
Args:
direction (str): 'next' or 'prev'
Returns:
bool: True if successful, False otherwise
"""
try:
# Get available modules
modules = self.get_speechd_modules()
if not modules:
self.env["runtime"]["OutputManager"].present_text(
"No modules available", interrupt=True
)
return False
# Get current module
current_module = self.env["runtime"]["SettingsManager"].get_setting(
"speech", "module"
self._module_request_pending = True
self.env["runtime"]["OutputManager"].present_text(
"Loading speech modules", interrupt=True
)
discovery.request_modules(
lambda found, error: self._continue_module_cycle(
direction, found, True, error
)
)
return False
# Find current index
try:
current_index = (modules.index(current_module)
if current_module else 0)
except ValueError:
current_index = 0
# Cycle to next/previous
if direction == "next":
new_index = (current_index + 1) % len(modules)
else: # prev
new_index = (current_index - 1) % len(modules)
new_module = modules[new_index]
# Update setting (runtime only)
self.env["runtime"]["SettingsManager"].set_setting(
"speech", "module", new_module
)
# Select sensible default voice for new module
voices = self.get_module_voices(new_module)
if voices:
default_voice = self._select_default_voice(voices)
# Parse voice name and language for modules like Voxin
voice_name = default_voice
voice_lang = None
if "|" in default_voice:
voice_name, voice_lang = default_voice.split("|", 1)
self.env["runtime"]["SettingsManager"].set_setting(
"speech", "voice", voice_name
)
# Apply voice to speech driver immediately
if "SpeechDriver" in self.env["runtime"]:
try:
self.env["runtime"]["SpeechDriver"].set_module(
new_module
)
# Set language first if available
if voice_lang:
self.env["runtime"]["SpeechDriver"].set_language(
voice_lang
)
# Then set voice
self.env["runtime"]["SpeechDriver"].set_voice(
voice_name
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
(f"QuickMenuManager cycle_speech_module: "
f"Error applying voice: {e}"),
debug.DebugLevel.ERROR
)
# Announce new module
def _continue_module_cycle(
self, direction, modules, announce_result, error=""
):
if error:
self._module_request_pending = False
self._report_discovery_error("module", error)
return False
if not modules:
self._module_request_pending = False
self.env["runtime"]["OutputManager"].present_text(
new_module, interrupt=True
)
return True
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
f"QuickMenuManager cycle_speech_module: Error: {e}",
debug.DebugLevel.ERROR
"No modules available", interrupt=True
)
return False
def cycle_speech_voice(self, direction):
"""Cycle to next/previous voice for current module.
Args:
direction (str): 'next' or 'prev'
Returns:
bool: True if successful, False otherwise
"""
try:
# Get current module
current_module = self.env["runtime"]["SettingsManager"].get_setting(
"speech", "module"
)
try:
current_index = (
modules.index(current_module) if current_module else 0
)
except ValueError:
current_index = 0
if direction == "next":
new_index = (current_index + 1) % len(modules)
else:
new_index = (current_index - 1) % len(modules)
new_module = modules[new_index]
discovery = self.env["runtime"]["SpeechDiscoveryManager"]
voices = discovery.get_cached_voices(new_module)
if voices is not None:
self._module_request_pending = False
return self._apply_module(
new_module, voices, announce_result
)
self._module_request_pending = True
discovery.request_voices(
new_module,
lambda _module, found, voice_error: self._finish_module_cycle(
new_module, found, voice_error
),
)
return False
except Exception as error:
self._module_request_pending = False
self.env["runtime"]["DebugManager"].write_debug_out(
f"QuickMenuManager cycle_speech_module: Error: {error}",
debug.DebugLevel.ERROR,
)
return False
def _finish_module_cycle(self, module, voices, error):
self._module_request_pending = False
if error:
self.env["runtime"]["DebugManager"].write_debug_out(
f"QuickMenuManager voice discovery failed: {error}",
debug.DebugLevel.ERROR,
)
self._apply_module(module, voices, False)
self.env["runtime"]["OutputManager"].present_text(
f"{module}; voice discovery failed", interrupt=True
)
return
self._apply_module(module, voices, True)
def _apply_module(self, module, voices, announce_result):
settings_manager = self.env["runtime"]["SettingsManager"]
settings_manager.set_setting("speech", "module", module)
speech_driver = self.env["runtime"].get("SpeechDriver")
if speech_driver:
speech_driver.set_module(module)
if voices:
default_voice = self._select_default_voice(voices)
voice_name, voice_language = self._split_voice(default_voice)
settings_manager.set_setting("speech", "voice", voice_name)
if speech_driver:
if voice_language:
speech_driver.set_language(voice_language)
speech_driver.set_voice(voice_name)
if announce_result:
self.env["runtime"]["OutputManager"].present_text(
module, interrupt=True
)
return True
def cycle_speech_voice(self, direction):
try:
current_module = self.env["runtime"]["SettingsManager"].get_setting(
"speech", "module"
)
if not current_module:
self.env["runtime"]["OutputManager"].present_text(
"No module selected", interrupt=True
)
return False
# Get available voices for this module
voices = self.get_module_voices(current_module)
if not voices:
self.env["runtime"]["OutputManager"].present_text(
f"No voices for module {current_module}", interrupt=True
)
discovery = self.env["runtime"]["SpeechDiscoveryManager"]
voices = discovery.get_cached_voices(current_module)
if voices is not None:
return self._apply_voice(direction, voices, False)
if current_module in self._voice_requests_pending:
return False
# Get current voice
current_voice = self.env["runtime"]["SettingsManager"].get_setting(
"speech", "voice"
self._voice_requests_pending.add(current_module)
self.env["runtime"]["OutputManager"].present_text(
f"Loading voices for {current_module}", interrupt=True
)
# Find current index (handle Voxin voice|language format)
current_index = 0
if current_voice:
try:
# Try exact match first
current_index = voices.index(current_voice)
except ValueError:
# For Voxin, compare just the voice name part
for i, voice in enumerate(voices):
voice_name = voice.split("|")[0] if "|" in voice else voice
if voice_name == current_voice:
current_index = i
break
# Cycle to next/previous
if direction == "next":
new_index = (current_index + 1) % len(voices)
else: # prev
new_index = (current_index - 1) % len(voices)
new_voice = voices[new_index]
# Parse voice name and language for modules like Voxin
voice_name = new_voice
voice_lang = None
if "|" in new_voice:
# Format: "voicename|language" (e.g., "daniel-embedded-high|en-GB")
voice_name, voice_lang = new_voice.split("|", 1)
# Update setting (runtime only) - store the voice name only
self.env["runtime"]["SettingsManager"].set_setting(
"speech", "voice", voice_name
discovery.request_voices(
current_module,
lambda module, found, error: self._finish_voice_cycle(
direction, module, found, error
),
)
return False
except Exception as error:
self.env["runtime"]["DebugManager"].write_debug_out(
f"QuickMenuManager cycle_speech_voice: Error: {error}",
debug.DebugLevel.ERROR,
)
return False
# Apply voice to speech driver immediately
if "SpeechDriver" in self.env["runtime"]:
try:
# Set language first if available
if voice_lang:
self.env["runtime"]["SpeechDriver"].set_language(
voice_lang
)
# Then set voice
self.env["runtime"]["SpeechDriver"].set_voice(voice_name)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
(f"QuickMenuManager cycle_speech_voice: "
f"Error applying voice: {e}"),
debug.DebugLevel.ERROR
)
def _finish_voice_cycle(self, direction, module, voices, error):
self._voice_requests_pending.discard(module)
if error:
self._report_discovery_error("voice", error)
return
self._apply_voice(direction, voices, True)
# Announce new voice (voice name only, not language)
def _apply_voice(self, direction, voices, announce_result):
if not voices:
self.env["runtime"]["OutputManager"].present_text(
"No voices available", interrupt=True
)
return False
settings_manager = self.env["runtime"]["SettingsManager"]
current_voice = settings_manager.get_setting("speech", "voice")
current_index = 0
for voice_index, voice in enumerate(voices):
voice_name, _language = self._split_voice(voice)
if voice == current_voice or voice_name == current_voice:
current_index = voice_index
break
if direction == "next":
new_index = (current_index + 1) % len(voices)
else:
new_index = (current_index - 1) % len(voices)
voice_name, voice_language = self._split_voice(voices[new_index])
settings_manager.set_setting("speech", "voice", voice_name)
speech_driver = self.env["runtime"].get("SpeechDriver")
if speech_driver:
if voice_language:
speech_driver.set_language(voice_language)
speech_driver.set_voice(voice_name)
if announce_result:
self.env["runtime"]["OutputManager"].present_text(
voice_name, interrupt=True
)
return True
return True
@staticmethod
def _split_voice(voice):
if "|" in voice:
return tuple(voice.split("|", 1))
return voice, None
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
f"QuickMenuManager cycle_speech_voice: Error: {e}",
debug.DebugLevel.ERROR
)
return False
def _report_discovery_error(self, kind, error):
self.env["runtime"]["DebugManager"].write_debug_out(
f"QuickMenuManager {kind} discovery failed: {error}",
debug.DebugLevel.ERROR,
)
self.env["runtime"]["OutputManager"].present_text(
f"{kind.capitalize()} discovery failed", interrupt=True
)
def get_current_entry(self):
if len(self.quickMenu) == 0:
@@ -22,6 +22,8 @@ runtime_data = {
"SettingsManager": None,
"FenrirManager": None,
"EventManager": None,
"BackgroundTaskManager": None,
"ProcessManager": None,
"SpeechDiscoveryManager": None,
"DiffReviewManager": None,
}
@@ -12,6 +12,7 @@ from configparser import ConfigParser
from fenrirscreenreader.core import applicationManager
from fenrirscreenreader.core import attributeManager
from fenrirscreenreader.core import barrierManager
from fenrirscreenreader.core import backgroundTaskManager
from fenrirscreenreader.core import clipboardSyncManager
from fenrirscreenreader.core import commandManager
from fenrirscreenreader.core import cursorManager
@@ -32,6 +33,7 @@ from fenrirscreenreader.core import remoteManager
from fenrirscreenreader.core import sayAllManager
from fenrirscreenreader.core import screenManager
from fenrirscreenreader.core import speechHistoryManager
from fenrirscreenreader.core import speechDiscoveryManager
from fenrirscreenreader.core import tableManager
from fenrirscreenreader.core import textManager
from fenrirscreenreader.core import vmenuManager
@@ -753,6 +755,11 @@ class SettingsManager:
] = processManager.ProcessManager()
environment["runtime"]["ProcessManager"].initialize(environment)
environment["runtime"][
"BackgroundTaskManager"
] = backgroundTaskManager.BackgroundTaskManager()
environment["runtime"]["BackgroundTaskManager"].initialize(environment)
environment["runtime"]["OutputManager"] = outputManager.OutputManager()
environment["runtime"]["OutputManager"].initialize(environment)
@@ -807,6 +814,10 @@ class SettingsManager:
environment["runtime"]["BarrierManager"].initialize(environment)
environment["runtime"]["SayAllManager"] = sayAllManager.SayAllManager()
environment["runtime"]["SayAllManager"].initialize(environment)
environment["runtime"][
"SpeechDiscoveryManager"
] = speechDiscoveryManager.SpeechDiscoveryManager()
environment["runtime"]["SpeechDiscoveryManager"].initialize(environment)
environment["runtime"]["VmenuManager"] = vmenuManager.VmenuManager()
environment["runtime"]["VmenuManager"].initialize(environment)
environment["runtime"][
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import subprocess
import time
from fenrirscreenreader.core import debug
from fenrirscreenreader.utils.speechd_utils import (
get_synthesis_voice_name,
parse_synthesis_voice_line,
)
MODULE_QUERY_TIMEOUT = 5
VOICE_QUERY_TIMEOUT = 8
VOICE_TEST_TIMEOUT = 8
def query_speechd_modules(timeout=MODULE_QUERY_TIMEOUT):
"""Return available Speech Dispatcher output modules."""
result = subprocess.run(
["spd-say", "-O"],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or "module discovery failed")
return [
line.strip()
for line in result.stdout.splitlines()[1:]
if line.strip() and line.strip().lower() != "dummy"
]
def query_speechd_voices(module, timeout=VOICE_QUERY_TIMEOUT):
"""Return the synthesis voices exposed by one output module."""
result = subprocess.run(
["spd-say", "-o", module, "-L"],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
if result.returncode != 0:
raise RuntimeError(
result.stderr.strip() or f"voice discovery failed for {module}"
)
voices = []
for line in result.stdout.splitlines()[1:]:
if not line.strip():
continue
voice = get_synthesis_voice_name(module, line)
if module.lower() == "voxin":
voice_data = parse_synthesis_voice_line(line)
if voice_data is not None:
voice_name, language, _variant = voice_data
voice = f"{voice_name}|{language}" if language else None
if voice:
voices.append(voice)
return voices
def test_speechd_voice(module, voice, message, timeout=VOICE_TEST_TIMEOUT):
"""Play one bounded voice test and return when Speech Dispatcher finishes."""
result = subprocess.run(
["spd-say", "-C", "-w", "-o", module, "-y", voice, message],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or "voice test failed")
return True
class SpeechDiscoveryManager:
"""Cache and coordinate asynchronous Speech Dispatcher discovery."""
def __init__(self):
self.env = None
self.cache_timeout = 300
self._modules = None
self._modules_timestamp = 0
self._voices = {}
self._voice_timestamps = {}
self._module_callbacks = []
self._voice_callbacks = {}
self._task_ids = set()
self._module_task_id = None
self._voice_task_ids = {}
def initialize(self, environment):
self.env = environment
def shutdown(self):
task_manager = self.env["runtime"].get("BackgroundTaskManager")
if task_manager:
for task_id in self._task_ids:
task_manager.cancel_task(task_id)
self._task_ids.clear()
self._module_callbacks = []
self._voice_callbacks = {}
def get_cached_modules(self):
if not self._cache_is_current(self._modules_timestamp):
return None
return list(self._modules) if self._modules is not None else None
def get_cached_voices(self, module):
if not self._cache_is_current(self._voice_timestamps.get(module, 0)):
return None
voices = self._voices.get(module)
return list(voices) if voices is not None else None
def request_modules(self, callback, refresh=False):
modules = None if refresh else self.get_cached_modules()
if modules is not None:
callback(modules, "")
return None
self._module_callbacks.append(callback)
if self._module_task_id is not None:
return self._module_task_id
task_id = self._submit_task(query_speechd_modules, self._finish_modules)
if task_id is None:
self._finish_modules(self._submission_failure())
return None
self._module_task_id = task_id
return task_id
def request_voices(self, module, callback, refresh=False):
voices = None if refresh else self.get_cached_voices(module)
if voices is not None:
callback(module, voices, "")
return None
callbacks = self._voice_callbacks.setdefault(module, [])
callbacks.append(callback)
if module in self._voice_task_ids:
return self._voice_task_ids[module]
task_id = self._submit_task(
query_speechd_voices,
lambda result: self._finish_voices(module, result),
module,
)
if task_id is None:
self._finish_voices(module, self._submission_failure())
return None
self._voice_task_ids[module] = task_id
return task_id
def request_voice_test(self, module, voice, message, callback):
task_id = self._submit_task(
test_speechd_voice,
lambda result: callback(
bool(result.get("succeeded")), result.get("error", "")
),
module,
voice,
message,
)
if task_id is None:
callback(False, "background task manager is unavailable")
return task_id
def invalidate_cache(self):
self._modules = None
self._modules_timestamp = 0
self._voices = {}
self._voice_timestamps = {}
def _cache_is_current(self, timestamp):
return timestamp > 0 and time.time() - timestamp < self.cache_timeout
def _submit_task(self, function, callback, *args):
task_manager = self.env["runtime"]["BackgroundTaskManager"]
def finish(result):
self._task_ids.discard(result.get("task_id"))
callback(result)
task_id = task_manager.submit_task(function, finish, *args)
if task_id is None:
self.env["runtime"]["DebugManager"].write_debug_out(
"SpeechDiscoveryManager could not submit background task",
debug.DebugLevel.ERROR,
)
return None
self._task_ids.add(task_id)
return task_id
@staticmethod
def _submission_failure():
return {
"task_id": None,
"succeeded": False,
"value": None,
"error": "background task manager is unavailable",
}
def _finish_modules(self, result):
self._module_task_id = None
callbacks = self._module_callbacks
self._module_callbacks = []
error = result.get("error", "")
modules = []
if result.get("succeeded"):
modules = list(result.get("value") or [])
self._modules = modules
self._modules_timestamp = time.time()
self._run_callbacks(callbacks, list(modules), error)
def _finish_voices(self, module, result):
self._voice_task_ids.pop(module, None)
callbacks = self._voice_callbacks.pop(module, [])
error = result.get("error", "")
voices = []
if result.get("succeeded"):
voices = list(result.get("value") or [])
self._voices[module] = voices
self._voice_timestamps[module] = time.time()
self._run_callbacks(callbacks, module, list(voices), error)
def _run_callbacks(self, callbacks, *args):
for callback in callbacks:
try:
callback(*args)
except Exception as error:
self.env["runtime"]["DebugManager"].write_debug_out(
"SpeechDiscoveryManager callback failed: " + str(error),
debug.DebugLevel.ERROR,
)
+1 -1
View File
@@ -4,5 +4,5 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
version = "2026.08.06"
version = "2026.08.15"
code_name = "master"
@@ -61,20 +61,31 @@ class driver(inputDriver):
"Escape": "KEY_ESC",
"space": "KEY_SPACE",
"minus": "KEY_MINUS",
"-": "KEY_MINUS",
"underscore": "KEY_MINUS",
"_": "KEY_MINUS",
"equal": "KEY_EQUAL",
"=": "KEY_EQUAL",
"plus": "KEY_EQUAL",
"+": "KEY_EQUAL",
"bracketleft": "KEY_LEFTBRACE",
"[": "KEY_LEFTBRACE",
"bracketright": "KEY_RIGHTBRACE",
"]": "KEY_RIGHTBRACE",
"backslash": "KEY_BACKSLASH",
"\\": "KEY_BACKSLASH",
"semicolon": "KEY_SEMICOLON",
";": "KEY_SEMICOLON",
"apostrophe": "KEY_APOSTROPHE",
"'": "KEY_APOSTROPHE",
"grave": "KEY_GRAVE",
"`": "KEY_GRAVE",
"comma": "KEY_COMMA",
",": "KEY_COMMA",
"period": "KEY_DOT",
".": "KEY_DOT",
"slash": "KEY_SLASH",
"/": "KEY_SLASH",
"Shift_L": "KEY_LEFTSHIFT",
"Shift_R": "KEY_RIGHTSHIFT",
"Control_L": "KEY_LEFTCTRL",
@@ -148,6 +159,13 @@ class driver(inputDriver):
modifier_key_names = set(modifier_masks.keys())
canonical_modifier_masks = (
("KEY_SHIFT", X.ShiftMask if X else 1),
("KEY_CTRL", X.ControlMask if X else 4),
("KEY_ALT", X.Mod1Mask if X else 8),
("KEY_META", X.Mod4Mask if X else 64),
)
def __init__(self):
inputDriver.__init__(self)
self.display = None
@@ -163,6 +181,10 @@ class driver(inputDriver):
self.failed_grabs = 0
self.modifier_state = 0
self.modifier_interrupt_state = 0
self.command_key_active = False
self.chord_modifiers = set()
self.chord_keys = set()
self.help_keyboard_grabbed = False
def initialize(self, environment):
self.env = environment
@@ -204,6 +226,7 @@ class driver(inputDriver):
self._initialized = True
def shutdown(self):
self.set_help_capture(False)
self.ungrab_all_devices()
try:
if self.display:
@@ -297,6 +320,10 @@ class driver(inputDriver):
def handle_x_event(self, event, event_queue):
event_type = getattr(event, "type", None)
if event_type in [X.FocusIn, X.FocusOut] and getattr(
event, "mode", X.NotifyNormal
) in [X.NotifyGrab, X.NotifyUngrab]:
return
if event_type == X.FocusIn:
self.active = True
self.clear_event_buffer()
@@ -316,6 +343,12 @@ class driver(inputDriver):
if not self.should_emit_key(key_name):
return
self.update_modifier_state_from_event(input_event)
is_command_key = self.is_command_key(key_name)
if is_command_key and input_event["event_state"] == 1:
self.start_command_chord(input_event, event_queue)
elif self.command_key_active and not is_command_key:
self.track_chord_key(input_event)
self.track_chord_modifier(input_event)
self.write_debug(
"x11Driver key event "
+ key_name
@@ -332,6 +365,86 @@ class driver(inputDriver):
"data": input_event,
}
)
if is_command_key and input_event["event_state"] == 0:
self.finish_command_chord(input_event, event_queue)
def is_command_key(self, key_name):
if key_name in self.fenrir_keys or key_name in self.env["input"].get(
"script_key", []
):
return True
converted_name = self.env["runtime"][
"InputManager"
].convert_event_name(key_name)
return converted_name in {"KEY_FENRIR", "KEY_SCRIPT"}
def start_command_chord(self, input_event, event_queue):
self.command_key_active = True
self.chord_keys.clear()
raw_state = input_event.get("event_raw_state", 0)
current_input = self.env["input"]["curr_input"]
for key_name, modifier_mask in self.canonical_modifier_masks:
if not raw_state & modifier_mask:
continue
self.chord_modifiers.add(key_name)
if key_name not in current_input:
self.queue_synthetic_key(
key_name, 1, input_event, event_queue
)
def track_chord_modifier(self, input_event):
key_name = self.canonical_modifier_name(input_event["event_name"])
if key_name is None:
return
if input_event["event_state"] == 1:
self.chord_modifiers.add(key_name)
elif input_event["event_state"] == 0:
self.chord_modifiers.discard(key_name)
def track_chord_key(self, input_event):
key_name = input_event["event_name"]
if key_name in self.modifier_key_names:
return
if input_event["event_state"] == 1:
self.chord_keys.add(key_name)
elif input_event["event_state"] == 0:
self.chord_keys.discard(key_name)
def canonical_modifier_name(self, key_name):
modifier_mask = self.modifier_masks.get(key_name, 0)
for canonical_name, canonical_mask in self.canonical_modifier_masks:
if modifier_mask == canonical_mask:
return canonical_name
return None
def finish_command_chord(self, input_event, event_queue):
for key_name in sorted(self.chord_keys):
self.queue_synthetic_key(key_name, 0, input_event, event_queue)
self.chord_keys.clear()
for key_name in sorted(self.chord_modifiers):
self.queue_synthetic_key(key_name, 0, input_event, event_queue)
self.chord_modifiers.clear()
self.command_key_active = False
def queue_synthetic_key(
self, key_name, event_state, source_event, event_queue
):
synthetic_event = {
"event_name": key_name,
"event_value": 0,
"event_sec": source_event["event_sec"],
"event_usec": source_event["event_usec"],
"event_state": event_state,
"event_type": 0,
"event_raw_state": source_event.get("event_raw_state", 0),
"event_x_time": source_event.get("event_x_time", X.CurrentTime),
}
event_queue.put(
{
"Type": FenrirEventType.keyboard_input,
"data": synthetic_event,
}
)
def event_is_in_target_tree(self, event):
event_window = getattr(event, "event", None)
@@ -698,6 +811,9 @@ class driver(inputDriver):
return []
def reset_input_state(self):
self.command_key_active = False
self.chord_modifiers.clear()
self.chord_keys.clear()
try:
self.env["runtime"]["InputManager"].reset_input_state()
except Exception:
@@ -758,6 +874,54 @@ class driver(inputDriver):
pass
return True
def set_help_capture(self, enabled):
if not self._initialized or not self.display or not self.window:
return not enabled
if enabled:
if self.help_keyboard_grabbed:
return True
try:
grab_status = self.window.grab_keyboard(
False,
X.GrabModeAsync,
X.GrabModeAsync,
X.CurrentTime,
)
self.display.flush()
self.help_keyboard_grabbed = grab_status == X.GrabSuccess
if not self.help_keyboard_grabbed:
self.write_debug(
"x11Driver help keyboard grab failed with status "
+ str(grab_status),
debug.DebugLevel.WARNING,
)
return self.help_keyboard_grabbed
except Exception as error:
self.help_keyboard_grabbed = False
self.write_debug(
"x11Driver help keyboard grab failed: " + str(error),
debug.DebugLevel.ERROR,
)
return False
if self.help_keyboard_grabbed:
for attempt in range(1, 4):
try:
self.display.ungrab_keyboard(X.CurrentTime)
self.display.sync()
break
except Exception as error:
self.write_debug(
"x11Driver help keyboard ungrab attempt "
+ str(attempt)
+ " failed: "
+ str(error),
debug.DebugLevel.ERROR,
)
else:
return False
self.help_keyboard_grabbed = False
return True
def remove_all_devices(self):
self.ungrab_all_devices()
@@ -66,6 +66,23 @@ class driver(speech_driver):
self._sd = speechd.SSIPClient("fenrir-dev")
self._punct = speechd.PunctuationMode()
self._is_initialized = True
configured_module = self.env["runtime"][
"SettingsManager"
].get_setting("speech", "module")
if not configured_module:
try:
active_module = self._sd.get_output_module()
if active_module:
self.module = active_module
self.env["runtime"]["SettingsManager"].set_setting(
"speech", "module", active_module
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"SpeechDriver get_output_module:" + str(e),
debug.DebugLevel.ERROR,
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"SpeechDriver initialize:" + str(e), debug.DebugLevel.ERROR
@@ -226,4 +243,3 @@ class driver(speech_driver):
self.env["runtime"]["DebugManager"].write_debug_out(
"SpeechDriver set_volume:" + str(e), debug.DebugLevel.ERROR
)
+303
View File
@@ -0,0 +1,303 @@
from unittest.mock import Mock
from fenrirscreenreader.commands.commands import apply_tested_voice
from fenrirscreenreader.commands.commands import voice_browser
from fenrirscreenreader.commands.commands import voice_browser_safe
from fenrirscreenreader.core import dynamicVoiceMenu
from fenrirscreenreader.core.quickMenuManager import QuickMenuManager
class FakeDiscoveryManager:
def __init__(self):
self.cached_modules = None
self.cached_voices = {}
self.module_callbacks = []
self.voice_callbacks = {}
self.test_callbacks = []
def get_cached_modules(self):
return self.cached_modules
def get_cached_voices(self, module):
return self.cached_voices.get(module)
def request_modules(self, callback, refresh=False):
self.module_callbacks.append(callback)
return len(self.module_callbacks)
def request_voices(self, module, callback, refresh=False):
self.voice_callbacks.setdefault(module, []).append(callback)
return len(self.voice_callbacks[module])
def request_voice_test(self, module, voice, message, callback):
self.test_callbacks.append((module, voice, message, callback))
return len(self.test_callbacks)
def complete_modules(self, modules, error=""):
self.cached_modules = modules
callbacks = self.module_callbacks
self.module_callbacks = []
for callback in callbacks:
callback(list(modules), error)
def complete_voices(self, module, voices, error=""):
self.cached_voices[module] = voices
callbacks = self.voice_callbacks.pop(module, [])
for callback in callbacks:
callback(module, list(voices), error)
def complete_test(self, succeeded, error=""):
_module, _voice, _message, callback = self.test_callbacks.pop(0)
callback(succeeded, error)
class RuntimeSettings:
def __init__(
self,
module="rhvoice",
voice="alan",
language="en-us",
driver="speechdDriver",
):
self.values = {
("speech", "driver"): driver,
("speech", "module"): module,
("speech", "voice"): voice,
("speech", "language"): language,
}
def get_setting(self, section, setting):
return self.values[(section, setting)]
def set_setting(self, section, setting, value):
self.values[(section, setting)] = value
def create_voice_environment(discovery):
return {
"runtime": {
"DebugManager": Mock(),
"OutputManager": Mock(),
"SpeechDiscoveryManager": discovery,
},
"commandBuffer": {},
}
def test_safe_browser_records_voice_only_after_successful_test():
discovery = FakeDiscoveryManager()
environment = create_voice_environment(discovery)
browser = voice_browser_safe.command()
browser.initialize(environment)
browser.run()
assert "lastTestedVoice" not in environment["commandBuffer"]
discovery.complete_modules(["rhvoice"])
discovery.complete_voices("rhvoice", ["alan", "slt"])
assert "lastTestedVoice" not in environment["commandBuffer"]
discovery.complete_test(True)
assert environment["commandBuffer"]["lastTestedModule"] == "rhvoice"
assert environment["commandBuffer"]["lastTestedVoice"] == "alan"
def test_safe_browser_does_not_record_failed_test():
discovery = FakeDiscoveryManager()
environment = create_voice_environment(discovery)
browser = voice_browser_safe.command()
browser.initialize(environment)
browser.run()
discovery.complete_modules(["rhvoice"])
discovery.complete_voices("rhvoice", ["alan"])
discovery.complete_test(False, "test timed out")
assert "lastTestedVoice" not in environment["commandBuffer"]
def test_safe_browser_keeps_voxin_language_separate():
discovery = FakeDiscoveryManager()
environment = create_voice_environment(discovery)
browser = voice_browser_safe.command()
browser.initialize(environment)
browser.run()
discovery.complete_modules(["voxin"])
discovery.complete_voices("voxin", ["Nathan|en-US"])
discovery.complete_test(True)
assert environment["commandBuffer"]["lastTestedVoice"] == "Nathan"
assert environment["commandBuffer"]["lastTestedLanguage"] == "en-US"
def test_interactive_browser_keeps_voxin_language_through_test_and_apply():
discovery = FakeDiscoveryManager()
environment = create_voice_environment(discovery)
environment["runtime"]["SettingsManager"] = Mock(
settings={"speech": {}},
)
browser = voice_browser.command()
browser.initialize(environment)
browser.modules = ["voxin"]
browser.voices = ["Nathan|en-US"]
browser.test_voice()
assert discovery.test_callbacks[0][1] == "Nathan"
discovery.complete_test(True)
assert environment["commandBuffer"]["lastTestedVoice"] == "Nathan"
assert environment["commandBuffer"]["lastTestedLanguage"] == "en-US"
browser.apply_voice()
speech_settings = environment["runtime"]["SettingsManager"].settings[
"speech"
]
assert speech_settings["voice"] == "Nathan"
assert speech_settings["language"] == "en-US"
def test_apply_tested_voice_applies_voxin_language():
settings = RuntimeSettings()
speech_driver = Mock()
environment = {
"runtime": {
"OutputManager": Mock(),
"SettingsManager": settings,
"SpeechDriver": speech_driver,
},
"commandBuffer": {
"lastTestedModule": "voxin",
"lastTestedVoice": "Nathan",
"lastTestedLanguage": "en-US",
},
}
command = apply_tested_voice.command()
command.initialize(environment)
command.run()
assert settings.get_setting("speech", "language") == "en-US"
speech_driver.set_language.assert_called_once_with("en-US")
def test_dynamic_voice_command_commits_result_on_main_loop_callback():
discovery = FakeDiscoveryManager()
environment = create_voice_environment(discovery)
voice_command = dynamicVoiceMenu.DynamicVoiceCommand(
"rhvoice", "alan", environment
)
voice_command.run()
assert environment["commandBuffer"] == {}
discovery.complete_test(True)
assert environment["commandBuffer"]["pendingVoiceModule"] == "rhvoice"
assert environment["commandBuffer"]["pendingVoiceVoice"] == "alan"
assert environment["commandBuffer"]["voiceTestCompleted"] is True
def test_dynamic_voxin_voice_keeps_language_out_of_voice_name():
discovery = FakeDiscoveryManager()
environment = create_voice_environment(discovery)
voice_command = dynamicVoiceMenu.DynamicVoiceCommand(
"voxin", "Nathan|en-US", environment
)
voice_command.run()
assert discovery.test_callbacks[0][1] == "Nathan"
discovery.complete_test(True)
assert environment["commandBuffer"]["pendingVoiceVoice"] == "Nathan"
assert environment["commandBuffer"]["pendingVoiceLanguage"] == "en-US"
def test_quick_menu_waits_for_discovery_then_applies_module():
discovery = FakeDiscoveryManager()
settings = RuntimeSettings()
output_manager = Mock()
manager = QuickMenuManager()
manager.env = {
"runtime": {
"DebugManager": Mock(),
"OutputManager": output_manager,
"SettingsManager": settings,
"SpeechDriver": Mock(),
"SpeechDiscoveryManager": discovery,
}
}
assert manager.cycle_speech_module("next") is False
assert settings.get_setting("speech", "module") == "rhvoice"
discovery.complete_modules(["rhvoice", "espeak-ng"])
discovery.complete_voices("espeak-ng", ["en-us"])
assert settings.get_setting("speech", "module") == "espeak-ng"
assert settings.get_setting("speech", "voice") == "en-us"
output_manager.present_text.assert_any_call("espeak-ng", interrupt=True)
def test_quick_menu_deduplicates_voice_request_for_module_cycle():
discovery = FakeDiscoveryManager()
discovery.cached_modules = ["rhvoice", "espeak-ng"]
settings = RuntimeSettings()
manager = QuickMenuManager()
manager.env = {
"runtime": {
"DebugManager": Mock(),
"OutputManager": Mock(),
"SettingsManager": settings,
"SpeechDriver": Mock(),
"SpeechDiscoveryManager": discovery,
}
}
assert manager.cycle_speech_module("next") is False
assert manager.cycle_speech_module("next") is False
assert len(discovery.voice_callbacks["espeak-ng"]) == 1
def test_dynamic_vmenu_keeps_loading_entry_until_all_results_arrive():
discovery = FakeDiscoveryManager()
environment = create_voice_environment(discovery)
vmenu_manager = Mock()
vmenu_manager.env = environment
vmenu_manager.menuDict = {}
vmenu_manager._voice_menu_generation = 0
vmenu_manager.get_active.return_value = False
dynamicVoiceMenu.add_dynamic_voice_menus(vmenu_manager)
assert "Loading voices Action" in vmenu_manager.menuDict[
"Voice Browser Menu"
]
discovery.complete_modules(["rhvoice", "espeak-ng"])
discovery.complete_voices("rhvoice", ["alan"])
assert "Loading voices Action" in vmenu_manager.menuDict[
"Voice Browser Menu"
]
discovery.complete_voices("espeak-ng", ["en-us"])
voice_menu = vmenu_manager.menuDict["Voice Browser Menu"]
assert "rhvoice Menu" in voice_menu
assert "espeak-ng Menu" in voice_menu
assert "alan Action" in voice_menu["rhvoice Menu"]
def test_dynamic_vmenu_does_not_replace_active_loading_menu():
discovery = FakeDiscoveryManager()
environment = create_voice_environment(discovery)
vmenu_manager = Mock()
vmenu_manager.env = environment
vmenu_manager.menuDict = {}
vmenu_manager._voice_menu_generation = 0
vmenu_manager.get_active.return_value = False
dynamicVoiceMenu.add_dynamic_voice_menus(vmenu_manager)
vmenu_manager.get_active.return_value = True
discovery.complete_modules(["voxin"])
discovery.complete_voices("voxin", ["Nathan|en-US"])
voice_menu = vmenu_manager.menuDict["Voice Browser Menu"]
assert "Loading voices Action" in voice_menu
assert vmenu_manager._voice_menu_refresh_pending is True
+109
View File
@@ -0,0 +1,109 @@
import threading
import time
from unittest.mock import Mock
from fenrirscreenreader.core.backgroundTaskManager import BackgroundTaskManager
from fenrirscreenreader.core.eventData import FenrirEventType
def wait_for_call(mock, timeout=1.0):
deadline = time.monotonic() + timeout
while not mock.called and time.monotonic() < deadline:
time.sleep(0.01)
assert mock.called
def test_worker_returns_result_through_event_queue_before_callback_runs():
event_manager = Mock()
environment = {
"runtime": {
"DebugManager": Mock(),
"EventManager": event_manager,
}
}
manager = BackgroundTaskManager(worker_count=1)
manager.initialize(environment)
callback = Mock()
main_thread_id = threading.get_ident()
try:
task_id = manager.submit_task(lambda value: value * 2, callback, 21)
wait_for_call(event_manager.put_to_event_queue)
callback.assert_not_called()
event_type, result = event_manager.put_to_event_queue.call_args.args
assert event_type == FenrirEventType.background_task_result
assert result == {
"task_id": task_id,
"succeeded": True,
"value": 42,
"error": "",
}
callback.side_effect = lambda _result: setattr(
callback, "thread_id", threading.get_ident()
)
manager.handle_result(result)
callback.assert_called_once_with(result)
assert callback.thread_id == main_thread_id
finally:
manager.shutdown()
def test_cancelled_task_result_is_not_delivered():
environment = {
"runtime": {
"DebugManager": Mock(),
"EventManager": Mock(),
}
}
manager = BackgroundTaskManager(worker_count=1)
manager.initialize(environment)
callback = Mock()
try:
task_id = manager.submit_task(lambda: "late", callback)
manager.cancel_task(task_id)
manager.handle_result(
{
"task_id": task_id,
"succeeded": True,
"value": "late",
"error": "",
}
)
callback.assert_not_called()
finally:
manager.shutdown()
def test_external_tasks_cannot_starve_default_tasks():
event_manager = Mock()
environment = {
"runtime": {
"DebugManager": Mock(),
"EventManager": event_manager,
}
}
manager = BackgroundTaskManager(worker_count=1, external_worker_count=2)
manager.initialize(environment)
release_external = threading.Event()
external_started = [threading.Event(), threading.Event()]
def block_external(started):
started.set()
release_external.wait(timeout=2)
try:
for started in external_started:
manager.submit_external_task(block_external, Mock(), started)
for started in external_started:
assert started.wait(timeout=1)
manager.submit_task(lambda: "voice result", Mock())
wait_for_call(event_manager.put_to_event_queue)
result = event_manager.put_to_event_queue.call_args.args[1]
assert result["value"] == "voice result"
finally:
release_external.set()
manager.shutdown()
@@ -0,0 +1,72 @@
from unittest.mock import Mock
from fenrirscreenreader.commands.commands import export_clipboard_to_x
from fenrirscreenreader.commands.commands import import_clipboard_from_x
class FakeTaskManager:
def __init__(self):
self.callback = None
self.function = None
self.args = None
def submit_task(self, function, callback, *args):
self.function = function
self.callback = callback
self.args = args
return 7
def cancel_task(self, _task_id):
pass
def test_clipboard_import_changes_history_only_after_result_event():
task_manager = FakeTaskManager()
memory_manager = Mock()
environment = {
"runtime": {
"BackgroundTaskManager": task_manager,
"MemoryManager": memory_manager,
"OutputManager": Mock(),
}
}
command = import_clipboard_from_x.command()
command.initialize(environment)
command.run()
memory_manager.add_value_to_first_index.assert_not_called()
task_manager.callback(
{"task_id": 7, "succeeded": True, "value": "copied text"}
)
memory_manager.add_value_to_first_index.assert_called_once_with(
"clipboardHistory", "copied text"
)
def test_clipboard_export_updates_sync_state_only_after_result_event():
task_manager = FakeTaskManager()
memory_manager = Mock(
is_index_list_empty=Mock(return_value=False),
get_index_list_element=Mock(return_value="copied text"),
)
sync_manager = Mock()
environment = {
"runtime": {
"BackgroundTaskManager": task_manager,
"ClipboardSyncManager": sync_manager,
"DebugManager": Mock(),
"MemoryManager": memory_manager,
"OutputManager": Mock(),
}
}
command = export_clipboard_to_x.command()
command.initialize(environment)
command.run()
sync_manager.mark_written_to_x.assert_not_called()
task_manager.callback(
{"task_id": 7, "succeeded": True, "value": True}
)
sync_manager.mark_written_to_x.assert_called_once_with("copied text")
+85
View File
@@ -3,6 +3,7 @@ from unittest.mock import Mock
import pytest
from fenrirscreenreader.core.clipboardSyncManager import ClipboardSyncManager
from fenrirscreenreader.core.clipboardSyncManager import synchronize_clipboards
def build_env(
@@ -270,3 +271,87 @@ def test_x_clipboard_paste_exception_is_ignored(monkeypatch):
env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called()
env["runtime"]["DebugManager"].write_debug_out.assert_called_once()
@pytest.mark.unit
def test_runtime_sync_applies_worker_result_on_main_loop():
env = build_env(fenrir_text=None)
task_manager = Mock()
task_manager.submit_task.return_value = 12
env["runtime"]["BackgroundTaskManager"] = task_manager
manager = ClipboardSyncManager()
manager.env = env
manager.enabled = True
manager.display = ":1"
manager.running = True
manager.handle_sync_event()
env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called()
callback = task_manager.submit_task.call_args.args[1]
callback(
{
"task_id": 12,
"succeeded": True,
"value": {
"import_text": "from x",
"last_imported_from_x": "from x",
"last_observed_fenrir": "from x",
"last_observed_x": "from x",
},
"error": "",
}
)
env["runtime"]["MemoryManager"].add_value_to_first_index.assert_called_once_with(
"clipboardHistory", "from x"
)
assert manager.last_imported_from_x == "from x"
@pytest.mark.unit
def test_runtime_sync_discards_import_if_fenrir_clipboard_changed():
env = build_env(fenrir_text=None)
task_manager = Mock()
task_manager.submit_task.return_value = 12
env["runtime"]["BackgroundTaskManager"] = task_manager
manager = ClipboardSyncManager()
manager.env = env
manager.display = ":1"
manager.running = True
manager.handle_sync_event()
env["runtime"]["MemoryManager"].is_index_list_empty.return_value = False
env["runtime"]["MemoryManager"].get_index_list_element.return_value = (
"new fenrir copy"
)
callback = task_manager.submit_task.call_args.args[1]
callback(
{
"task_id": 12,
"succeeded": True,
"value": {
"import_text": "stale x text",
"last_imported_from_x": "stale x text",
"last_observed_fenrir": "stale x text",
"last_observed_x": "stale x text",
},
"error": "",
}
)
env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called()
assert manager.last_imported_from_x is None
@pytest.mark.unit
def test_sync_worker_returns_changes_without_manager_access(monkeypatch):
monkeypatch.setattr(
"fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text",
Mock(return_value="from x"),
)
result = synchronize_clipboards(":1", None, None, None)
assert result["import_text"] == "from x"
assert result["last_observed_x"] == "from x"
+422
View File
@@ -0,0 +1,422 @@
from unittest.mock import Mock
import pytest
from fenrirscreenreader.core.eventData import FenrirEventType
from fenrirscreenreader.core.fenrirManager import FenrirManager
from fenrirscreenreader.core.inputManager import InputManager
def create_input_manager():
input_driver = Mock(get_led_state=Mock(return_value=False))
settings_manager = Mock()
settings_manager.get_setting_as_float.return_value = 0.2
manager = InputManager()
manager.env = {
"input": {
"curr_input": [],
"prev_input": [],
"event_buffer": [object()],
"shortcut_repeat": 1,
"old_num_lock": False,
"new_num_lock": False,
"old_caps_lock": False,
"new_caps_lock": False,
"old_scroll_lock": False,
"new_scroll_lock": False,
},
"runtime": {
"DebugManager": Mock(),
"InputDriver": input_driver,
"SettingsManager": settings_manager,
},
}
manager.lastDeepestInput = []
manager.lastInputTime = 0
manager.handle_led_states = Mock()
return manager
def send_input(manager, event_name, event_state):
manager.handle_input_event(
{"event_name": event_name, "event_state": event_state}
)
def create_handle_input_manager(no_key_pressed):
manager = FenrirManager.__new__(FenrirManager)
manager.modifierInput = False
manager.modifier_prefix_input = True
manager.singleKeyCommand = False
manager.command = ""
manager.detect_shortcut_command = Mock()
manager.update_key_forward = Mock()
input_manager = Mock(
convert_event_name=Mock(side_effect=lambda key_name: key_name),
no_key_pressed=Mock(return_value=no_key_pressed),
)
manager.environment = {
"input": {"key_forward": 0},
"runtime": {
"DebugManager": Mock(write_debug_out=Mock()),
"InputManager": input_manager,
"ScreenManager": Mock(is_ignored_screen=Mock(return_value=False)),
"HelpManager": Mock(is_tutorial_mode=Mock(return_value=False)),
"VmenuManager": Mock(get_active=Mock(return_value=False)),
"DiffReviewManager": Mock(is_active=Mock(return_value=False)),
"SpeechHistoryManager": Mock(is_active=Mock(return_value=False)),
"CommandManager": Mock(execute_default_trigger=Mock()),
},
}
return manager, input_manager
@pytest.mark.unit
def test_input_manager_recognizes_conventional_modifier_prefix():
input_manager = InputManager()
input_manager.env = {"input": {"curr_input": ["KEY_CTRL", "KEY_SHIFT"]}}
assert input_manager.curr_input_is_modifier_prefix() is True
input_manager.env["input"]["curr_input"].append("KEY_S")
assert input_manager.curr_input_is_modifier_prefix() is False
@pytest.mark.unit
def test_bare_key_taps_advance_shortcut_repeat(monkeypatch):
manager = create_input_manager()
monkeypatch.setattr(
"fenrirscreenreader.core.inputManager.time.time", lambda: 1.0
)
send_input(manager, "KEY_KP5", 1)
assert manager.env["input"]["shortcut_repeat"] == 1
send_input(manager, "KEY_KP5", 0)
send_input(manager, "KEY_KP5", 1)
assert manager.env["input"]["shortcut_repeat"] == 2
send_input(manager, "KEY_KP5", 0)
send_input(manager, "KEY_KP5", 1)
assert manager.env["input"]["shortcut_repeat"] == 3
@pytest.mark.unit
def test_tutorial_toggle_runs_while_tutorial_mode_is_active():
manager = FenrirManager.__new__(FenrirManager)
command_manager = Mock()
command_manager.command_exists.return_value = False
manager.environment = {
"runtime": {
"HelpManager": Mock(
is_tutorial_mode=Mock(return_value=True)
),
"CommandManager": command_manager,
"ReadAllManager": Mock(is_active=Mock(return_value=False)),
"VmenuManager": Mock(get_active=Mock(return_value=False)),
"SpeechHistoryManager": Mock(is_active=Mock(return_value=False)),
}
}
manager.handle_execute_command({"data": "TOGGLE_TUTORIAL_MODE"})
command_manager.run_command.assert_called_once_with(
"TOGGLE_TUTORIAL_MODE", "commands"
)
command_manager.execute_command.assert_not_called()
@pytest.mark.unit
def test_tutorial_mode_swallows_input_on_ignored_screen():
manager, input_manager = create_handle_input_manager(
no_key_pressed=False
)
manager.environment["runtime"]["HelpManager"].is_tutorial_mode.return_value = (
True
)
manager.environment["runtime"]["ScreenManager"].is_ignored_screen.return_value = (
True
)
manager.handle_input(
{"data": {"event_name": "KEY_A", "event_state": 1}}
)
input_manager.clear_event_buffer.assert_called()
input_manager.write_event_buffer.assert_not_called()
input_manager.key_echo.assert_called_once_with(
{"event_name": "KEY_A", "event_state": 1}
)
manager.detect_shortcut_command.assert_called_once_with()
@pytest.mark.unit
def test_tutorial_mode_promotes_non_fenrir_chord_to_command():
manager = FenrirManager.__new__(FenrirManager)
manager.modifierInput = False
manager.modifier_prefix_input = False
manager.singleKeyCommand = False
manager.command = ""
input_manager = Mock(
is_key_press=Mock(return_value=False),
curr_input_has_command_modifier=Mock(return_value=False),
curr_input_is_modifier_prefix=Mock(return_value=False),
no_key_pressed=Mock(return_value=False),
get_curr_shortcut=Mock(
return_value=str([1, ["KEY_CTRL", "KEY_S"]])
),
get_command_for_shortcut=Mock(return_value="SPELL_CHECK"),
)
event_manager = Mock()
manager.environment = {
"input": {
"key_forward": 0,
"prev_input": ["KEY_CTRL"],
"curr_input": ["KEY_CTRL", "KEY_S"],
},
"runtime": {
"InputManager": input_manager,
"EventManager": event_manager,
"HelpManager": Mock(
is_tutorial_mode=Mock(return_value=True)
),
"VmenuManager": Mock(get_active=Mock(return_value=False)),
"DiffReviewManager": Mock(is_active=Mock(return_value=False)),
"SpeechHistoryManager": Mock(is_active=Mock(return_value=False)),
},
}
manager.detect_shortcut_command()
event_manager.put_to_event_queue.assert_called_once_with(
FenrirEventType.execute_command, "SPELL_CHECK"
)
@pytest.mark.unit
def test_bare_key_taps_resolve_progressive_shortcuts(monkeypatch):
manager = create_input_manager()
monkeypatch.setattr(
"fenrirscreenreader.core.inputManager.time.time", lambda: 1.0
)
manager.env["runtime"]["CursorManager"] = Mock(
should_process_numpad_commands=Mock(return_value=True)
)
manager.env["bindings"] = {
str([1, ["KEY_KP5"]]): "REVIEW_CURR_WORD",
str([2, ["KEY_KP5"]]): "REVIEW_CURR_WORD_SPELL",
str([3, ["KEY_KP5"]]): "REVIEW_CURR_WORD_PHONETIC",
}
resolved_commands = []
for _tap in range(3):
send_input(manager, "KEY_KP5", 1)
shortcut = manager.get_curr_shortcut()
resolved_commands.append(manager.get_command_for_shortcut(shortcut))
send_input(manager, "KEY_KP5", 0)
assert resolved_commands == [
"REVIEW_CURR_WORD",
"REVIEW_CURR_WORD_SPELL",
"REVIEW_CURR_WORD_PHONETIC",
]
@pytest.mark.unit
def test_different_bare_key_starts_new_repeat_sequence(monkeypatch):
manager = create_input_manager()
monkeypatch.setattr(
"fenrirscreenreader.core.inputManager.time.time", lambda: 1.0
)
send_input(manager, "KEY_KP5", 1)
send_input(manager, "KEY_KP5", 0)
send_input(manager, "KEY_KP5", 1)
assert manager.env["input"]["shortcut_repeat"] == 2
send_input(manager, "KEY_KP5", 0)
send_input(manager, "KEY_KP2", 1)
assert manager.env["input"]["shortcut_repeat"] == 1
@pytest.mark.unit
def test_bare_key_tap_after_timeout_starts_new_sequence(monkeypatch):
manager = create_input_manager()
current_time = [1.0]
monkeypatch.setattr(
"fenrirscreenreader.core.inputManager.time.time",
lambda: current_time[0],
)
send_input(manager, "KEY_KP5", 1)
send_input(manager, "KEY_KP5", 0)
current_time[0] = 2.0
send_input(manager, "KEY_KP5", 1)
assert manager.env["input"]["shortcut_repeat"] == 1
@pytest.mark.unit
def test_modifier_chord_taps_still_advance_shortcut_repeat(monkeypatch):
manager = create_input_manager()
monkeypatch.setattr(
"fenrirscreenreader.core.inputManager.time.time", lambda: 1.0
)
send_input(manager, "KEY_FENRIR", 1)
send_input(manager, "KEY_T", 1)
assert manager.env["input"]["shortcut_repeat"] == 1
send_input(manager, "KEY_T", 0)
send_input(manager, "KEY_T", 1)
assert manager.env["input"]["shortcut_repeat"] == 2
@pytest.mark.unit
def test_first_conventional_modifier_starts_deferred_prefix():
manager = FenrirManager.__new__(FenrirManager)
manager.modifierInput = False
manager.modifier_prefix_input = False
manager.singleKeyCommand = False
manager.command = ""
input_manager = Mock(
is_key_press=Mock(return_value=True),
curr_input_has_command_modifier=Mock(return_value=False),
curr_input_is_modifier_prefix=Mock(return_value=True),
get_curr_shortcut=Mock(return_value=str([1, ["KEY_CTRL"]])),
get_command_for_shortcut=Mock(return_value=""),
)
manager.environment = {
"input": {
"key_forward": 0,
"prev_input": [],
"curr_input": ["KEY_CTRL"],
},
"runtime": {
"InputManager": input_manager,
"EventManager": Mock(put_to_event_queue=Mock()),
"VmenuManager": Mock(get_active=Mock(return_value=False)),
"DiffReviewManager": Mock(is_active=Mock(return_value=False)),
"SpeechHistoryManager": Mock(is_active=Mock(return_value=False)),
},
}
manager.detect_shortcut_command()
assert manager.modifier_prefix_input is True
@pytest.mark.unit
def test_fenrir_shortcut_is_dispatched_when_ctrl_is_pressed_first():
manager = FenrirManager.__new__(FenrirManager)
manager.modifierInput = False
manager.modifier_prefix_input = True
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),
curr_input_has_command_modifier=Mock(return_value=True),
get_curr_shortcut=Mock(
side_effect=[
str([1, ["KEY_CTRL", "KEY_FENRIR"]]),
str([1, ["KEY_CTRL", "KEY_FENRIR", "KEY_S"]]),
]
),
get_command_for_shortcut=Mock(side_effect=["", "SAVE_SETTINGS"]),
)
manager.environment = {
"input": {
"key_forward": 0,
"prev_input": ["KEY_CTRL"],
"curr_input": ["KEY_CTRL", "KEY_FENRIR"],
},
"runtime": {
"InputManager": input_manager,
"EventManager": event_manager,
"VmenuManager": Mock(get_active=Mock(return_value=False)),
"DiffReviewManager": Mock(is_active=Mock(return_value=False)),
"SpeechHistoryManager": Mock(is_active=Mock(return_value=False)),
},
}
manager.detect_shortcut_command()
assert manager.modifierInput is True
assert manager.modifier_prefix_input is False
event_manager.put_to_event_queue.assert_not_called()
manager.environment["input"]["prev_input"] = [
"KEY_CTRL",
"KEY_FENRIR",
]
manager.environment["input"]["curr_input"] = [
"KEY_CTRL",
"KEY_FENRIR",
"KEY_S",
]
manager.detect_shortcut_command()
event_manager.put_to_event_queue.assert_called_once_with(
FenrirEventType.execute_command, "SAVE_SETTINGS"
)
@pytest.mark.unit
def test_action_key_disarms_modifier_prefix():
manager = FenrirManager.__new__(FenrirManager)
manager.modifierInput = False
manager.modifier_prefix_input = True
manager.singleKeyCommand = False
manager.command = ""
input_manager = Mock(
is_key_press=Mock(return_value=False),
no_key_pressed=Mock(return_value=False),
curr_input_has_command_modifier=Mock(return_value=False),
curr_input_is_modifier_prefix=Mock(return_value=False),
get_curr_shortcut=Mock(return_value=str([1, ["KEY_C", "KEY_CTRL"]])),
get_command_for_shortcut=Mock(return_value=""),
)
manager.environment = {
"input": {
"key_forward": 0,
"prev_input": ["KEY_CTRL"],
"curr_input": ["KEY_C", "KEY_CTRL"],
},
"runtime": {
"InputManager": input_manager,
"EventManager": Mock(put_to_event_queue=Mock()),
"VmenuManager": Mock(get_active=Mock(return_value=False)),
"DiffReviewManager": Mock(is_active=Mock(return_value=False)),
"SpeechHistoryManager": Mock(is_active=Mock(return_value=False)),
},
}
manager.detect_shortcut_command()
assert manager.modifier_prefix_input is False
@pytest.mark.unit
def test_modifier_prefix_is_not_forwarded_before_chord_is_known():
manager, input_manager = create_handle_input_manager(False)
manager.handle_input(
{"data": {"event_name": "KEY_CTRL", "event_state": 1}}
)
input_manager.write_event_buffer.assert_not_called()
input_manager.clear_event_buffer.assert_not_called()
@pytest.mark.unit
def test_modifier_prefix_is_forwarded_when_released_unused():
manager, input_manager = create_handle_input_manager(True)
manager.handle_input(
{"data": {"event_name": "KEY_CTRL", "event_state": 0}}
)
input_manager.write_event_buffer.assert_called_once_with()
input_manager.clear_last_deep_input.assert_not_called()
assert manager.modifier_prefix_input is False
@@ -10,6 +10,7 @@ from fenrirscreenreader.core.fenrirManager import FenrirManager
def test_speech_history_plain_key_modal_command_is_dispatched():
manager = FenrirManager.__new__(FenrirManager)
manager.modifierInput = False
manager.modifier_prefix_input = False
manager.singleKeyCommand = False
manager.command = ""
@@ -50,6 +51,7 @@ def test_speech_history_plain_key_modal_command_is_dispatched():
def test_vmenu_plain_key_modal_command_is_dispatched():
manager = FenrirManager.__new__(FenrirManager)
manager.modifierInput = False
manager.modifier_prefix_input = False
manager.singleKeyCommand = False
manager.command = ""
+202
View File
@@ -0,0 +1,202 @@
import ast
from unittest.mock import Mock
import pytest
from fenrirscreenreader.core.commandManager import CommandManager
from fenrirscreenreader.core.helpManager import HelpManager
def create_help_manager(capture_succeeds=True):
manager = HelpManager()
bindings = {
str([1, ["KEY_F1", "KEY_FENRIR"]]): "TOGGLE_TUTORIAL_MODE",
str([1, ["KEY_FENRIR", "KEY_S"]]): "SPELL_CHECK",
str([1, ["KEY_FENRIR", "KEY_W"]]): "READ_WORD",
str([2, ["KEY_FENRIR", "KEY_W"]]): "READ_WORD",
str([1, ["KEY_SCRIPT", "KEY_U"]]): (
"MUSICPLAYER__-__KEY_U"
),
}
raw_bindings = {
shortcut: ast.literal_eval(shortcut) for shortcut in bindings
}
command_manager = Mock()
descriptions = {
"SPELL_CHECK": "spell check",
"READ_WORD": "read current word",
"UNBOUND_ACTION": "an action without a shortcut",
"MUSICPLAYER__-__KEY_U": "music player plugin",
}
command_manager.get_command_description.side_effect = (
lambda command, section="commands": descriptions.get(command, "")
)
input_manager = Mock()
input_manager.set_help_capture.return_value = capture_succeeds
internal_command = Mock()
internal_command.help_visible = False
plugin_command = Mock()
plugin_command.help_category = "plugins"
manager.initialize(
{
"general": {"tutorialMode": False},
"bindings": bindings.copy(),
"rawBindings": {
shortcut: [raw[0], raw[1].copy()]
for shortcut, raw in raw_bindings.items()
},
"commands": {
"commands": {
"READ_WORD": Mock(),
"SPELL_CHECK": Mock(),
"TOGGLE_TUTORIAL_MODE": Mock(),
"UNBOUND_ACTION": Mock(),
"00_INIT_COMMANDS": internal_command,
"MUSICPLAYER__-__KEY_U": plugin_command,
}
},
"runtime": {
"CommandManager": command_manager,
"DebugManager": Mock(),
"InputDriver": Mock(refresh_grabs=Mock()),
"InputManager": input_manager,
"VmenuManager": Mock(get_active=Mock(return_value=False)),
},
}
)
return manager, bindings, raw_bindings
@pytest.mark.unit
def test_help_lists_bound_actions_once_with_all_shortcuts():
manager, _bindings, raw_bindings = create_help_manager()
manager.set_tutorial_mode(True)
active_text = " ".join(manager.help_lists["active"])
assert active_text.count("read word") == 1
assert "fenrir, w" in active_text
assert "2 times fenrir, w" in active_text
assert "unbound action" not in active_text
assert "musicplayer" not in active_text
assert "00 init commands" not in active_text
assert all(
"00 init commands" not in entry
for entry in manager.help_lists["unbound"]
)
assert manager.help_lists["unbound"] == [
"unbound action, Shortcuts unbound, Description an action without a shortcut"
]
assert manager.help_lists["plugins"] == [
"musicplayer, Shortcuts script, u, Description music player plugin"
]
assert raw_bindings[str([1, ["KEY_FENRIR", "KEY_S"]])] == [
1,
["KEY_FENRIR", "KEY_S"],
]
@pytest.mark.unit
def test_help_navigation_defaults_to_active_and_starts_before_first_item():
manager, _bindings, _raw_bindings = create_help_manager()
manager.set_tutorial_mode(True)
assert manager.get_help_section_name() == "Active actions"
assert manager.get_help_for_current_index() == "Active actions"
manager.next_index()
assert manager.get_help_for_current_index() == manager.help_lists["active"][0]
assert manager.select_help_section("unbound") == "Unbound actions"
assert manager.get_help_for_current_index() == "Unbound actions"
manager.prev_index()
assert manager.get_help_for_current_index() == manager.help_lists["unbound"][-1]
assert manager.next_help_section() == "Plugins"
assert manager.next_help_section() == "Active actions"
assert manager.prev_help_section() == "Plugins"
@pytest.mark.unit
def test_help_installs_and_restores_modal_bindings_exactly():
manager, bindings, raw_bindings = create_help_manager()
manager.set_tutorial_mode(True)
assert (
manager.env["bindings"][str([1, ["KEY_LEFT"]])]
== "PREV_HELP_SECTION"
)
assert (
manager.env["bindings"][str([1, ["KEY_RIGHT"]])]
== "NEXT_HELP_SECTION"
)
assert manager.env["bindings"][str([1, ["KEY_ESC"]])] == "TOGGLE_TUTORIAL_MODE"
assert manager.env["rawBindings"][str([1, ["KEY_RIGHT"]])] == [
1,
["KEY_RIGHT"],
]
manager.env["runtime"]["InputManager"].set_help_capture.assert_called_once_with(
True
)
manager.set_tutorial_mode(False)
assert manager.env["bindings"] == bindings
assert manager.env["rawBindings"] == raw_bindings
manager.env["runtime"]["InputManager"].set_help_capture.assert_called_with(
False
)
@pytest.mark.unit
def test_help_records_degraded_capture_without_refusing_mode():
manager, _bindings, _raw_bindings = create_help_manager(
capture_succeeds=False
)
manager.set_tutorial_mode(True)
assert manager.is_tutorial_mode() is True
assert manager.is_capture_degraded() is True
@pytest.mark.unit
def test_help_does_not_claim_exit_when_capture_release_fails():
manager, bindings, raw_bindings = create_help_manager()
manager.set_tutorial_mode(True)
manager.env["runtime"]["InputManager"].set_help_capture.return_value = False
assert manager.set_tutorial_mode(False) is False
assert manager.is_tutorial_mode() is True
assert manager.env["bindings"] != bindings
assert manager.env["rawBindings"] != raw_bindings
@pytest.mark.unit
def test_command_description_is_presented_on_ignored_screen_in_help():
command_manager = CommandManager()
output_manager = Mock()
command_manager.env = {
"runtime": {
"ScreenManager": Mock(
is_ignored_screen=Mock(return_value=True)
),
"HelpManager": Mock(
is_tutorial_mode=Mock(return_value=True)
),
"DebugManager": Mock(),
"OutputManager": output_manager,
}
}
command_manager.command_exists = Mock(return_value=True)
command_manager.get_command_description = Mock(
return_value="spell check"
)
command_manager.execute_command("SPELL_CHECK", "commands")
output_manager.present_text.assert_called_once_with(
"spell check", interrupt=False
)
+155
View File
@@ -0,0 +1,155 @@
import importlib.util
from pathlib import Path
from unittest.mock import Mock
import pytest
from fenrirscreenreader.core.inputManager import InputManager
PROJECT_ROOT = Path(__file__).resolve().parents[2]
COMMANDS_DIR = (
PROJECT_ROOT / "src" / "fenrirscreenreader" / "commands" / "commands"
)
KEYBOARD_DIR = PROJECT_ROOT / "config" / "keyboard"
def load_command(name):
spec = importlib.util.spec_from_file_location(
f"fenrir_{name}", COMMANDS_DIR / f"{name}.py"
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.command()
def run_command(name, content, cursor_x=0, cursor_y=0):
output_manager = Mock()
settings_manager = Mock()
settings_manager.get_setting_as_bool.return_value = True
env = {
"punctuation": {"PUNCTDICT": {" ": "space"}},
"screen": {
"newCursorReview": {"x": cursor_x, "y": cursor_y},
"new_content_text": content,
},
"runtime": {
"CursorManager": Mock(),
"OutputManager": output_manager,
"SettingsManager": settings_manager,
},
}
command = load_command(name)
command.initialize(env)
command.run()
return output_manager
def load_bindings(layout):
manager = InputManager()
manager.env = {
"bindings": {},
"rawBindings": {},
"runtime": {"DebugManager": Mock()},
}
manager.load_shortcuts(KEYBOARD_DIR / f"{layout}.conf")
return manager.env["bindings"]
@pytest.mark.unit
def test_current_word_is_spelled_one_character_at_a_time():
output_manager = run_command(
"review_curr_word_spell", "hello world", cursor_x=2
)
calls = output_manager.present_text.call_args_list
assert [call.args[0] for call in calls] == list("hello")
assert calls[0].kwargs == {
"interrupt": True,
"ignore_punctuation": True,
"announce_capital": True,
"flush": False,
}
assert all(call.kwargs["interrupt"] is False for call in calls[1:])
@pytest.mark.unit
def test_current_word_spelling_preserves_capitals_and_punctuation():
output_manager = run_command("review_curr_word_spell", "Hi!", cursor_x=1)
assert [
call.args[0] for call in output_manager.present_text.call_args_list
] == ["H", "i", "!"]
@pytest.mark.unit
@pytest.mark.parametrize(
"command_name",
["review_curr_word_spell", "review_curr_word_phonetic"],
)
def test_current_word_spelling_announces_blank_when_no_word_exists(
command_name,
):
output_manager = run_command(command_name, " ")
output_manager.present_text.assert_called_once_with(
"blank", interrupt=True, flush=False
)
@pytest.mark.unit
def test_current_word_phonetic_spelling_uses_phonetic_names():
output_manager = run_command(
"review_curr_word_phonetic", "Az!", cursor_x=1
)
calls = output_manager.present_text.call_args_list
assert [call.args[0] for call in calls] == ["Alpha", "zulu", "!"]
assert calls[0].kwargs["interrupt"] is True
assert all(call.kwargs["interrupt"] is False for call in calls[1:])
@pytest.mark.unit
@pytest.mark.parametrize(
("layout", "character_keys", "word_keys"),
[
("desktop", ["KEY_KP2"], ["KEY_KP5"]),
("laptop", ["KEY_COMMA", "KEY_FENRIR"], ["KEY_FENRIR", "KEY_K"]),
],
)
def test_progressive_review_bindings(layout, character_keys, word_keys):
bindings = load_bindings(layout)
assert bindings[str([1, character_keys])] == "REVIEW_CURR_CHAR"
assert bindings[str([2, character_keys])] == "REVIEW_CURR_CHAR_PHONETIC"
assert bindings[str([1, word_keys])] == "REVIEW_CURR_WORD"
assert bindings[str([2, word_keys])] == "REVIEW_CURR_WORD_SPELL"
assert bindings[str([3, word_keys])] == "REVIEW_CURR_WORD_PHONETIC"
@pytest.mark.unit
@pytest.mark.parametrize("layout", ["desktop", "laptop"])
def test_layouts_do_not_bind_removed_keyboard_layout_cycle(layout):
bindings = load_bindings(layout)
assert "CYCLE_KEYBOARD_LAYOUT" not in bindings.values()
removed_commands = {
"REVIEW_PREV_CHAR_PHONETIC",
"REVIEW_NEXT_CHAR_PHONETIC",
"REVIEW_PREV_WORD_PHONETIC",
"REVIEW_NEXT_WORD_PHONETIC",
}
assert removed_commands.isdisjoint(bindings.values())
@pytest.mark.unit
def test_obsolete_phonetic_navigation_commands_are_removed():
removed_commands = [
"review_prev_char_phonetic.py",
"review_next_char_phonetic.py",
"review_prev_word_phonetic.py",
"review_next_word_phonetic.py",
]
assert all(not (COMMANDS_DIR / name).exists() for name in removed_commands)
+116
View File
@@ -0,0 +1,116 @@
from unittest.mock import Mock
from fenrirscreenreader.core.speechDiscoveryManager import (
SpeechDiscoveryManager,
query_speechd_voices,
)
class FakeBackgroundTaskManager:
def __init__(self):
self.tasks = []
def submit_task(self, function, callback, *args, **kwargs):
task_id = len(self.tasks) + 1
self.tasks.append((task_id, function, callback, args, kwargs))
return task_id
def cancel_task(self, task_id):
pass
def complete(self, task_id, value=None, error=""):
_, _function, callback, _args, _kwargs = self.tasks[task_id - 1]
callback(
{
"task_id": task_id,
"succeeded": not error,
"value": value,
"error": error,
}
)
def create_manager():
task_manager = FakeBackgroundTaskManager()
manager = SpeechDiscoveryManager()
manager.initialize(
{
"runtime": {
"BackgroundTaskManager": task_manager,
"DebugManager": Mock(),
}
}
)
return manager, task_manager
def test_module_requests_share_one_worker_and_cache_main_thread_result():
manager, task_manager = create_manager()
first_callback = Mock()
second_callback = Mock()
first_task_id = manager.request_modules(first_callback)
second_task_id = manager.request_modules(second_callback)
assert first_task_id == second_task_id
assert len(task_manager.tasks) == 1
task_manager.complete(first_task_id, ["rhvoice", "espeak-ng"])
first_callback.assert_called_once_with(["rhvoice", "espeak-ng"], "")
second_callback.assert_called_once_with(["rhvoice", "espeak-ng"], "")
cached_callback = Mock()
assert manager.request_modules(cached_callback) is None
cached_callback.assert_called_once_with(["rhvoice", "espeak-ng"], "")
assert len(task_manager.tasks) == 1
def test_voice_requests_are_deduplicated_per_module():
manager, task_manager = create_manager()
rhvoice_callback = Mock()
repeated_callback = Mock()
espeak_callback = Mock()
rhvoice_task = manager.request_voices("rhvoice", rhvoice_callback)
repeated_task = manager.request_voices("rhvoice", repeated_callback)
espeak_task = manager.request_voices("espeak-ng", espeak_callback)
assert rhvoice_task == repeated_task
assert espeak_task != rhvoice_task
assert len(task_manager.tasks) == 2
task_manager.complete(rhvoice_task, ["alan", "slt"])
rhvoice_callback.assert_called_once_with("rhvoice", ["alan", "slt"], "")
repeated_callback.assert_called_once_with(
"rhvoice", ["alan", "slt"], ""
)
espeak_callback.assert_not_called()
def test_failing_discovery_callback_does_not_hide_result_from_others():
manager, task_manager = create_manager()
failing_callback = Mock(side_effect=RuntimeError("consumer failed"))
second_callback = Mock()
task_id = manager.request_modules(failing_callback)
manager.request_modules(second_callback)
task_manager.complete(task_id, ["rhvoice"])
failing_callback.assert_called_once_with(["rhvoice"], "")
second_callback.assert_called_once_with(["rhvoice"], "")
manager.env["runtime"]["DebugManager"].write_debug_out.assert_called_once()
def test_voice_query_preserves_multiword_synthesis_voice_names(monkeypatch):
voice_list = (
" NAME LANGUAGE VARIANT\n"
" Perfect Paul en-US none\n"
" Big Bob en-US none\n"
)
monkeypatch.setattr(
"fenrirscreenreader.core.speechDiscoveryManager.subprocess.run",
Mock(return_value=Mock(returncode=0, stdout=voice_list, stderr="")),
)
assert query_speechd_voices("doubletalk") == ["Perfect Paul", "Big Bob"]
+96 -77
View File
@@ -1,86 +1,12 @@
import sys
from types import SimpleNamespace
from unittest.mock import Mock
from fenrirscreenreader.commands.commands import voice_browser
from fenrirscreenreader.commands.commands import voice_browser_safe
from fenrirscreenreader.core import dynamicVoiceMenu
from fenrirscreenreader.core.quickMenuManager import SpeechHelperMixin
from fenrirscreenreader.core.quickMenuManager import QuickMenuManager
from fenrirscreenreader.speechDriver import speechdDriver
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
VOICE_LIST = (
" NAME LANGUAGE VARIANT\n"
" Perfect Paul en-US none\n"
" Big Bob en-US none\n"
)
def completed_voice_list():
return SimpleNamespace(returncode=0, stdout=VOICE_LIST)
def test_quick_menu_preserves_multiword_synthesis_voice_names(monkeypatch):
monkeypatch.setattr(
"fenrirscreenreader.core.quickMenuManager.subprocess.run",
Mock(return_value=completed_voice_list()),
)
helper = SpeechHelperMixin()
helper.env = {"runtime": {"DebugManager": Mock()}}
assert helper.get_module_voices("doubletalk") == [
"Perfect Paul",
"Big Bob",
]
def test_safe_voice_browser_preserves_multiword_synthesis_voice_names(
monkeypatch,
):
monkeypatch.setattr(
voice_browser_safe.subprocess,
"run",
Mock(return_value=completed_voice_list()),
)
browser = voice_browser_safe.command()
browser.initialize({"runtime": {}})
assert browser.get_module_voices_with_timeout("doubletalk") == [
"Perfect Paul",
"Big Bob",
]
def test_interactive_voice_browser_preserves_multiword_synthesis_voice_names(
monkeypatch,
):
monkeypatch.setattr(
voice_browser.subprocess,
"run",
Mock(return_value=completed_voice_list()),
)
browser = voice_browser.command()
assert browser.get_module_voices("doubletalk") == [
"Perfect Paul",
"Big Bob",
]
def test_dynamic_voice_menu_preserves_multiword_synthesis_voice_names(
monkeypatch,
):
monkeypatch.setattr(
dynamicVoiceMenu.subprocess,
"run",
Mock(return_value=completed_voice_list()),
)
assert dynamicVoiceMenu.get_module_voices("doubletalk") == [
"Perfect Paul",
"Big Bob",
]
def test_espeak_voice_selection_keeps_language_and_variant_behavior():
voice = get_synthesis_voice_name(
"espeak-ng",
@@ -88,3 +14,96 @@ def test_espeak_voice_selection_keeps_language_and_variant_behavior():
)
assert voice == "en-us+female3"
class RuntimeSettings:
def __init__(self, module="", voice="en-us", language=""):
self.values = {
("speech", "module"): module,
("speech", "voice"): voice,
("speech", "language"): language,
}
def get_setting(self, section, setting):
return self.values[(section, setting)]
def set_setting(self, section, setting, value):
self.values[(section, setting)] = value
def test_speechd_driver_resolves_blank_module_from_active_default(monkeypatch):
settings = RuntimeSettings()
client = Mock()
client.get_output_module.return_value = "rhvoice"
speechd = SimpleNamespace(
SSIPClient=Mock(return_value=client),
PunctuationMode=Mock(return_value=Mock()),
)
monkeypatch.setitem(sys.modules, "speechd", speechd)
environment = {
"runtime": {
"DebugManager": Mock(),
"SettingsManager": settings,
}
}
driver = speechdDriver.driver()
driver.initialize(environment)
assert settings.get_setting("speech", "module") == "rhvoice"
assert driver.module == "rhvoice"
def test_speechd_driver_keeps_running_when_default_module_query_fails(
monkeypatch,
):
settings = RuntimeSettings()
client = Mock()
client.get_output_module.side_effect = RuntimeError("query failed")
speechd = SimpleNamespace(
SSIPClient=Mock(return_value=client),
PunctuationMode=Mock(return_value=Mock()),
)
monkeypatch.setitem(sys.modules, "speechd", speechd)
environment = {
"runtime": {
"DebugManager": Mock(),
"SettingsManager": settings,
}
}
driver = speechdDriver.driver()
driver.initialize(environment)
assert driver._is_initialized is True
assert settings.get_setting("speech", "module") == ""
def test_quick_menu_espeak_voice_can_move_away_and_return():
settings = RuntimeSettings(module="rhvoice")
discovery = Mock()
discovery.get_cached_modules.return_value = ["rhvoice", "espeak-ng"]
discovery.get_cached_voices.side_effect = lambda module: {
"rhvoice": ["alan"],
"espeak-ng": ["en-gb", "en-us", "en-us+female2"],
}[module]
manager = QuickMenuManager()
manager.env = {
"runtime": {
"DebugManager": Mock(),
"OutputManager": Mock(),
"SettingsManager": settings,
"SpeechDriver": Mock(),
"SpeechDiscoveryManager": discovery,
}
}
assert manager.cycle_speech_module("next") is True
assert settings.get_setting("speech", "module") == "espeak-ng"
assert settings.get_setting("speech", "voice") == "en-us"
assert manager.cycle_speech_voice("next") is True
assert settings.get_setting("speech", "voice") == "en-us+female2"
assert manager.cycle_speech_voice("prev") is True
assert settings.get_setting("speech", "voice") == "en-us"
+10 -17
View File
@@ -8,26 +8,19 @@ from fenrirscreenreader.commands.commands import subprocess as subprocess_comman
@pytest.mark.unit
def test_script_command_executes_without_shell(monkeypatch):
process = Mock()
process.communicate.return_value = (b"done", b"")
process.returncode = 0
process.communicate.return_value = ("done", "")
popen = Mock(return_value=process)
monkeypatch.setattr(subprocess_command, "Popen", popen)
output_manager = Mock()
command = subprocess_command.command()
command.initialize(
{
"general": {"curr_user": "Username"},
"runtime": {"OutputManager": output_manager},
},
"/tmp/script with spaces",
)
monkeypatch.setattr(subprocess_command.subprocess, "Popen", popen)
command._thread_run()
result = subprocess_command.run_script(
"/tmp/script with spaces", "Username"
)
popen.assert_called_once_with(
["/tmp/script with spaces", "Username"],
stdout=subprocess_command.PIPE,
stderr=subprocess_command.PIPE,
)
output_manager.present_text.assert_called_once_with(
"done", sound_icon="", interrupt=False
stdout=subprocess_command.subprocess.PIPE,
stderr=subprocess_command.subprocess.PIPE,
text=True,
)
assert result == {"return_code": 0, "stdout": "done", "stderr": ""}
@@ -0,0 +1,47 @@
import importlib
from unittest.mock import Mock
subprocess_command = importlib.import_module(
"fenrirscreenreader.commands.commands.subprocess"
)
def test_external_script_output_is_delivered_by_main_loop_callback(tmp_path):
script_path = tmp_path / "helper.sh"
script_path.write_text("#!/bin/sh\necho ready\n", encoding="utf-8")
script_path.chmod(0o755)
task_manager = Mock()
task_manager.submit_external_task.return_value = 7
output_manager = Mock()
environment = {
"runtime": {
"BackgroundTaskManager": task_manager,
"OutputManager": output_manager,
},
"general": {"curr_user": "Username"},
}
script_command = subprocess_command.command()
script_command.initialize(environment, str(script_path))
script_command.run()
output_manager.present_text.assert_not_called()
function, callback, path, current_user = (
task_manager.submit_external_task.call_args.args
)
assert function is subprocess_command.run_script
assert path == str(script_path)
assert current_user == "Username"
callback(
{
"task_id": 7,
"succeeded": True,
"value": {"return_code": 0, "stdout": "ready\n", "stderr": ""},
"error": "",
}
)
output_manager.present_text.assert_called_once_with(
"ready\n", sound_icon="", interrupt=False
)
+34
View File
@@ -0,0 +1,34 @@
import importlib.util
from pathlib import Path
from unittest.mock import Mock
import pytest
COMMAND_PATH = (
Path(__file__).resolve().parents[2]
/ "src"
/ "fenrirscreenreader"
/ "commands"
/ "commands"
/ "toggle_vmenu_mode.py"
)
def load_command():
spec = importlib.util.spec_from_file_location(
"fenrir_toggle_vmenu_mode", COMMAND_PATH
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.command()
@pytest.mark.unit
def test_get_description_does_not_toggle_vmenu_mode():
vmenu_manager = Mock()
command = load_command()
command.initialize({"runtime": {"VmenuManager": vmenu_manager}})
assert command.get_description() == "Entering or Leaving v menu mode."
vmenu_manager.toggle_vmenu_mode.assert_not_called()
+209
View File
@@ -189,6 +189,29 @@ def test_x11_should_emit_unbound_mapped_keys_for_speech_interrupt():
assert x11.should_emit_key("KEY_ENTER") is True
@pytest.mark.unit
@pytest.mark.parametrize(
("keysym_name", "key_name"),
[
("-", "KEY_MINUS"),
("=", "KEY_EQUAL"),
("[", "KEY_LEFTBRACE"),
("]", "KEY_RIGHTBRACE"),
("\\", "KEY_BACKSLASH"),
(";", "KEY_SEMICOLON"),
("'", "KEY_APOSTROPHE"),
("`", "KEY_GRAVE"),
(",", "KEY_COMMA"),
(".", "KEY_DOT"),
("/", "KEY_SLASH"),
],
)
def test_x11_maps_literal_punctuation_keysyms(keysym_name, key_name):
x11 = X11Driver()
assert x11.keysym_name_to_key_name(keysym_name) == key_name
@pytest.mark.unit
def test_x11_build_passive_grabs_for_fenrir_keys_and_shortcuts():
x11 = X11Driver()
@@ -372,6 +395,58 @@ def test_x11_write_event_buffer_does_not_replay_key_release():
assert x11.env["input"]["event_buffer"] == []
@pytest.mark.unit
def test_x11_help_capture_grabs_and_releases_keyboard():
x11 = X11Driver()
x11._initialized = True
x11.window = Mock()
x11.window.grab_keyboard.return_value = X.GrabSuccess
x11.display = Mock()
x11.env = {"runtime": {"DebugManager": Mock()}}
assert x11.set_help_capture(True) is True
x11.window.grab_keyboard.assert_called_once_with(
False,
X.GrabModeAsync,
X.GrabModeAsync,
X.CurrentTime,
)
assert x11.help_keyboard_grabbed is True
assert x11.set_help_capture(False) is True
x11.display.ungrab_keyboard.assert_called_once_with(X.CurrentTime)
x11.display.sync.assert_called_once_with()
assert x11.help_keyboard_grabbed is False
@pytest.mark.unit
def test_x11_help_capture_reports_failed_grab():
x11 = X11Driver()
x11._initialized = True
x11.window = Mock()
x11.window.grab_keyboard.return_value = X.AlreadyGrabbed
x11.display = Mock()
x11.env = {"runtime": {"DebugManager": Mock()}}
assert x11.set_help_capture(True) is False
assert x11.help_keyboard_grabbed is False
@pytest.mark.unit
def test_x11_help_capture_retries_failed_keyboard_release():
x11 = X11Driver()
x11._initialized = True
x11.help_keyboard_grabbed = True
x11.window = Mock()
x11.display = Mock()
x11.display.sync.side_effect = RuntimeError("ungrab failed")
x11.env = {"runtime": {"DebugManager": Mock()}}
assert x11.set_help_capture(False) is False
assert x11.display.ungrab_keyboard.call_count == 3
assert x11.help_keyboard_grabbed is True
@pytest.mark.unit
def test_x11_map_event_keeps_x_event_time_for_replay():
x11 = X11Driver()
@@ -425,3 +500,137 @@ def test_x11_handle_key_event_keeps_event_buffer_for_input_manager():
assert x11.env["input"]["event_buffer"][0]["event_name"] == "KEY_KP0"
event_queue.put.assert_called_once()
@pytest.mark.unit
@pytest.mark.parametrize(
("event_type", "event_mode"),
[
(X.FocusOut, X.NotifyGrab),
(X.FocusIn, X.NotifyUngrab),
],
)
def test_x11_transient_grab_focus_events_preserve_input_state(
event_type, event_mode
):
x11 = X11Driver()
x11.active = True
x11.clear_event_buffer = Mock()
x11.reset_input_state = Mock()
event = Mock(type=event_type, mode=event_mode)
x11.handle_x_event(event, Mock())
assert x11.active is True
x11.clear_event_buffer.assert_not_called()
x11.reset_input_state.assert_not_called()
@pytest.mark.unit
def test_x11_normal_focus_out_resets_input_state():
x11 = X11Driver()
x11.active = True
x11.reset_input_state = Mock()
event = Mock(type=X.FocusOut, mode=X.NotifyNormal)
x11.handle_x_event(event, Mock())
assert x11.active is False
x11.reset_input_state.assert_called_once_with()
@pytest.mark.unit
def test_x11_fenrir_press_emits_modifiers_from_raw_state_first():
x11 = X11Driver()
x11.active = True
x11.fenrir_keys = {"KEY_KP0"}
x11.interesting_keys = {"KEY_KP0"}
x11.env = {
"input": {
"curr_input": [],
"event_buffer": [],
},
"runtime": {
"InputManager": Mock(convert_event_name=lambda key: key),
"DebugManager": Mock(),
},
}
event_queue = Mock()
event = Mock(type=X.KeyPress, detail=90, state=X.ControlMask)
x11.keycode_to_key_name = Mock(return_value="KEY_KP0")
x11.handle_x_event(event, event_queue)
modifier_event = event_queue.put.call_args_list[0].args[0]
fenrir_event = event_queue.put.call_args_list[1].args[0]
assert modifier_event["data"]["event_name"] == "KEY_CTRL"
assert modifier_event["data"]["event_state"] == 1
assert fenrir_event["data"]["event_name"] == "KEY_KP0"
@pytest.mark.unit
def test_x11_fenrir_release_balances_synthesized_modifiers():
x11 = X11Driver()
x11.active = True
x11.fenrir_keys = {"KEY_KP0"}
x11.interesting_keys = {"KEY_KP0"}
x11.command_key_active = True
x11.chord_modifiers = {"KEY_CTRL"}
x11.env = {
"input": {
"curr_input": ["KEY_CTRL", "KEY_FENRIR"],
"event_buffer": [],
},
"runtime": {
"InputManager": Mock(convert_event_name=lambda key: key),
"DebugManager": Mock(),
},
}
event_queue = Mock()
event = Mock(type=X.KeyRelease, detail=90, state=X.ControlMask)
x11.keycode_to_key_name = Mock(return_value="KEY_KP0")
x11.handle_x_event(event, event_queue)
fenrir_event = event_queue.put.call_args_list[0].args[0]
modifier_event = event_queue.put.call_args_list[1].args[0]
assert fenrir_event["data"]["event_name"] == "KEY_KP0"
assert modifier_event["data"]["event_name"] == "KEY_CTRL"
assert modifier_event["data"]["event_state"] == 0
@pytest.mark.unit
def test_x11_fenrir_release_balances_held_chord_key():
x11 = X11Driver()
x11.active = True
x11.fenrir_keys = {"KEY_KP0"}
x11.interesting_keys = {"KEY_KP0", "KEY_KP9"}
x11.env = {
"input": {
"curr_input": [],
"event_buffer": [],
},
"runtime": {
"InputManager": Mock(convert_event_name=lambda key: key),
"DebugManager": Mock(),
},
}
event_queue = Mock()
key_names = {90: "KEY_KP0", 81: "KEY_KP9"}
x11.keycode_to_key_name = Mock(
side_effect=lambda keycode, state=0: key_names[keycode]
)
x11.handle_x_event(
Mock(type=X.KeyPress, detail=90, state=0), event_queue
)
x11.handle_x_event(
Mock(type=X.KeyPress, detail=81, state=0), event_queue
)
x11.handle_x_event(
Mock(type=X.KeyRelease, detail=90, state=0), event_queue
)
synthetic_release = event_queue.put.call_args_list[-1].args[0]
assert synthetic_release["data"]["event_name"] == "KEY_KP9"
assert synthetic_release["data"]["event_state"] == 0
+60 -31
View File
@@ -108,13 +108,12 @@ msgstr ""
#: ../src/fenrirscreenreader\commands\commands\review_curr_line.py:27
#: ../src/fenrirscreenreader\commands\commands\review_curr_word.py:27
#: ../src/fenrirscreenreader\commands\commands\review_curr_word_phonetic.py:27
#: ../src/fenrirscreenreader\commands\commands\review_curr_word_spell.py:43
#: ../src/fenrirscreenreader\commands\commands\review_line_begin.py:27
#: ../src/fenrirscreenreader\commands\commands\review_next_line.py:29
#: ../src/fenrirscreenreader\commands\commands\review_next_word.py:29
#: ../src/fenrirscreenreader\commands\commands\review_next_word_phonetic.py:27
#: ../src/fenrirscreenreader\commands\commands\review_prev_line.py:27
#: ../src/fenrirscreenreader\commands\commands\review_prev_word.py:27
#: ../src/fenrirscreenreader\commands\commands\review_prev_word_phonetic.py:27
#: ../src/fenrirscreenreader\commands\onCursorChange\65000-present_line_if_cursor_change_vertical.py:37
#: ../src/fenrirscreenreader\commands\onScreenUpdate\60000-history.py:59
msgid "blank"
@@ -513,31 +512,25 @@ msgstr ""
#: ../src/fenrirscreenreader\commands\commands\review_curr_word.py:32
#: ../src/fenrirscreenreader\commands\commands\review_curr_word_phonetic.py:36
#: ../src/fenrirscreenreader\commands\commands\review_curr_word_spell.py:59
#: ../src/fenrirscreenreader\commands\commands\review_down.py:27
#: ../src/fenrirscreenreader\commands\commands\review_next_char.py:28
#: ../src/fenrirscreenreader\commands\commands\review_next_char_phonetic.py:30
#: ../src/fenrirscreenreader\commands\commands\review_next_line.py:34
#: ../src/fenrirscreenreader\commands\commands\review_next_word.py:34
#: ../src/fenrirscreenreader\commands\commands\review_next_word_phonetic.py:36
#: ../src/fenrirscreenreader\commands\commands\review_prev_char.py:31
#: ../src/fenrirscreenreader\commands\commands\review_prev_char_phonetic.py:30
#: ../src/fenrirscreenreader\commands\commands\review_prev_line.py:32
#: ../src/fenrirscreenreader\commands\commands\review_prev_word.py:32
#: ../src/fenrirscreenreader\commands\commands\review_prev_word_phonetic.py:36
#: ../src/fenrirscreenreader\commands\commands\review_up.py:27
msgid "end of screen"
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\review_curr_word.py:35
#: ../src/fenrirscreenreader\commands\commands\review_curr_word_phonetic.py:39
#: ../src/fenrirscreenreader\commands\commands\review_curr_word_spell.py:68
#: ../src/fenrirscreenreader\commands\commands\review_next_char.py:31
#: ../src/fenrirscreenreader\commands\commands\review_next_char_phonetic.py:33
#: ../src/fenrirscreenreader\commands\commands\review_next_word.py:37
#: ../src/fenrirscreenreader\commands\commands\review_next_word_phonetic.py:39
#: ../src/fenrirscreenreader\commands\commands\review_prev_char.py:34
#: ../src/fenrirscreenreader\commands\commands\review_prev_char_phonetic.py:33
#: ../src/fenrirscreenreader\commands\commands\review_prev_word.py:35
#: ../src/fenrirscreenreader\commands\commands\review_prev_word_phonetic.py:39
#: ../src/fenrirscreenreader\commands\commands\review_up.py:30
msgid "line break"
msgstr ""
@@ -546,6 +539,10 @@ msgstr ""
msgid "Phonetically spells the current word"
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\review_curr_word_spell.py:23
msgid "Spells the current word"
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\review_down.py:18
msgid "Move review to the character below the current position"
msgstr ""
@@ -590,10 +587,6 @@ msgstr ""
msgid "Moves review to the next character "
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\review_next_char_phonetic.py:18
msgid "phonetically presents the next character and set review to it"
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\review_next_line.py:18
msgid "moves review to the next line "
msgstr ""
@@ -602,18 +595,10 @@ msgstr ""
msgid "moves review to the next word "
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\review_next_word_phonetic.py:19
msgid "Phonetically spells the next word and moves review to it"
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\review_prev_char.py:18
msgid "moves review to the previous character "
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\review_prev_char_phonetic.py:18
msgid "phonetically presents the previous character and set review to it"
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\review_prev_line.py:18
msgid "moves review to the previous line "
msgstr ""
@@ -622,10 +607,6 @@ msgstr ""
msgid "moves review focus to the previous word "
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\review_prev_word_phonetic.py:19
msgid "Phonetically spells the previous word and moves review to it"
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\review_top.py:18
msgid "move review to top of screen"
msgstr ""
@@ -901,12 +882,24 @@ msgstr ""
msgid "speech enabled"
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:18
msgid "Exiting tutorial mode. To enter tutorial mode again press Fenrir+f1"
#: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:23
msgid "enter or leave tutorial mode"
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:22
msgid "Entering tutorial mode. In this mode commands are described but not executed. You can move through the list of commands with the up and down arrow keys. To Exit tutorial mode press Fenrir+f1."
#: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:28
msgid "Entering tutorial mode. In this mode commands are described but not executed. Use up and down to browse actions and left and right to switch between Active actions, Unbound actions, and Plugins. Press Space to repeat the current item. To exit tutorial mode press Fenrir+F1 or Escape. Active actions."
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:32
msgid "Unable to exit tutorial mode because exclusive keyboard capture could not be released. Press Fenrir+F1 or Escape to try again."
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:38
msgid "Warning: full keyboard capture is unavailable. Unbound keys may reach the active application."
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:46
msgid "Exiting tutorial mode. To enter tutorial mode again press Fenrir+F1"
msgstr ""
#: ../src/fenrirscreenreader\commands\commands\toggle_vmenu_mode.py:18
@@ -925,6 +918,10 @@ msgstr ""
msgid "get current help message"
msgstr ""
#: ../src/fenrirscreenreader\commands\help\next_help_section.py:21
msgid "show next help category"
msgstr ""
#: ../src/fenrirscreenreader\commands\help\next_help.py:17
msgid "get next help message"
msgstr ""
@@ -933,6 +930,10 @@ msgstr ""
msgid "get prev help message"
msgstr ""
#: ../src/fenrirscreenreader\commands\help\prev_help_section.py:21
msgid "show previous help category"
msgstr ""
#:
#: ../src/fenrirscreenreader\commands\onCursorChange\65000-present_line_if_cursor_change_vertical.py:46
msgid "indented "
@@ -1095,10 +1096,38 @@ msgstr ""
msgid "Quit Fenrir"
msgstr ""
#: ../src/fenrirscreenreader\core\helpManager.py:77
#: ../src/fenrirscreenreader\core\helpManager.py:132
msgid "{} times "
msgstr ""
#: ../src/fenrirscreenreader\core\helpManager.py:161
msgid "toggles the tutorial mode"
msgstr ""
#: ../src/fenrirscreenreader\core\helpManager.py:168
msgid "no description available"
msgstr ""
#: ../src/fenrirscreenreader\core\helpManager.py:175
msgid "unbound"
msgstr ""
#: ../src/fenrirscreenreader\core\helpManager.py:176
msgid "{command_name}, Shortcuts {command_shortcuts}, Description {command_description}"
msgstr ""
#: ../src/fenrirscreenreader\core\helpManager.py:203
msgid "Plugins"
msgstr ""
#: ../src/fenrirscreenreader\core\helpManager.py:205
msgid "Unbound actions"
msgstr ""
#: ../src/fenrirscreenreader\core\helpManager.py:206
msgid "Active actions"
msgstr ""
#: ../src/fenrirscreenreader\core\outputManager.py:297
msgid "speech temporary disabled"
msgstr ""