diff --git a/config/keyboard/desktop.conf b/config/keyboard/desktop.conf index 121878df..b69a5df3 100644 --- a/config/keyboard/desktop.conf +++ b/config/keyboard/desktop.conf @@ -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 diff --git a/config/keyboard/laptop.conf b/config/keyboard/laptop.conf index bffbdd3a..b14be74f 100644 --- a/config/keyboard/laptop.conf +++ b/config/keyboard/laptop.conf @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/00_init_commands.py b/src/fenrirscreenreader/commands/commands/00_init_commands.py index 5caacb9c..28e9cf5f 100644 --- a/src/fenrirscreenreader/commands/commands/00_init_commands.py +++ b/src/fenrirscreenreader/commands/commands/00_init_commands.py @@ -13,6 +13,8 @@ from fenrirscreenreader.core.i18n import _ class command: + help_visible = False + def __init__(self): pass diff --git a/src/fenrirscreenreader/commands/commands/apply_tested_voice.py b/src/fenrirscreenreader/commands/commands/apply_tested_voice.py index a1fe209b..a228452b 100644 --- a/src/fenrirscreenreader/commands/commands/apply_tested_voice.py +++ b/src/fenrirscreenreader/commands/commands/apply_tested_voice.py @@ -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)}", diff --git a/src/fenrirscreenreader/commands/commands/cycle_keyboard_layout.py b/src/fenrirscreenreader/commands/commands/cycle_keyboard_layout.py deleted file mode 100644 index f83a6cad..00000000 --- a/src/fenrirscreenreader/commands/commands/cycle_keyboard_layout.py +++ /dev/null @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py b/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py index e30dfd0b..01a4ad3c 100644 --- a/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py +++ b/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py b/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py index 1c61a523..6bcd48a8 100644 --- a/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py +++ b/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py b/src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py index 0dac32ef..7a4c80e0 100644 --- a/src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py +++ b/src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py @@ -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 ) diff --git a/src/fenrirscreenreader/commands/commands/review_next_word_phonetic.py b/src/fenrirscreenreader/commands/commands/review_curr_word_spell.py similarity index 81% rename from src/fenrirscreenreader/commands/commands/review_next_word_phonetic.py rename to src/fenrirscreenreader/commands/commands/review_curr_word_spell.py index 45b7cb90..b64e6cb7 100644 --- a/src/fenrirscreenreader/commands/commands/review_next_word_phonetic.py +++ b/src/fenrirscreenreader/commands/commands/review_curr_word_spell.py @@ -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" diff --git a/src/fenrirscreenreader/commands/commands/review_next_char_phonetic.py b/src/fenrirscreenreader/commands/commands/review_next_char_phonetic.py deleted file mode 100644 index c3029864..00000000 --- a/src/fenrirscreenreader/commands/commands/review_next_char_phonetic.py +++ /dev/null @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/review_prev_char_phonetic.py b/src/fenrirscreenreader/commands/commands/review_prev_char_phonetic.py deleted file mode 100644 index 805fca57..00000000 --- a/src/fenrirscreenreader/commands/commands/review_prev_char_phonetic.py +++ /dev/null @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/review_prev_word_phonetic.py b/src/fenrirscreenreader/commands/commands/review_prev_word_phonetic.py deleted file mode 100644 index 0e9613f6..00000000 --- a/src/fenrirscreenreader/commands/commands/review_prev_word_phonetic.py +++ /dev/null @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/subprocess.py b/src/fenrirscreenreader/commands/commands/subprocess.py index 450fb1be..73f4084b 100644 --- a/src/fenrirscreenreader/commands/commands/subprocess.py +++ b/src/fenrirscreenreader/commands/commands/subprocess.py @@ -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): diff --git a/src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py b/src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py index ff18fea9..33ef14ec 100644 --- a/src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py +++ b/src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py @@ -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, ) diff --git a/src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py b/src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py index 4560d54f..b0e1e293 100644 --- a/src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py +++ b/src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py @@ -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): diff --git a/src/fenrirscreenreader/commands/commands/voice_browser.py b/src/fenrirscreenreader/commands/commands/voice_browser.py index 7d201e75..b881b222 100644 --- a/src/fenrirscreenreader/commands/commands/voice_browser.py +++ b/src/fenrirscreenreader/commands/commands/voice_browser.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_safe.py b/src/fenrirscreenreader/commands/commands/voice_browser_safe.py index 0e76e8e8..71a71763 100644 --- a/src/fenrirscreenreader/commands/commands/voice_browser_safe.py +++ b/src/fenrirscreenreader/commands/commands/voice_browser_safe.py @@ -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 diff --git a/src/fenrirscreenreader/commands/help/next_help_section.py b/src/fenrirscreenreader/commands/help/next_help_section.py new file mode 100644 index 00000000..4624b62c --- /dev/null +++ b/src/fenrirscreenreader/commands/help/next_help_section.py @@ -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 diff --git a/src/fenrirscreenreader/commands/help/prev_help_section.py b/src/fenrirscreenreader/commands/help/prev_help_section.py new file mode 100644 index 00000000..3050f36f --- /dev/null +++ b/src/fenrirscreenreader/commands/help/prev_help_section.py @@ -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 diff --git a/src/fenrirscreenreader/core/backgroundTaskManager.py b/src/fenrirscreenreader/core/backgroundTaskManager.py new file mode 100644 index 00000000..a91e54b5 --- /dev/null +++ b/src/fenrirscreenreader/core/backgroundTaskManager.py @@ -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() diff --git a/src/fenrirscreenreader/core/clipboardSyncManager.py b/src/fenrirscreenreader/core/clipboardSyncManager.py index d6512d9d..af09b499 100644 --- a/src/fenrirscreenreader/core/clipboardSyncManager.py +++ b/src/fenrirscreenreader/core/clipboardSyncManager.py @@ -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() diff --git a/src/fenrirscreenreader/core/commandManager.py b/src/fenrirscreenreader/core/commandManager.py index 38f9d585..1b80483b 100644 --- a/src/fenrirscreenreader/core/commandManager.py +++ b/src/fenrirscreenreader/core/commandManager.py @@ -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: diff --git a/src/fenrirscreenreader/core/dynamicVoiceMenu.py b/src/fenrirscreenreader/core/dynamicVoiceMenu.py index 3d91d994..6a153aed 100644 --- a/src/fenrirscreenreader/core/dynamicVoiceMenu.py +++ b/src/fenrirscreenreader/core/dynamicVoiceMenu.py @@ -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 [] diff --git a/src/fenrirscreenreader/core/eventData.py b/src/fenrirscreenreader/core/eventData.py index 1880b67d..0a812d1f 100644 --- a/src/fenrirscreenreader/core/eventData.py +++ b/src/fenrirscreenreader/core/eventData.py @@ -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 diff --git a/src/fenrirscreenreader/core/eventManager.py b/src/fenrirscreenreader/core/eventManager.py index 88d81f9e..cacb97bf 100644 --- a/src/fenrirscreenreader/core/eventManager.py +++ b/src/fenrirscreenreader/core/eventManager.py @@ -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 diff --git a/src/fenrirscreenreader/core/fenrirManager.py b/src/fenrirscreenreader/core/fenrirManager.py index ed19624f..65ca30ff 100644 --- a/src/fenrirscreenreader/core/fenrirManager.py +++ b/src/fenrirscreenreader/core/fenrirManager.py @@ -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() diff --git a/src/fenrirscreenreader/core/generalData.py b/src/fenrirscreenreader/core/generalData.py index c85234d5..dcf9721d 100644 --- a/src/fenrirscreenreader/core/generalData.py +++ b/src/fenrirscreenreader/core/generalData.py @@ -24,6 +24,8 @@ general_data = { "SpeechHistoryManager", "HelpManager", "MemoryManager", + "SpeechDiscoveryManager", + "BackgroundTaskManager", "EventManager", "ProcessManager", "VmenuManager", diff --git a/src/fenrirscreenreader/core/helpManager.py b/src/fenrirscreenreader/core/helpManager.py index 23053cb7..704bf093 100755 --- a/src/fenrirscreenreader/core/helpManager.py +++ b/src/fenrirscreenreader/core/helpManager.py @@ -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 + ) diff --git a/src/fenrirscreenreader/core/inputDriver.py b/src/fenrirscreenreader/core/inputDriver.py index b45d3352..979a78d2 100644 --- a/src/fenrirscreenreader/core/inputDriver.py +++ b/src/fenrirscreenreader/core/inputDriver.py @@ -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: diff --git a/src/fenrirscreenreader/core/inputManager.py b/src/fenrirscreenreader/core/inputManager.py index 1324668e..29680e0c 100644 --- a/src/fenrirscreenreader/core/inputManager.py +++ b/src/fenrirscreenreader/core/inputManager.py @@ -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 diff --git a/src/fenrirscreenreader/core/quickMenuManager.py b/src/fenrirscreenreader/core/quickMenuManager.py index b4f6eaed..1b1312fe 100644 --- a/src/fenrirscreenreader/core/quickMenuManager.py +++ b/src/fenrirscreenreader/core/quickMenuManager.py @@ -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: diff --git a/src/fenrirscreenreader/core/runtimeData.py b/src/fenrirscreenreader/core/runtimeData.py index 3f255a43..7c9e1d12 100644 --- a/src/fenrirscreenreader/core/runtimeData.py +++ b/src/fenrirscreenreader/core/runtimeData.py @@ -22,6 +22,8 @@ runtime_data = { "SettingsManager": None, "FenrirManager": None, "EventManager": None, + "BackgroundTaskManager": None, "ProcessManager": None, + "SpeechDiscoveryManager": None, "DiffReviewManager": None, } diff --git a/src/fenrirscreenreader/core/settingsManager.py b/src/fenrirscreenreader/core/settingsManager.py index 5a613bde..f044b26e 100644 --- a/src/fenrirscreenreader/core/settingsManager.py +++ b/src/fenrirscreenreader/core/settingsManager.py @@ -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"][ diff --git a/src/fenrirscreenreader/core/speechDiscoveryManager.py b/src/fenrirscreenreader/core/speechDiscoveryManager.py new file mode 100644 index 00000000..434bc3ae --- /dev/null +++ b/src/fenrirscreenreader/core/speechDiscoveryManager.py @@ -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, + ) diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index bfc393af..1eb75f6b 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -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" diff --git a/src/fenrirscreenreader/inputDriver/x11Driver.py b/src/fenrirscreenreader/inputDriver/x11Driver.py index 7d6335e8..33948ce0 100644 --- a/src/fenrirscreenreader/inputDriver/x11Driver.py +++ b/src/fenrirscreenreader/inputDriver/x11Driver.py @@ -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() diff --git a/src/fenrirscreenreader/speechDriver/speechdDriver.py b/src/fenrirscreenreader/speechDriver/speechdDriver.py index 210e3315..f7ae49fd 100644 --- a/src/fenrirscreenreader/speechDriver/speechdDriver.py +++ b/src/fenrirscreenreader/speechDriver/speechdDriver.py @@ -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 ) - diff --git a/tests/unit/test_async_voice_workflows.py b/tests/unit/test_async_voice_workflows.py new file mode 100644 index 00000000..5f0d75fe --- /dev/null +++ b/tests/unit/test_async_voice_workflows.py @@ -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 diff --git a/tests/unit/test_background_task_manager.py b/tests/unit/test_background_task_manager.py new file mode 100644 index 00000000..e8f43edf --- /dev/null +++ b/tests/unit/test_background_task_manager.py @@ -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() diff --git a/tests/unit/test_clipboard_commands_background_result.py b/tests/unit/test_clipboard_commands_background_result.py new file mode 100644 index 00000000..97289ee7 --- /dev/null +++ b/tests/unit/test_clipboard_commands_background_result.py @@ -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") diff --git a/tests/unit/test_clipboard_sync_manager.py b/tests/unit/test_clipboard_sync_manager.py index 756efab5..481f2f00 100644 --- a/tests/unit/test_clipboard_sync_manager.py +++ b/tests/unit/test_clipboard_sync_manager.py @@ -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" diff --git a/tests/unit/test_fenrir_manager_shortcuts.py b/tests/unit/test_fenrir_manager_shortcuts.py new file mode 100644 index 00000000..545c1c35 --- /dev/null +++ b/tests/unit/test_fenrir_manager_shortcuts.py @@ -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 diff --git a/tests/unit/test_fenrir_manager_speech_history.py b/tests/unit/test_fenrir_manager_speech_history.py index fd68ecc2..1bc9d05a 100644 --- a/tests/unit/test_fenrir_manager_speech_history.py +++ b/tests/unit/test_fenrir_manager_speech_history.py @@ -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 = "" diff --git a/tests/unit/test_help_manager.py b/tests/unit/test_help_manager.py new file mode 100644 index 00000000..1f3d9ff0 --- /dev/null +++ b/tests/unit/test_help_manager.py @@ -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 + ) diff --git a/tests/unit/test_review_spelling_commands.py b/tests/unit/test_review_spelling_commands.py new file mode 100644 index 00000000..67faeaf3 --- /dev/null +++ b/tests/unit/test_review_spelling_commands.py @@ -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) diff --git a/tests/unit/test_speech_discovery_manager.py b/tests/unit/test_speech_discovery_manager.py new file mode 100644 index 00000000..38a16fc0 --- /dev/null +++ b/tests/unit/test_speech_discovery_manager.py @@ -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"] diff --git a/tests/unit/test_speechd_voice_selection.py b/tests/unit/test_speechd_voice_selection.py index f1e137f4..92f7c3a0 100644 --- a/tests/unit/test_speechd_voice_selection.py +++ b/tests/unit/test_speechd_voice_selection.py @@ -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" diff --git a/tests/unit/test_subprocess_command.py b/tests/unit/test_subprocess_command.py index 777c3064..c2b81244 100644 --- a/tests/unit/test_subprocess_command.py +++ b/tests/unit/test_subprocess_command.py @@ -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": ""} diff --git a/tests/unit/test_subprocess_command_background_result.py b/tests/unit/test_subprocess_command_background_result.py new file mode 100644 index 00000000..761df604 --- /dev/null +++ b/tests/unit/test_subprocess_command_background_result.py @@ -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 + ) diff --git a/tests/unit/test_toggle_vmenu_command.py b/tests/unit/test_toggle_vmenu_command.py new file mode 100644 index 00000000..baa536e1 --- /dev/null +++ b/tests/unit/test_toggle_vmenu_command.py @@ -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() diff --git a/tests/unit/test_x11_terminal_mode.py b/tests/unit/test_x11_terminal_mode.py index 9ca6d376..2ee6859a 100644 --- a/tests/unit/test_x11_terminal_mode.py +++ b/tests/unit/test_x11_terminal_mode.py @@ -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 diff --git a/tools/fenrir.pot b/tools/fenrir.pot index e7b85c79..cfa57eaa 100644 --- a/tools/fenrir.pot +++ b/tools/fenrir.pot @@ -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 ""