Compare commits
8
Commits
2026.08.06
...
testing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e6e806947 | ||
|
|
45019091ab | ||
|
|
68462d00cb | ||
|
|
93a2745065 | ||
|
|
4ed431640e | ||
|
|
e7f69b2159 | ||
|
|
a2e4a8a086 | ||
|
|
cca688328b |
@@ -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
|
||||
|
||||
@@ -15,15 +15,12 @@ KEY_FENRIR,KEY_ALT,KEY_2=present_last_line
|
||||
KEY_FENRIR,KEY_K=review_curr_word
|
||||
KEY_FENRIR,KEY_J=review_prev_word
|
||||
KEY_FENRIR,KEY_L=review_next_word
|
||||
KEY_FENRIR,KEY_ALT,KEY_K=review_curr_word_phonetic
|
||||
KEY_FENRIR,KEY_ALT,KEY_J=review_prev_word_phonetic
|
||||
KEY_FENRIR,KEY_ALT,KEY_L=review_next_word_phonetic
|
||||
2,KEY_FENRIR,KEY_K=review_curr_word_spell
|
||||
3,KEY_FENRIR,KEY_K=review_curr_word_phonetic
|
||||
KEY_FENRIR,KEY_COMMA=review_curr_char
|
||||
KEY_FENRIR,KEY_M=review_prev_char
|
||||
KEY_FENRIR,KEY_DOT=review_next_char
|
||||
KEY_FENRIR,KEY_ALT,KEY_COMMA=curr_char_phonetic
|
||||
KEY_FENRIR,KEY_ALT,KEY_M=prev_char_phonetic
|
||||
KEY_FENRIR,KEY_ALT,KEY_DOT=next_char_phonetic
|
||||
2,KEY_FENRIR,KEY_COMMA=review_curr_char_phonetic
|
||||
KEY_FENRIR,KEY_CTRL,KEY_I=review_up
|
||||
KEY_FENRIR,KEY_CTRL,KEY_COMMA=review_down
|
||||
KEY_FENRIR,KEY_SLASH=exit_review
|
||||
@@ -133,4 +130,3 @@ KEY_FENRIR,KEY_F8=export_clipboard_to_x
|
||||
KEY_FENRIR,KEY_CTRL,KEY_DOWN=read_all_by_line
|
||||
KEY_FENRIR,KEY_CTRL,KEY_PAGEDOWN=read_all_by_page
|
||||
KEY_FENRIR,KEY_SHIFT,KEY_V=announce_fenrir_version
|
||||
KEY_FENRIR,KEY_LEFTCTRL,KEY_F4=cycle_keyboard_layout
|
||||
|
||||
@@ -13,6 +13,8 @@ from fenrirscreenreader.core.i18n import _
|
||||
|
||||
|
||||
class command:
|
||||
help_visible = False
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ class command:
|
||||
|
||||
module = self.env["commandBuffer"]["lastTestedModule"]
|
||||
voice = self.env["commandBuffer"]["lastTestedVoice"]
|
||||
language = self.env["commandBuffer"].get("lastTestedLanguage")
|
||||
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Applying {voice} from {module}", interrupt=True
|
||||
@@ -46,6 +47,7 @@ class command:
|
||||
old_driver = SettingsManager.get_setting("speech", "driver")
|
||||
old_module = SettingsManager.get_setting("speech", "module")
|
||||
old_voice = SettingsManager.get_setting("speech", "voice")
|
||||
old_language = SettingsManager.get_setting("speech", "language")
|
||||
|
||||
try:
|
||||
# Apply new settings to runtime only (use set_setting to update
|
||||
@@ -55,6 +57,10 @@ class command:
|
||||
)
|
||||
SettingsManager.set_setting("speech", "module", module)
|
||||
SettingsManager.set_setting("speech", "voice", voice)
|
||||
if language:
|
||||
SettingsManager.set_setting(
|
||||
"speech", "language", language
|
||||
)
|
||||
|
||||
# Apply to speech driver instance directly
|
||||
if "SpeechDriver" in self.env["runtime"]:
|
||||
@@ -62,6 +68,8 @@ class command:
|
||||
|
||||
# Set the module and voice on the driver instance
|
||||
SpeechDriver.set_module(module)
|
||||
if language:
|
||||
SpeechDriver.set_language(language)
|
||||
SpeechDriver.set_voice(voice)
|
||||
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
@@ -77,6 +85,9 @@ class command:
|
||||
SettingsManager.set_setting("speech", "driver", old_driver)
|
||||
SettingsManager.set_setting("speech", "module", old_module)
|
||||
SettingsManager.set_setting("speech", "voice", old_voice)
|
||||
SettingsManager.set_setting(
|
||||
"speech", "language", old_language
|
||||
)
|
||||
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Failed to apply voice, reverted: {str(e)}",
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
import os
|
||||
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def get_description(self):
|
||||
return _("cycles between available keyboard layouts")
|
||||
|
||||
def get_available_layouts(self):
|
||||
"""Get list of available keyboard layout files"""
|
||||
layouts = []
|
||||
|
||||
# Check standard locations for keyboard layouts
|
||||
settings_root = "/etc/fenrirscreenreader/"
|
||||
if not os.path.exists(settings_root):
|
||||
# Fallback to source directory
|
||||
import fenrirscreenreader
|
||||
|
||||
fenrir_path = os.path.dirname(fenrirscreenreader.__file__)
|
||||
settings_root = fenrir_path + "/../../config/"
|
||||
|
||||
keyboard_path = settings_root + "keyboard/"
|
||||
|
||||
if os.path.exists(keyboard_path):
|
||||
for file in os.listdir(keyboard_path):
|
||||
if (
|
||||
file.endswith(".conf")
|
||||
and not file.startswith("__")
|
||||
and not file.lower().startswith("pty")
|
||||
):
|
||||
layout_name = file.replace(".conf", "")
|
||||
if layout_name not in layouts:
|
||||
layouts.append(layout_name)
|
||||
|
||||
# Ensure we have at least basic layouts
|
||||
if not layouts:
|
||||
layouts = ["desktop", "laptop"]
|
||||
else:
|
||||
layouts.sort()
|
||||
|
||||
return layouts
|
||||
|
||||
def run(self):
|
||||
current_layout = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"keyboard", "keyboard_layout"
|
||||
)
|
||||
|
||||
# Extract layout name from full path if needed
|
||||
if "/" in current_layout:
|
||||
current_layout = os.path.basename(current_layout).replace(
|
||||
".conf", ""
|
||||
)
|
||||
|
||||
# Get available layouts
|
||||
available_layouts = self.get_available_layouts()
|
||||
|
||||
# Find next layout in cycle
|
||||
try:
|
||||
current_index = available_layouts.index(current_layout)
|
||||
next_index = (current_index + 1) % len(available_layouts)
|
||||
except ValueError:
|
||||
# If current layout not found, start from beginning
|
||||
next_index = 0
|
||||
|
||||
next_layout = available_layouts[next_index]
|
||||
|
||||
# Update setting and reload shortcuts
|
||||
self.env["runtime"]["SettingsManager"].set_setting(
|
||||
"keyboard", "keyboard_layout", next_layout
|
||||
)
|
||||
|
||||
# Reload shortcuts with new layout
|
||||
try:
|
||||
self.env["runtime"]["InputManager"].reload_shortcuts()
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("Switched to {} keyboard layout").format(next_layout),
|
||||
interrupt=True,
|
||||
)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"Error reloading shortcuts: " + str(e), debug.DebugLevel.ERROR
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("Error switching keyboard layout"), interrupt=True
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
@@ -4,30 +4,35 @@
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
import _thread
|
||||
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.utils import x_clipboard
|
||||
|
||||
|
||||
def write_clipboard(clipboard):
|
||||
"""Write the graphical clipboard without accessing Fenrir state."""
|
||||
return x_clipboard.write_text(clipboard, scan_displays=True)
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
self._task_ids = set()
|
||||
|
||||
def initialize(self, environment, script_path=""):
|
||||
self.env = environment
|
||||
self.script_path = script_path
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
task_manager = self.env["runtime"].get("BackgroundTaskManager")
|
||||
if task_manager:
|
||||
for task_id in self._task_ids:
|
||||
task_manager.cancel_task(task_id)
|
||||
self._task_ids.clear()
|
||||
|
||||
def get_description(self):
|
||||
return _("Export current fenrir clipboard to X or GUI clipboard")
|
||||
|
||||
def run(self):
|
||||
_thread.start_new_thread(self._thread_run, ())
|
||||
|
||||
def _thread_run(self):
|
||||
try:
|
||||
# Check if clipboard is empty
|
||||
if self.env["runtime"]["MemoryManager"].is_index_list_empty(
|
||||
@@ -43,36 +48,43 @@ class command:
|
||||
"MemoryManager"
|
||||
].get_index_list_element("clipboardHistory")
|
||||
|
||||
try:
|
||||
success = x_clipboard.write_text(
|
||||
clipboard, scan_displays=True
|
||||
)
|
||||
except Exception:
|
||||
success = False
|
||||
|
||||
# Notify the user of the result
|
||||
if success:
|
||||
sync_manager = self.env["runtime"].get(
|
||||
"ClipboardSyncManager"
|
||||
)
|
||||
if sync_manager:
|
||||
sync_manager.mark_written_to_x(clipboard)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("exported to the X session."), interrupt=True
|
||||
)
|
||||
else:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_(
|
||||
"failed to export to X clipboard. No available display "
|
||||
"found."
|
||||
),
|
||||
interrupt=True,
|
||||
)
|
||||
task_id = self.env["runtime"][
|
||||
"BackgroundTaskManager"
|
||||
].submit_task(
|
||||
write_clipboard,
|
||||
lambda result: self._handle_result(clipboard, result),
|
||||
clipboard,
|
||||
)
|
||||
if task_id is not None:
|
||||
self._task_ids.add(task_id)
|
||||
|
||||
except Exception as e:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
str(e), sound_icon="", interrupt=False
|
||||
)
|
||||
|
||||
def _handle_result(self, clipboard, result):
|
||||
self._task_ids.discard(result.get("task_id"))
|
||||
if result.get("succeeded") and result.get("value"):
|
||||
sync_manager = self.env["runtime"].get("ClipboardSyncManager")
|
||||
if sync_manager:
|
||||
sync_manager.mark_written_to_x(clipboard)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("exported to the X session."), interrupt=True
|
||||
)
|
||||
return
|
||||
|
||||
if not result.get("succeeded") and result.get("error"):
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"Clipboard export failed: " + result["error"],
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_(
|
||||
"failed to export to X clipboard. No available display found."
|
||||
),
|
||||
interrupt=True,
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
|
||||
@@ -4,60 +4,68 @@
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
import _thread
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.utils import x_clipboard
|
||||
|
||||
|
||||
def read_clipboard():
|
||||
"""Read the graphical clipboard without accessing Fenrir state."""
|
||||
return x_clipboard.read_text(scan_displays=True)
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
self._task_ids = set()
|
||||
|
||||
def initialize(self, environment, script_path=""):
|
||||
self.env = environment
|
||||
self.script_path = script_path
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
task_manager = self.env["runtime"].get("BackgroundTaskManager")
|
||||
if task_manager:
|
||||
for task_id in self._task_ids:
|
||||
task_manager.cancel_task(task_id)
|
||||
self._task_ids.clear()
|
||||
|
||||
def get_description(self):
|
||||
return _("imports the graphical clipboard to Fenrir's clipboard")
|
||||
|
||||
def run(self):
|
||||
_thread.start_new_thread(self._thread_run, ())
|
||||
task_id = self.env["runtime"]["BackgroundTaskManager"].submit_task(
|
||||
read_clipboard, self._handle_result
|
||||
)
|
||||
if task_id is not None:
|
||||
self._task_ids.add(task_id)
|
||||
|
||||
def _thread_run(self):
|
||||
try:
|
||||
try:
|
||||
clipboard_content = x_clipboard.read_text(
|
||||
scan_displays=True
|
||||
)
|
||||
except Exception:
|
||||
clipboard_content = None
|
||||
|
||||
# Process the clipboard content if we found any
|
||||
if clipboard_content and isinstance(clipboard_content, str):
|
||||
self.env["runtime"]["MemoryManager"].add_value_to_first_index(
|
||||
"clipboardHistory", clipboard_content
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Import to Clipboard",
|
||||
sound_icon="CopyToClipboard",
|
||||
interrupt=True,
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
clipboard_content, sound_icon="", interrupt=False
|
||||
)
|
||||
else:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"No text found in clipboard or no accessible display",
|
||||
interrupt=True,
|
||||
)
|
||||
except Exception as e:
|
||||
def _handle_result(self, result):
|
||||
self._task_ids.discard(result.get("task_id"))
|
||||
clipboard_content = result.get("value")
|
||||
if not result.get("succeeded"):
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
str(e), sound_icon="", interrupt=False
|
||||
result.get("error", "Clipboard import failed"),
|
||||
sound_icon="",
|
||||
interrupt=False,
|
||||
)
|
||||
return
|
||||
if not clipboard_content or not isinstance(clipboard_content, str):
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"No text found in clipboard or no accessible display",
|
||||
interrupt=True,
|
||||
)
|
||||
return
|
||||
|
||||
self.env["runtime"]["MemoryManager"].add_value_to_first_index(
|
||||
"clipboardHistory", clipboard_content
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Import to Clipboard",
|
||||
sound_icon="CopyToClipboard",
|
||||
interrupt=True,
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
clipboard_content, sound_icon="", interrupt=False
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
|
||||
@@ -38,7 +38,7 @@ class command:
|
||||
self.env["screen"]["new_content_text"],
|
||||
)
|
||||
|
||||
if curr_word.isspace():
|
||||
if not curr_word or curr_word.isspace():
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("blank"), interrupt=True, flush=False
|
||||
)
|
||||
|
||||
+8
-10
@@ -20,7 +20,7 @@ class command:
|
||||
pass
|
||||
|
||||
def get_description(self):
|
||||
return _("Phonetically spells the next word and moves review to it")
|
||||
return _("Spells the current word")
|
||||
|
||||
def run(self):
|
||||
self.env["runtime"][
|
||||
@@ -29,30 +29,28 @@ class command:
|
||||
(
|
||||
self.env["screen"]["newCursorReview"]["x"],
|
||||
self.env["screen"]["newCursorReview"]["y"],
|
||||
next_word,
|
||||
curr_word,
|
||||
end_of_screen,
|
||||
line_break,
|
||||
) = word_utils.get_next_word(
|
||||
) = word_utils.get_current_word(
|
||||
self.env["screen"]["newCursorReview"]["x"],
|
||||
self.env["screen"]["newCursorReview"]["y"],
|
||||
self.env["screen"]["new_content_text"],
|
||||
)
|
||||
|
||||
if next_word.isspace():
|
||||
if not curr_word or curr_word.isspace():
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("blank"), interrupt=True, flush=False
|
||||
)
|
||||
else:
|
||||
first_sequence = True
|
||||
for c in next_word:
|
||||
curr_char = char_utils.get_phonetic(c)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
for index, curr_char in enumerate(curr_word):
|
||||
char_utils.present_char_for_review(
|
||||
self.env,
|
||||
curr_char,
|
||||
interrupt=first_sequence,
|
||||
interrupt=index == 0,
|
||||
announce_capital=True,
|
||||
flush=False,
|
||||
)
|
||||
first_sequence = False
|
||||
if end_of_screen:
|
||||
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
|
||||
"review", "end_of_screen"
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.utils import char_utils
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def get_description(self):
|
||||
return _(
|
||||
"phonetically presents the next character and set review to it"
|
||||
)
|
||||
|
||||
def run(self):
|
||||
self.env["runtime"][
|
||||
"CursorManager"
|
||||
].enter_review_mode_curr_text_cursor()
|
||||
|
||||
(
|
||||
self.env["screen"]["newCursorReview"]["x"],
|
||||
self.env["screen"]["newCursorReview"]["y"],
|
||||
next_char,
|
||||
end_of_screen,
|
||||
line_break,
|
||||
) = char_utils.get_next_char(
|
||||
self.env["screen"]["newCursorReview"]["x"],
|
||||
self.env["screen"]["newCursorReview"]["y"],
|
||||
self.env["screen"]["new_content_text"],
|
||||
)
|
||||
|
||||
next_char = char_utils.get_phonetic(next_char)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
next_char, interrupt=True, announce_capital=True, flush=False
|
||||
)
|
||||
if end_of_screen:
|
||||
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
|
||||
"review", "end_of_screen"
|
||||
):
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("end of screen"),
|
||||
interrupt=True,
|
||||
sound_icon="EndOfScreen",
|
||||
)
|
||||
if line_break:
|
||||
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
|
||||
"review", "line_break"
|
||||
):
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("line break"), interrupt=False, sound_icon="EndOfLine"
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.utils import char_utils
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def get_description(self):
|
||||
return _(
|
||||
"phonetically presents the previous character and set review to it"
|
||||
)
|
||||
|
||||
def run(self):
|
||||
self.env["runtime"][
|
||||
"CursorManager"
|
||||
].enter_review_mode_curr_text_cursor()
|
||||
|
||||
(
|
||||
self.env["screen"]["newCursorReview"]["x"],
|
||||
self.env["screen"]["newCursorReview"]["y"],
|
||||
prev_char,
|
||||
end_of_screen,
|
||||
line_break,
|
||||
) = char_utils.get_prev_char(
|
||||
self.env["screen"]["newCursorReview"]["x"],
|
||||
self.env["screen"]["newCursorReview"]["y"],
|
||||
self.env["screen"]["new_content_text"],
|
||||
)
|
||||
|
||||
prev_char = char_utils.get_phonetic(prev_char)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
prev_char, interrupt=True, announce_capital=True, flush=False
|
||||
)
|
||||
if end_of_screen:
|
||||
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
|
||||
"review", "end_of_screen"
|
||||
):
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("start of screen"),
|
||||
interrupt=True,
|
||||
sound_icon="StartOfScreen",
|
||||
)
|
||||
if line_break:
|
||||
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
|
||||
"review", "line_break"
|
||||
):
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("line break"), interrupt=False, sound_icon="EndOfLine"
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.utils import char_utils
|
||||
from fenrirscreenreader.utils import word_utils
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def get_description(self):
|
||||
return _(
|
||||
"Phonetically spells the previous word and moves review to it"
|
||||
)
|
||||
|
||||
def run(self):
|
||||
self.env["runtime"][
|
||||
"CursorManager"
|
||||
].enter_review_mode_curr_text_cursor()
|
||||
(
|
||||
self.env["screen"]["newCursorReview"]["x"],
|
||||
self.env["screen"]["newCursorReview"]["y"],
|
||||
prev_word,
|
||||
end_of_screen,
|
||||
line_break,
|
||||
) = word_utils.get_prev_word(
|
||||
self.env["screen"]["newCursorReview"]["x"],
|
||||
self.env["screen"]["newCursorReview"]["y"],
|
||||
self.env["screen"]["new_content_text"],
|
||||
)
|
||||
|
||||
if prev_word.isspace():
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("blank"), interrupt=True, flush=False
|
||||
)
|
||||
else:
|
||||
first_sequence = True
|
||||
for c in prev_word:
|
||||
curr_char = char_utils.get_phonetic(c)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
curr_char,
|
||||
interrupt=first_sequence,
|
||||
announce_capital=True,
|
||||
flush=False,
|
||||
)
|
||||
first_sequence = False
|
||||
if end_of_screen:
|
||||
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
|
||||
"review", "end_of_screen"
|
||||
):
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("start of screen"),
|
||||
interrupt=True,
|
||||
sound_icon="StartOfScreen",
|
||||
)
|
||||
if line_break:
|
||||
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
|
||||
"review", "line_break"
|
||||
):
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("line break"), interrupt=False, sound_icon="EndOfLine"
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
@@ -4,24 +4,42 @@
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
import _thread
|
||||
import os
|
||||
from subprocess import PIPE
|
||||
from subprocess import Popen
|
||||
import subprocess
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
|
||||
|
||||
def run_script(script_path, current_user):
|
||||
"""Run an external command without accessing Fenrir's shared state."""
|
||||
process = subprocess.Popen(
|
||||
[script_path, current_user],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
stdout, stderr = process.communicate()
|
||||
return {
|
||||
"return_code": process.returncode,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
}
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
self._task_ids = set()
|
||||
|
||||
def initialize(self, environment, script_path=""):
|
||||
self.env = environment
|
||||
self.script_path = script_path
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
task_manager = self.env["runtime"].get("BackgroundTaskManager")
|
||||
if task_manager:
|
||||
for task_id in self._task_ids:
|
||||
task_manager.cancel_task(task_id)
|
||||
self._task_ids.clear()
|
||||
|
||||
def get_description(self):
|
||||
return _("script: {0} fullpath: {1}").format(
|
||||
@@ -48,30 +66,39 @@ class command:
|
||||
interrupt=False,
|
||||
)
|
||||
return
|
||||
_thread.start_new_thread(self._thread_run, ())
|
||||
|
||||
def _thread_run(self):
|
||||
try:
|
||||
p = Popen(
|
||||
[self.script_path, self.env["general"]["curr_user"]],
|
||||
stdout=PIPE,
|
||||
stderr=PIPE,
|
||||
)
|
||||
stdout, stderr = p.communicate()
|
||||
stdout = stdout.decode("utf-8")
|
||||
stderr = stderr.decode("utf-8")
|
||||
self.env["runtime"]["OutputManager"].interrupt_output()
|
||||
if stderr != "":
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
str(stderr), sound_icon="", interrupt=False
|
||||
)
|
||||
if stdout != "":
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
str(stdout), sound_icon="", interrupt=False
|
||||
)
|
||||
except Exception as e:
|
||||
task_id = self.env["runtime"][
|
||||
"BackgroundTaskManager"
|
||||
].submit_external_task(
|
||||
run_script,
|
||||
self._handle_result,
|
||||
self.script_path,
|
||||
self.env["general"]["curr_user"],
|
||||
)
|
||||
if task_id is not None:
|
||||
self._task_ids.add(task_id)
|
||||
|
||||
def _handle_result(self, result):
|
||||
self._task_ids.discard(result.get("task_id"))
|
||||
if not result.get("succeeded"):
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
str(e), sound_icon="", interrupt=False
|
||||
result.get("error", _("Script failed")),
|
||||
sound_icon="",
|
||||
interrupt=False,
|
||||
)
|
||||
return
|
||||
|
||||
output = result.get("value") or {}
|
||||
stderr = output.get("stderr", "")
|
||||
stdout = output.get("stdout", "")
|
||||
self.env["runtime"]["OutputManager"].interrupt_output()
|
||||
if stderr:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
stderr, sound_icon="", interrupt=False
|
||||
)
|
||||
if stdout:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
stdout, sound_icon="", interrupt=False
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
|
||||
@@ -19,19 +19,47 @@ class command:
|
||||
pass
|
||||
|
||||
def get_description(self):
|
||||
self.env["runtime"]["HelpManager"].toggle_tutorial_mode()
|
||||
return _(
|
||||
"Exiting tutorial mode. To enter tutorial mode again press Fenrir+f1"
|
||||
)
|
||||
return _("enter or leave tutorial mode")
|
||||
|
||||
def run(self):
|
||||
self.env["runtime"]["HelpManager"].toggle_tutorial_mode()
|
||||
if self.env["runtime"]["HelpManager"].is_tutorial_mode():
|
||||
help_manager = self.env["runtime"]["HelpManager"]
|
||||
was_active = help_manager.is_tutorial_mode()
|
||||
help_manager.toggle_tutorial_mode()
|
||||
is_active = help_manager.is_tutorial_mode()
|
||||
if was_active and is_active:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_(
|
||||
"Entering tutorial mode. In this mode commands are described but not "
|
||||
"executed. You can move through the list of commands with the up and "
|
||||
"down arrow keys. To Exit tutorial mode press Fenrir+f1."
|
||||
"Unable to exit tutorial mode because exclusive keyboard "
|
||||
"capture could not be released. Press Fenrir+F1 or Escape "
|
||||
"to try again."
|
||||
),
|
||||
interrupt=True,
|
||||
)
|
||||
elif is_active:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_(
|
||||
"Entering tutorial mode. In this mode commands are "
|
||||
"described but not executed. Use up and down to browse "
|
||||
"actions and left and right to switch between Active "
|
||||
"actions, Unbound actions, and Plugins. Press Space to "
|
||||
"repeat the current item. To exit tutorial mode press "
|
||||
"Fenrir+F1 or Escape. Active actions."
|
||||
),
|
||||
interrupt=True,
|
||||
)
|
||||
if self.env["runtime"]["HelpManager"].is_capture_degraded():
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_(
|
||||
"Warning: full keyboard capture is unavailable. "
|
||||
"Unbound keys may reach the active application."
|
||||
),
|
||||
interrupt=False,
|
||||
)
|
||||
else:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_(
|
||||
"Exiting tutorial mode. To enter tutorial mode again "
|
||||
"press Fenrir+F1"
|
||||
),
|
||||
interrupt=True,
|
||||
)
|
||||
|
||||
@@ -19,7 +19,6 @@ class command:
|
||||
pass
|
||||
|
||||
def get_description(self):
|
||||
self.env["runtime"]["VmenuManager"].toggle_vmenu_mode()
|
||||
return _("Entering or Leaving v menu mode.")
|
||||
|
||||
def run(self):
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
self._request_generation = 0
|
||||
self._loading = False
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
self.testMessage = "This is a voice test. The quick brown fox jumps over the lazy dog."
|
||||
self.test_message = (
|
||||
"This is a voice test. The quick brown fox jumps over the lazy dog."
|
||||
)
|
||||
self.modules = []
|
||||
self.voices = []
|
||||
self.module_index = 0
|
||||
@@ -23,7 +24,9 @@ class command:
|
||||
self.lastAnnounceTime = 0
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
self._request_generation += 1
|
||||
self._loading = False
|
||||
self._leave_voice_browser(False)
|
||||
|
||||
def get_description(self):
|
||||
return "Interactive voice browser with arrow key navigation"
|
||||
@@ -37,34 +40,59 @@ class command:
|
||||
"Starting voice browser", interrupt=True
|
||||
)
|
||||
|
||||
# Load modules
|
||||
self.modules = self.get_speechd_modules()
|
||||
if not self.modules:
|
||||
if self._loading:
|
||||
return
|
||||
self._loading = True
|
||||
self._request_generation += 1
|
||||
generation = self._request_generation
|
||||
self.env["runtime"]["SpeechDiscoveryManager"].request_modules(
|
||||
lambda modules, error: self._modules_ready(
|
||||
generation, modules, error
|
||||
)
|
||||
)
|
||||
|
||||
def _modules_ready(self, generation, modules, error):
|
||||
if generation != self._request_generation:
|
||||
return
|
||||
if error or not modules:
|
||||
self._loading = False
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"No speech modules found", interrupt=True
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].play_sound("Error")
|
||||
return
|
||||
|
||||
# Set current module
|
||||
self.modules = modules
|
||||
current_module = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "module"
|
||||
)
|
||||
if current_module and current_module in self.modules:
|
||||
self.module_index = self.modules.index(current_module)
|
||||
self._request_current_module_voices(generation, True)
|
||||
|
||||
# Load voices
|
||||
self.load_voices_for_current_module()
|
||||
def _request_current_module_voices(self, generation, enter_browser=False):
|
||||
module = self.modules[self.module_index]
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Loading voices for {module}", interrupt=True
|
||||
)
|
||||
self.env["runtime"]["SpeechDiscoveryManager"].request_voices(
|
||||
module,
|
||||
lambda _module, voices, error: self._voices_ready(
|
||||
generation, voices, error, enter_browser
|
||||
),
|
||||
)
|
||||
|
||||
# Set current voice
|
||||
def _voices_ready(self, generation, voices, error, enter_browser):
|
||||
if generation != self._request_generation:
|
||||
return
|
||||
self._loading = False
|
||||
self.voices = voices if not error else []
|
||||
self.voice_index = 0
|
||||
current_voice = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "voice"
|
||||
)
|
||||
if current_voice and current_voice in self.voices:
|
||||
self.voice_index = self.voices.index(current_voice)
|
||||
|
||||
# Enter browser mode
|
||||
self.enter_voice_browser()
|
||||
if enter_browser:
|
||||
self.enter_voice_browser()
|
||||
self.announce_current_selection()
|
||||
|
||||
def enter_voice_browser(self):
|
||||
@@ -113,6 +141,9 @@ class command:
|
||||
|
||||
def exit_voice_browser(self):
|
||||
"""Exit voice browser and restore normal key bindings"""
|
||||
self._leave_voice_browser(True)
|
||||
|
||||
def _leave_voice_browser(self, announce):
|
||||
if not self.browserActive:
|
||||
return
|
||||
|
||||
@@ -125,16 +156,10 @@ class command:
|
||||
if "voiceBrowserInstance" in self.env["runtime"]:
|
||||
del self.env["runtime"]["voiceBrowserInstance"]
|
||||
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Voice browser exited", interrupt=True
|
||||
)
|
||||
|
||||
def load_voices_for_current_module(self):
|
||||
"""Load voices for current module"""
|
||||
if self.module_index < len(self.modules):
|
||||
module = self.modules[self.module_index]
|
||||
self.voices = self.get_module_voices(module)
|
||||
self.voice_index = 0 # Reset to first voice when changing modules
|
||||
if announce:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Voice browser exited", interrupt=True
|
||||
)
|
||||
|
||||
def announce_current_selection(self):
|
||||
"""Announce current module and voice"""
|
||||
@@ -149,7 +174,7 @@ class command:
|
||||
|
||||
module = self.modules[self.module_index]
|
||||
if self.voices and self.voice_index < len(self.voices):
|
||||
voice = self.voices[self.voice_index]
|
||||
voice = self.voices[self.voice_index].split("|", 1)[0]
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"{module}: {voice} ({self.voice_index + 1}/{len(self.voices)})",
|
||||
interrupt=True,
|
||||
@@ -174,16 +199,19 @@ class command:
|
||||
self.announce_current_selection()
|
||||
|
||||
def next_module(self):
|
||||
"""Move to next module"""
|
||||
self.module_index = (self.module_index + 1) % len(self.modules)
|
||||
self.load_voices_for_current_module()
|
||||
self.announce_current_selection()
|
||||
self._change_module(1)
|
||||
|
||||
def prev_module(self):
|
||||
"""Move to previous module"""
|
||||
self.module_index = (self.module_index - 1) % len(self.modules)
|
||||
self.load_voices_for_current_module()
|
||||
self.announce_current_selection()
|
||||
self._change_module(-1)
|
||||
|
||||
def _change_module(self, offset):
|
||||
if self._loading or not self.modules:
|
||||
return
|
||||
self._loading = True
|
||||
self._request_generation += 1
|
||||
generation = self._request_generation
|
||||
self.module_index = (self.module_index + offset) % len(self.modules)
|
||||
self._request_current_module_voices(generation)
|
||||
|
||||
def test_voice(self):
|
||||
"""Test current voice"""
|
||||
@@ -194,15 +222,38 @@ class command:
|
||||
return
|
||||
|
||||
module = self.modules[self.module_index]
|
||||
voice = self.voices[self.voice_index]
|
||||
encoded_voice = self.voices[self.voice_index]
|
||||
voice_name = encoded_voice.split("|", 1)[0]
|
||||
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Testing...", interrupt=True
|
||||
)
|
||||
if self.preview_voice(module, voice):
|
||||
# Store for apply command
|
||||
self._request_generation += 1
|
||||
generation = self._request_generation
|
||||
self._loading = True
|
||||
self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test(
|
||||
module,
|
||||
voice_name,
|
||||
self.test_message,
|
||||
lambda succeeded, error: self._voice_test_ready(
|
||||
generation, module, encoded_voice, succeeded, error
|
||||
),
|
||||
)
|
||||
|
||||
def _voice_test_ready(
|
||||
self, generation, module, voice, succeeded, _error
|
||||
):
|
||||
if generation != self._request_generation:
|
||||
return
|
||||
self._loading = False
|
||||
if succeeded:
|
||||
self.env["commandBuffer"]["lastTestedModule"] = module
|
||||
self.env["commandBuffer"]["lastTestedVoice"] = voice
|
||||
voice_name, separator, language = voice.partition("|")
|
||||
self.env["commandBuffer"]["lastTestedVoice"] = voice_name
|
||||
if separator:
|
||||
self.env["commandBuffer"]["lastTestedLanguage"] = language
|
||||
else:
|
||||
self.env["commandBuffer"].pop("lastTestedLanguage", None)
|
||||
self.env["runtime"]["OutputManager"].play_sound("Accept")
|
||||
else:
|
||||
self.env["runtime"]["OutputManager"].play_sound("Error")
|
||||
@@ -213,13 +264,17 @@ class command:
|
||||
return
|
||||
|
||||
module = self.modules[self.module_index]
|
||||
voice = self.voices[self.voice_index]
|
||||
voice, separator, language = self.voices[self.voice_index].partition(
|
||||
"|"
|
||||
)
|
||||
|
||||
try:
|
||||
SettingsManager = self.env["runtime"]["SettingsManager"]
|
||||
SettingsManager.settings["speech"]["driver"] = "speechdDriver"
|
||||
SettingsManager.settings["speech"]["module"] = module
|
||||
SettingsManager.settings["speech"]["voice"] = voice
|
||||
if separator:
|
||||
SettingsManager.settings["speech"]["language"] = language
|
||||
|
||||
if "SpeechDriver" in self.env["runtime"]:
|
||||
SpeechDriver = self.env["runtime"]["SpeechDriver"]
|
||||
@@ -237,50 +292,5 @@ class command:
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].play_sound("Error")
|
||||
|
||||
def preview_voice(self, module, voice):
|
||||
"""Test voice with spd-say"""
|
||||
try:
|
||||
cmd = ["spd-say", "-o", module, "-y", voice, self.testMessage]
|
||||
result = subprocess.run(cmd, timeout=10)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_speechd_modules(self):
|
||||
"""Get available speech modules"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["spd-say", "-O"], capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
return [line.strip() for line in lines[1:] if line.strip()]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
def get_module_voices(self, module):
|
||||
"""Get voices for module"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["spd-say", "-o", module, "-L"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
voices = []
|
||||
for line in lines[1:]:
|
||||
if not line.strip():
|
||||
continue
|
||||
voice = get_synthesis_voice_name(module, line)
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
return voices
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
|
||||
@@ -1,207 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
self._request_generation = 0
|
||||
self._loading = False
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
self.testMessage = (
|
||||
self.test_message = (
|
||||
"Voice test: The quick brown fox jumps over the lazy dog."
|
||||
)
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
self._request_generation += 1
|
||||
self._loading = False
|
||||
|
||||
def get_description(self):
|
||||
return "Safe voice browser - cycles through voices without hanging"
|
||||
return _("browse and test Speech Dispatcher voices")
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
if self._loading:
|
||||
return
|
||||
self._loading = True
|
||||
self._request_generation += 1
|
||||
generation = self._request_generation
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("Loading speech modules"), interrupt=True
|
||||
)
|
||||
self.env["runtime"]["SpeechDiscoveryManager"].request_modules(
|
||||
lambda modules, error: self._modules_ready(
|
||||
generation, modules, error
|
||||
)
|
||||
)
|
||||
|
||||
def _modules_ready(self, generation, modules, error):
|
||||
if generation != self._request_generation:
|
||||
return
|
||||
if error or not modules:
|
||||
self._loading = False
|
||||
self._report_error(_("No speech modules found"), error)
|
||||
return
|
||||
|
||||
module_index = self.env["commandBuffer"].get(
|
||||
"safeBrowserModuleIndex", 0
|
||||
)
|
||||
if module_index >= len(modules):
|
||||
module_index = 0
|
||||
module = modules[module_index]
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("Loading voices for {module}").format(module=module),
|
||||
interrupt=True,
|
||||
)
|
||||
self.env["runtime"]["SpeechDiscoveryManager"].request_voices(
|
||||
module,
|
||||
lambda _module, voices, voice_error: self._voices_ready(
|
||||
generation,
|
||||
modules,
|
||||
module_index,
|
||||
voices,
|
||||
voice_error,
|
||||
),
|
||||
)
|
||||
|
||||
def _voices_ready(
|
||||
self, generation, modules, module_index, voices, error
|
||||
):
|
||||
if generation != self._request_generation:
|
||||
return
|
||||
if error or not voices:
|
||||
self._loading = False
|
||||
next_module = (module_index + 1) % len(modules)
|
||||
self.env["commandBuffer"]["safeBrowserModuleIndex"] = next_module
|
||||
self.env["commandBuffer"]["safeBrowserVoiceIndex"] = 0
|
||||
self._report_error(_("No voices found"), error)
|
||||
return
|
||||
|
||||
voice_index = self.env["commandBuffer"].get(
|
||||
"safeBrowserVoiceIndex", 0
|
||||
)
|
||||
if voice_index >= len(voices):
|
||||
voice_index = 0
|
||||
module = modules[module_index]
|
||||
voice = voices[voice_index]
|
||||
voice_name = voice.split("|", 1)[0]
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("Module: {module}, voice: {voice}").format(
|
||||
module=module, voice=voice_name
|
||||
),
|
||||
interrupt=True,
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("Testing voice"), interrupt=True
|
||||
)
|
||||
self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test(
|
||||
module,
|
||||
voice_name,
|
||||
self.test_message,
|
||||
lambda succeeded, test_error: self._test_ready(
|
||||
generation,
|
||||
modules,
|
||||
module_index,
|
||||
voices,
|
||||
voice_index,
|
||||
succeeded,
|
||||
test_error,
|
||||
),
|
||||
)
|
||||
|
||||
def _test_ready(
|
||||
self,
|
||||
generation,
|
||||
modules,
|
||||
module_index,
|
||||
voices,
|
||||
voice_index,
|
||||
succeeded,
|
||||
error,
|
||||
):
|
||||
if generation != self._request_generation:
|
||||
return
|
||||
self._loading = False
|
||||
module = modules[module_index]
|
||||
voice_name, separator, language = voices[voice_index].partition("|")
|
||||
if succeeded:
|
||||
self.env["commandBuffer"]["lastTestedModule"] = module
|
||||
self.env["commandBuffer"]["lastTestedVoice"] = voice_name
|
||||
if separator:
|
||||
self.env["commandBuffer"]["lastTestedLanguage"] = language
|
||||
else:
|
||||
self.env["commandBuffer"].pop("lastTestedLanguage", None)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Starting safe voice browser", interrupt=True
|
||||
_("Voice test completed successfully"), interrupt=True
|
||||
)
|
||||
else:
|
||||
self._report_error(_("Voice test failed"), error)
|
||||
|
||||
# Get modules with timeout protection
|
||||
modules = self.get_speechd_modules_with_timeout()
|
||||
if not modules:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"No speech modules found", interrupt=True
|
||||
)
|
||||
return
|
||||
voice_index += 1
|
||||
if voice_index >= len(voices):
|
||||
voice_index = 0
|
||||
module_index = (module_index + 1) % len(modules)
|
||||
self.env["commandBuffer"]["safeBrowserModuleIndex"] = module_index
|
||||
self.env["commandBuffer"]["safeBrowserVoiceIndex"] = voice_index
|
||||
|
||||
# Get current position from commandBuffer or start fresh
|
||||
module_index = self.env["commandBuffer"].get(
|
||||
"safeBrowserModuleIndex", 0
|
||||
def _report_error(self, message, detail):
|
||||
if detail:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"voice_browser_safe: {detail}",
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
voice_index = self.env["commandBuffer"].get(
|
||||
"safeBrowserVoiceIndex", 0
|
||||
)
|
||||
|
||||
# Ensure valid module index
|
||||
if module_index >= len(modules):
|
||||
module_index = 0
|
||||
|
||||
current_module = modules[module_index]
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Loading voices for {current_module}...", interrupt=True
|
||||
)
|
||||
|
||||
# Get voices with timeout protection
|
||||
voices = self.get_module_voices_with_timeout(current_module)
|
||||
if not voices:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"No voices in {current_module}, trying next module",
|
||||
interrupt=True,
|
||||
)
|
||||
module_index = (module_index + 1) % len(modules)
|
||||
self.env["commandBuffer"][
|
||||
"safeBrowserModuleIndex"
|
||||
] = module_index
|
||||
self.env["commandBuffer"]["safeBrowserVoiceIndex"] = 0
|
||||
return
|
||||
|
||||
# Ensure valid voice index
|
||||
if voice_index >= len(voices):
|
||||
voice_index = 0
|
||||
|
||||
current_voice = voices[voice_index]
|
||||
|
||||
# Announce current selection
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Module: {current_module} ({module_index + 1}/{len(modules)})",
|
||||
interrupt=True,
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Voice: {current_voice} ({voice_index + 1}/{len(voices)})",
|
||||
interrupt=True,
|
||||
)
|
||||
|
||||
# Test voice in background thread to avoid blocking
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Testing voice...", interrupt=True
|
||||
)
|
||||
|
||||
# Use threading to prevent freezing
|
||||
test_thread = threading.Thread(
|
||||
target=self.test_voice_async,
|
||||
args=(current_module, current_voice),
|
||||
)
|
||||
test_thread.daemon = True
|
||||
test_thread.start()
|
||||
|
||||
# Store tested voice for apply command
|
||||
self.env["commandBuffer"]["lastTestedModule"] = current_module
|
||||
self.env["commandBuffer"]["lastTestedVoice"] = current_voice
|
||||
|
||||
# Advance to next voice for next run
|
||||
voice_index += 1
|
||||
if voice_index >= len(voices):
|
||||
voice_index = 0
|
||||
module_index = (module_index + 1) % len(modules)
|
||||
|
||||
# Store position for next run
|
||||
self.env["commandBuffer"]["safeBrowserModuleIndex"] = module_index
|
||||
self.env["commandBuffer"]["safeBrowserVoiceIndex"] = voice_index
|
||||
|
||||
# Give instructions
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Run again for next voice, or use apply voice command",
|
||||
interrupt=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Voice browser error: {str(e)}", interrupt=True
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].play_sound("Error")
|
||||
|
||||
def test_voice_async(self, module, voice):
|
||||
"""Test voice in background thread to avoid blocking"""
|
||||
try:
|
||||
# Run with strict timeout
|
||||
cmd = ["spd-say", "-o", module, "-y", voice, self.testMessage]
|
||||
result = subprocess.run(cmd, timeout=5, capture_output=True)
|
||||
|
||||
# Schedule success sound for main thread
|
||||
if result.returncode == 0:
|
||||
# We can't call OutputManager from background thread safely
|
||||
# So we'll just let the main thread handle feedback
|
||||
pass
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
# Voice test timed out - this is okay, don't crash
|
||||
pass
|
||||
except Exception:
|
||||
# Any other error - also okay, don't crash
|
||||
pass
|
||||
|
||||
def get_speechd_modules_with_timeout(self):
|
||||
"""Get speech modules with timeout protection"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["spd-say", "-O"], capture_output=True, text=True, timeout=3
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
modules = [line.strip() for line in lines[1:] if line.strip()]
|
||||
# Limit to first 10 modules to prevent overload
|
||||
return modules[:10]
|
||||
except subprocess.TimeoutExpired:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Module detection timed out", interrupt=True
|
||||
)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Module detection failed: {str(e)}", interrupt=True
|
||||
)
|
||||
return []
|
||||
|
||||
def get_module_voices_with_timeout(self, module):
|
||||
"""Get voices with timeout and limits"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["spd-say", "-o", module, "-L"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
voices = []
|
||||
for line in lines[1:]:
|
||||
if not line.strip():
|
||||
continue
|
||||
voice = get_synthesis_voice_name(module, line)
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
|
||||
# Limit voice count to prevent memory issues
|
||||
if len(voices) > 1000:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"found {len(voices)} voices, limiting to first 1000",
|
||||
interrupt=True,
|
||||
)
|
||||
voices = voices[:1000]
|
||||
|
||||
return voices
|
||||
except subprocess.TimeoutExpired:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Voice detection for {module} timed out", interrupt=True
|
||||
)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Voice detection failed: {str(e)}", interrupt=True
|
||||
)
|
||||
return []
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
message, interrupt=True
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def get_description(self):
|
||||
return _("show next help category")
|
||||
|
||||
def run(self):
|
||||
text = self.env["runtime"]["HelpManager"].next_help_section()
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
text, interrupt=True
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def get_description(self):
|
||||
return _("show previous help category")
|
||||
|
||||
def run(self):
|
||||
text = self.env["runtime"]["HelpManager"].prev_help_section()
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
text, interrupt=True
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
@@ -408,6 +408,20 @@ class command:
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if not interruptible_activity_match:
|
||||
interruptible_activity_match = re.search(
|
||||
(
|
||||
r'^\s*(?:thinking|working|processing|analyzing|searching|'
|
||||
r'reading|writing|planning|running|executing|updating|'
|
||||
r'building|installing|compiling|downloading|reviewing|'
|
||||
r'generating|responding|applying|fixing|editing|creating|'
|
||||
r'preparing|checking|opening|loading|fetching|retrieving|'
|
||||
r'scanning|indexing|summarizing)\b.*'
|
||||
r'\([^)]*(?:esc|ctrl\+c) to interrupt[^)]*\)\s*$'
|
||||
),
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if interruptible_activity_match:
|
||||
if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
import itertools
|
||||
import queue
|
||||
import threading
|
||||
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.core.eventData import FenrirEventType
|
||||
|
||||
|
||||
class BackgroundTaskManager:
|
||||
"""Run bounded auxiliary work without exposing Fenrir state to workers."""
|
||||
|
||||
def __init__(self, worker_count=4, external_worker_count=2):
|
||||
self.worker_count = worker_count
|
||||
self.external_worker_count = external_worker_count
|
||||
self.env = None
|
||||
self._accepting_tasks = False
|
||||
self._callbacks = {}
|
||||
self._callback_lock = threading.Lock()
|
||||
self._task_ids = itertools.count(1)
|
||||
self._task_queues = {
|
||||
"default": queue.Queue(),
|
||||
"external": queue.Queue(),
|
||||
}
|
||||
self._workers = []
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
self._accepting_tasks = True
|
||||
self._start_workers("default", self.worker_count)
|
||||
self._start_workers("external", self.external_worker_count)
|
||||
|
||||
def _start_workers(self, lane, worker_count):
|
||||
task_queue = self._task_queues[lane]
|
||||
for worker_index in range(worker_count):
|
||||
worker = threading.Thread(
|
||||
target=self._run_worker,
|
||||
args=(task_queue,),
|
||||
name=f"fenrir-{lane}-{worker_index + 1}",
|
||||
daemon=True,
|
||||
)
|
||||
self._workers.append(worker)
|
||||
worker.start()
|
||||
|
||||
def shutdown(self):
|
||||
self._accepting_tasks = False
|
||||
with self._callback_lock:
|
||||
self._callbacks.clear()
|
||||
|
||||
for task_queue in self._task_queues.values():
|
||||
try:
|
||||
while True:
|
||||
task_queue.get_nowait()
|
||||
task_queue.task_done()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
for _worker_index in range(self.worker_count):
|
||||
self._task_queues["default"].put(None)
|
||||
for _worker_index in range(self.external_worker_count):
|
||||
self._task_queues["external"].put(None)
|
||||
for worker in self._workers:
|
||||
worker.join(timeout=0.25)
|
||||
self._workers = []
|
||||
|
||||
def submit_task(self, function, callback, *args, **kwargs):
|
||||
"""Schedule a pure worker function and return its task identifier."""
|
||||
return self._submit_to_lane(
|
||||
"default", function, callback, args, kwargs
|
||||
)
|
||||
|
||||
def submit_external_task(self, function, callback, *args, **kwargs):
|
||||
"""Schedule potentially long-running user scripts on an isolated lane."""
|
||||
return self._submit_to_lane(
|
||||
"external", function, callback, args, kwargs
|
||||
)
|
||||
|
||||
def _submit_to_lane(self, lane, function, callback, args, kwargs):
|
||||
if not self._accepting_tasks or not callable(function):
|
||||
return None
|
||||
if not callable(callback):
|
||||
return None
|
||||
|
||||
task_id = next(self._task_ids)
|
||||
with self._callback_lock:
|
||||
self._callbacks[task_id] = callback
|
||||
self._task_queues[lane].put((task_id, function, args, kwargs))
|
||||
return task_id
|
||||
|
||||
def cancel_task(self, task_id):
|
||||
"""Discard delivery for a task that is no longer relevant."""
|
||||
if task_id is None:
|
||||
return
|
||||
with self._callback_lock:
|
||||
self._callbacks.pop(task_id, None)
|
||||
|
||||
def handle_result(self, result):
|
||||
"""Deliver a completed task on Fenrir's main event-loop thread."""
|
||||
if not isinstance(result, dict):
|
||||
return
|
||||
task_id = result.get("task_id")
|
||||
with self._callback_lock:
|
||||
callback = self._callbacks.pop(task_id, None)
|
||||
if callback is None:
|
||||
return
|
||||
try:
|
||||
callback(result)
|
||||
except Exception as error:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"BackgroundTaskManager handle_result: " + str(error),
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
|
||||
def _run_worker(self, task_queue):
|
||||
while True:
|
||||
task = task_queue.get()
|
||||
try:
|
||||
if task is None:
|
||||
return
|
||||
task_id, function, args, kwargs = task
|
||||
try:
|
||||
value = function(*args, **kwargs)
|
||||
result = {
|
||||
"task_id": task_id,
|
||||
"succeeded": True,
|
||||
"value": value,
|
||||
"error": "",
|
||||
}
|
||||
except Exception as error:
|
||||
result = {
|
||||
"task_id": task_id,
|
||||
"succeeded": False,
|
||||
"value": None,
|
||||
"error": str(error),
|
||||
}
|
||||
|
||||
if self._accepting_tasks:
|
||||
self.env["runtime"]["EventManager"].put_to_event_queue(
|
||||
FenrirEventType.background_task_result,
|
||||
result,
|
||||
)
|
||||
finally:
|
||||
task_queue.task_done()
|
||||
@@ -6,12 +6,64 @@
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.core.eventData import FenrirEventType
|
||||
from fenrirscreenreader.utils import x_clipboard
|
||||
|
||||
|
||||
def synchronize_clipboards(
|
||||
display, fenrir_text, last_observed_fenrir, last_observed_x
|
||||
):
|
||||
"""Perform one clipboard I/O cycle without accessing Fenrir managers."""
|
||||
result = {}
|
||||
try:
|
||||
x_text = x_clipboard.read_text(display)
|
||||
if not isinstance(x_text, str) or not x_text:
|
||||
x_text = None
|
||||
except Exception as error:
|
||||
return {"error": "ClipboardSyncManager paste failed: " + str(error)}
|
||||
|
||||
if fenrir_text and x_text and fenrir_text == x_text:
|
||||
return {
|
||||
"last_observed_fenrir": fenrir_text,
|
||||
"last_observed_x": x_text,
|
||||
}
|
||||
|
||||
fenrir_changed = (
|
||||
fenrir_text and fenrir_text != last_observed_fenrir
|
||||
)
|
||||
x_changed = x_text and x_text != last_observed_x
|
||||
|
||||
if fenrir_changed:
|
||||
try:
|
||||
written = x_clipboard.write_text(fenrir_text, display)
|
||||
except Exception as error:
|
||||
written = False
|
||||
result["error"] = (
|
||||
"ClipboardSyncManager copy failed: " + str(error)
|
||||
)
|
||||
result["last_observed_fenrir"] = fenrir_text
|
||||
if written:
|
||||
result["last_written_to_x"] = fenrir_text
|
||||
result["last_observed_x"] = fenrir_text
|
||||
return result
|
||||
|
||||
if x_changed:
|
||||
return {
|
||||
"import_text": x_text,
|
||||
"last_imported_from_x": x_text,
|
||||
"last_observed_fenrir": x_text,
|
||||
"last_observed_x": x_text,
|
||||
}
|
||||
|
||||
if fenrir_text:
|
||||
result["last_observed_fenrir"] = fenrir_text
|
||||
if x_text:
|
||||
result["last_observed_x"] = x_text
|
||||
return result
|
||||
|
||||
|
||||
class ClipboardSyncManager:
|
||||
def __init__(self):
|
||||
self.env = None
|
||||
@@ -19,7 +71,10 @@ class ClipboardSyncManager:
|
||||
self.display = ""
|
||||
self.interval = 0.5
|
||||
self.running = False
|
||||
self.thread = None
|
||||
self._task_id = None
|
||||
self._scheduler_stop = threading.Event()
|
||||
self._sync_event_pending = threading.Event()
|
||||
self._scheduler_thread = None
|
||||
self.last_written_to_x = None
|
||||
self.last_imported_from_x = None
|
||||
self.last_observed_fenrir = None
|
||||
@@ -82,19 +137,85 @@ class ClipboardSyncManager:
|
||||
if self.running:
|
||||
return
|
||||
self.running = True
|
||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||
self.thread.start()
|
||||
self._scheduler_stop.clear()
|
||||
self._scheduler_thread = threading.Thread(
|
||||
target=self._schedule_sync_events,
|
||||
name="fenrir-clipboard-scheduler",
|
||||
daemon=True,
|
||||
)
|
||||
self._scheduler_thread.start()
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
if self.thread:
|
||||
self.thread.join(timeout=1.0)
|
||||
self.thread = None
|
||||
task_manager = self.env["runtime"].get("BackgroundTaskManager")
|
||||
if task_manager and self._task_id is not None:
|
||||
task_manager.cancel_task(self._task_id)
|
||||
self._task_id = None
|
||||
self._scheduler_stop.set()
|
||||
self._sync_event_pending.clear()
|
||||
if self._scheduler_thread:
|
||||
self._scheduler_thread.join(timeout=1.0)
|
||||
self._scheduler_thread = None
|
||||
|
||||
def _run(self):
|
||||
def _schedule_sync_events(self):
|
||||
while self.running:
|
||||
self.poll_once()
|
||||
time.sleep(self.interval)
|
||||
if not self._sync_event_pending.is_set():
|
||||
self._sync_event_pending.set()
|
||||
self.env["runtime"]["EventManager"].put_to_event_queue(
|
||||
FenrirEventType.clipboard_sync, None
|
||||
)
|
||||
if self._scheduler_stop.wait(self.interval):
|
||||
return
|
||||
|
||||
def handle_sync_event(self):
|
||||
"""Schedule clipboard I/O while keeping manager state on the loop."""
|
||||
if not self.running or self._task_id is not None:
|
||||
return
|
||||
|
||||
fenrir_text = self._get_fenrir_clipboard_text()
|
||||
self._task_id = self.env["runtime"][
|
||||
"BackgroundTaskManager"
|
||||
].submit_task(
|
||||
synchronize_clipboards,
|
||||
lambda result: self._handle_poll_result(fenrir_text, result),
|
||||
self.display,
|
||||
fenrir_text,
|
||||
self.last_observed_fenrir,
|
||||
self.last_observed_x,
|
||||
)
|
||||
if self._task_id is None:
|
||||
self._sync_event_pending.clear()
|
||||
|
||||
def _handle_poll_result(self, fenrir_snapshot, task_result):
|
||||
self._task_id = None
|
||||
self._sync_event_pending.clear()
|
||||
if not self.running:
|
||||
return
|
||||
if not task_result.get("succeeded"):
|
||||
self._debug(
|
||||
"ClipboardSyncManager poll failed: "
|
||||
+ task_result.get("error", "unknown error")
|
||||
)
|
||||
return
|
||||
|
||||
result = task_result.get("value") or {}
|
||||
if result.get("error"):
|
||||
self._debug(result["error"])
|
||||
import_text = result.get("import_text")
|
||||
if import_text:
|
||||
if self._get_fenrir_clipboard_text() != fenrir_snapshot:
|
||||
return
|
||||
self.env["runtime"]["MemoryManager"].add_value_to_first_index(
|
||||
"clipboardHistory", import_text
|
||||
)
|
||||
for attribute in (
|
||||
"last_written_to_x",
|
||||
"last_imported_from_x",
|
||||
"last_observed_fenrir",
|
||||
"last_observed_x",
|
||||
):
|
||||
if attribute in result:
|
||||
setattr(self, attribute, result[attribute])
|
||||
|
||||
def poll_once(self):
|
||||
fenrir_text = self._get_fenrir_clipboard_text()
|
||||
|
||||
@@ -436,7 +436,10 @@ class CommandManager:
|
||||
)
|
||||
|
||||
def execute_command(self, command, section="commands"):
|
||||
if self.env["runtime"]["ScreenManager"].is_ignored_screen():
|
||||
if (
|
||||
self.env["runtime"]["ScreenManager"].is_ignored_screen()
|
||||
and not self.env["runtime"]["HelpManager"].is_tutorial_mode()
|
||||
):
|
||||
return
|
||||
if self.command_exists(command, section):
|
||||
try:
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -19,6 +19,8 @@ class FenrirEventType(Enum):
|
||||
heart_beat = 6
|
||||
execute_command = 7
|
||||
remote_incomming = 8
|
||||
background_task_result = 9
|
||||
clipboard_sync = 10
|
||||
|
||||
def __int__(self):
|
||||
return self.value
|
||||
|
||||
@@ -63,6 +63,12 @@ class EventManager:
|
||||
self.env["runtime"]["FenrirManager"].handle_execute_command(event)
|
||||
elif event["Type"] == FenrirEventType.remote_incomming:
|
||||
self.env["runtime"]["FenrirManager"].handle_remote_incomming(event)
|
||||
elif event["Type"] == FenrirEventType.background_task_result:
|
||||
self.env["runtime"]["FenrirManager"].handle_background_task_result(
|
||||
event
|
||||
)
|
||||
elif event["Type"] == FenrirEventType.clipboard_sync:
|
||||
self.env["runtime"]["FenrirManager"].handle_clipboard_sync(event)
|
||||
|
||||
def is_main_event_loop_running(self):
|
||||
return self.running.value == 1
|
||||
|
||||
@@ -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"]
|
||||
@@ -229,13 +249,20 @@ class FenrirManager:
|
||||
):
|
||||
self.environment["runtime"]["InputManager"].clear_last_deep_input()
|
||||
|
||||
if not self._input_target_is_active():
|
||||
self.environment["runtime"]["InputManager"].clear_last_deep_input()
|
||||
return
|
||||
|
||||
if (
|
||||
self.environment["runtime"][
|
||||
"CursorManager"
|
||||
].is_cursor_vertical_move()
|
||||
or self.environment["runtime"][
|
||||
"CursorManager"
|
||||
].is_cursor_horizontal_move()
|
||||
self._has_direct_user_input()
|
||||
and (
|
||||
self.environment["runtime"][
|
||||
"CursorManager"
|
||||
].is_cursor_vertical_move()
|
||||
or self.environment["runtime"][
|
||||
"CursorManager"
|
||||
].is_cursor_horizontal_move()
|
||||
)
|
||||
):
|
||||
self.environment["runtime"][
|
||||
"CommandManager"
|
||||
@@ -244,7 +271,22 @@ class FenrirManager:
|
||||
self.environment["runtime"]["CommandManager"].execute_default_trigger(
|
||||
"onScreenUpdate"
|
||||
)
|
||||
self.environment["runtime"]["InputManager"].clear_last_deep_input()
|
||||
|
||||
def _input_target_is_active(self):
|
||||
input_driver = self.environment["runtime"].get("InputDriver")
|
||||
if input_driver is None:
|
||||
return True
|
||||
return input_driver.is_active()
|
||||
|
||||
def _has_direct_user_input(self):
|
||||
deepest_input = self.environment["runtime"][
|
||||
"InputManager"
|
||||
].get_last_deepest_input()
|
||||
if not deepest_input:
|
||||
return False
|
||||
return not any(
|
||||
key in deepest_input for key in ("KEY_FENRIR", "KEY_SCRIPT")
|
||||
)
|
||||
|
||||
def handle_plug_input_device(self, event):
|
||||
try:
|
||||
@@ -268,10 +310,19 @@ class FenrirManager:
|
||||
)
|
||||
|
||||
def handle_heart_beat(self, event):
|
||||
if not self._input_target_is_active():
|
||||
return
|
||||
self.environment["runtime"]["CommandManager"].execute_default_trigger(
|
||||
"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 +332,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 +374,13 @@ class FenrirManager:
|
||||
self.singleKeyCommand = True
|
||||
elif (
|
||||
(
|
||||
self.environment["runtime"]["VmenuManager"].get_active()
|
||||
(
|
||||
"HelpManager" in self.environment["runtime"]
|
||||
and self.environment["runtime"][
|
||||
"HelpManager"
|
||||
].is_tutorial_mode()
|
||||
)
|
||||
or self.environment["runtime"]["VmenuManager"].get_active()
|
||||
or self.environment["runtime"][
|
||||
"DiffReviewManager"
|
||||
].is_active()
|
||||
|
||||
@@ -24,6 +24,8 @@ general_data = {
|
||||
"SpeechHistoryManager",
|
||||
"HelpManager",
|
||||
"MemoryManager",
|
||||
"SpeechDiscoveryManager",
|
||||
"BackgroundTaskManager",
|
||||
"EventManager",
|
||||
"ProcessManager",
|
||||
"VmenuManager",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -26,6 +26,10 @@ class InputDriver:
|
||||
time.sleep(0.1)
|
||||
return None
|
||||
|
||||
def is_active(self):
|
||||
"""Return whether this input driver's target currently has focus."""
|
||||
return True
|
||||
|
||||
def clear_event_buffer(self):
|
||||
if not self._initialized:
|
||||
return
|
||||
@@ -54,6 +58,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,135 +4,19 @@
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.core.settingsData import settings_data
|
||||
from fenrirscreenreader.utils.speechd_utils import (
|
||||
get_synthesis_voice_name,
|
||||
parse_synthesis_voice_line,
|
||||
)
|
||||
|
||||
|
||||
class SpeechHelperMixin:
|
||||
"""Helper methods for querying speech-dispatcher modules and voices.
|
||||
|
||||
Provides caching and query functionality for speech-dispatcher module
|
||||
and voice enumeration, reusing proven logic from voice_browser.py.
|
||||
"""
|
||||
class QuickMenuManager:
|
||||
|
||||
def __init__(self):
|
||||
self._modules_cache = None
|
||||
self._voices_cache = {} # {module_name: [voice_list]}
|
||||
self._cache_timestamp = 0
|
||||
self._cache_timeout = 300 # 5 minutes
|
||||
|
||||
def get_speechd_modules(self):
|
||||
"""Get available speech-dispatcher modules (cached).
|
||||
|
||||
Returns:
|
||||
list: Available module names (e.g., ['espeak-ng', 'festival'])
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
# Return cached if valid
|
||||
if (self._modules_cache and
|
||||
(now - self._cache_timestamp) < self._cache_timeout):
|
||||
return self._modules_cache
|
||||
|
||||
# Query spd-say
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["spd-say", "-O"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=8
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
self._modules_cache = [
|
||||
line.strip() for line in lines[1:]
|
||||
if line.strip() and line.strip().lower() != "dummy"
|
||||
]
|
||||
self._cache_timestamp = now
|
||||
return self._modules_cache
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
(f"QuickMenuManager get_speechd_modules: "
|
||||
f"Error querying modules: {e}"),
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
def get_module_voices(self, module):
|
||||
"""Get voices for a specific module (cached per-module).
|
||||
|
||||
Args:
|
||||
module (str): Module name (e.g., 'espeak-ng')
|
||||
|
||||
Returns:
|
||||
list: Available voice names for this module
|
||||
"""
|
||||
# Return cached if available
|
||||
if module in self._voices_cache:
|
||||
return self._voices_cache[module]
|
||||
|
||||
# Query spd-say
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["spd-say", "-o", module, "-L"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=8
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
voices = []
|
||||
for line in lines[1:]:
|
||||
if not line.strip():
|
||||
continue
|
||||
if module.lower() == "voxin":
|
||||
# For Voxin, store voice name with language
|
||||
voice_data = self._process_voxin_voice(line)
|
||||
if voice_data:
|
||||
voices.append(voice_data)
|
||||
else:
|
||||
voice = get_synthesis_voice_name(module, line)
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
|
||||
self._voices_cache[module] = voices
|
||||
return voices
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
(f"QuickMenuManager get_module_voices: "
|
||||
f"Error querying voices for {module}: {e}"),
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
def _process_voxin_voice(self, voice_line):
|
||||
"""Process Voxin voice format with language information.
|
||||
|
||||
Args:
|
||||
voice_line (str): Raw line from spd-say -o voxin -L output
|
||||
Format: NAME LANGUAGE VARIANT
|
||||
|
||||
Returns:
|
||||
str: Voice name with language encoded (e.g., 'daniel-embedded-high|en-GB')
|
||||
"""
|
||||
voice_data = parse_synthesis_voice_line(voice_line)
|
||||
if voice_data is None:
|
||||
return None
|
||||
voice_name, language, _variant = voice_data
|
||||
if not language:
|
||||
return None
|
||||
# Encode language with voice for later extraction
|
||||
return f"{voice_name}|{language}"
|
||||
self.position = 0
|
||||
self.quickMenu = []
|
||||
self.settings = settings_data
|
||||
self._module_request_pending = False
|
||||
self._voice_requests_pending = set()
|
||||
|
||||
def _select_default_voice(self, voices):
|
||||
"""Select a sensible default voice from list, preferring user's
|
||||
@@ -202,18 +86,7 @@ class SpeechHelperMixin:
|
||||
return voices[0]
|
||||
|
||||
def invalidate_speech_cache(self):
|
||||
"""Clear cached module and voice data."""
|
||||
self._modules_cache = None
|
||||
self._voices_cache = {}
|
||||
self._cache_timestamp = 0
|
||||
|
||||
|
||||
class QuickMenuManager(SpeechHelperMixin):
|
||||
def __init__(self):
|
||||
SpeechHelperMixin.__init__(self)
|
||||
self.position = 0
|
||||
self.quickMenu = []
|
||||
self.settings = settings_data
|
||||
self.env["runtime"]["SpeechDiscoveryManager"].invalidate_cache()
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
@@ -420,197 +293,202 @@ class QuickMenuManager(SpeechHelperMixin):
|
||||
return True
|
||||
|
||||
def cycle_speech_module(self, direction):
|
||||
"""Cycle to next/previous speech-dispatcher module.
|
||||
if self._module_request_pending:
|
||||
return False
|
||||
discovery = self.env["runtime"]["SpeechDiscoveryManager"]
|
||||
modules = discovery.get_cached_modules()
|
||||
if modules is not None:
|
||||
return self._continue_module_cycle(direction, modules, False)
|
||||
|
||||
Args:
|
||||
direction (str): 'next' or 'prev'
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Get available modules
|
||||
modules = self.get_speechd_modules()
|
||||
if not modules:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"No modules available", interrupt=True
|
||||
)
|
||||
return False
|
||||
|
||||
# Get current module
|
||||
current_module = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "module"
|
||||
self._module_request_pending = True
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Loading speech modules", interrupt=True
|
||||
)
|
||||
discovery.request_modules(
|
||||
lambda found, error: self._continue_module_cycle(
|
||||
direction, found, True, error
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
# Find current index
|
||||
try:
|
||||
current_index = (modules.index(current_module)
|
||||
if current_module else 0)
|
||||
except ValueError:
|
||||
current_index = 0
|
||||
|
||||
# Cycle to next/previous
|
||||
if direction == "next":
|
||||
new_index = (current_index + 1) % len(modules)
|
||||
else: # prev
|
||||
new_index = (current_index - 1) % len(modules)
|
||||
|
||||
new_module = modules[new_index]
|
||||
|
||||
# Update setting (runtime only)
|
||||
self.env["runtime"]["SettingsManager"].set_setting(
|
||||
"speech", "module", new_module
|
||||
)
|
||||
|
||||
# Select sensible default voice for new module
|
||||
voices = self.get_module_voices(new_module)
|
||||
if voices:
|
||||
default_voice = self._select_default_voice(voices)
|
||||
|
||||
# Parse voice name and language for modules like Voxin
|
||||
voice_name = default_voice
|
||||
voice_lang = None
|
||||
if "|" in default_voice:
|
||||
voice_name, voice_lang = default_voice.split("|", 1)
|
||||
|
||||
self.env["runtime"]["SettingsManager"].set_setting(
|
||||
"speech", "voice", voice_name
|
||||
)
|
||||
|
||||
# Apply voice to speech driver immediately
|
||||
if "SpeechDriver" in self.env["runtime"]:
|
||||
try:
|
||||
self.env["runtime"]["SpeechDriver"].set_module(
|
||||
new_module
|
||||
)
|
||||
# Set language first if available
|
||||
if voice_lang:
|
||||
self.env["runtime"]["SpeechDriver"].set_language(
|
||||
voice_lang
|
||||
)
|
||||
# Then set voice
|
||||
self.env["runtime"]["SpeechDriver"].set_voice(
|
||||
voice_name
|
||||
)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
(f"QuickMenuManager cycle_speech_module: "
|
||||
f"Error applying voice: {e}"),
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
|
||||
# Announce new module
|
||||
def _continue_module_cycle(
|
||||
self, direction, modules, announce_result, error=""
|
||||
):
|
||||
if error:
|
||||
self._module_request_pending = False
|
||||
self._report_discovery_error("module", error)
|
||||
return False
|
||||
if not modules:
|
||||
self._module_request_pending = False
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
new_module, interrupt=True
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"QuickMenuManager cycle_speech_module: Error: {e}",
|
||||
debug.DebugLevel.ERROR
|
||||
"No modules available", interrupt=True
|
||||
)
|
||||
return False
|
||||
|
||||
def cycle_speech_voice(self, direction):
|
||||
"""Cycle to next/previous voice for current module.
|
||||
|
||||
Args:
|
||||
direction (str): 'next' or 'prev'
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Get current module
|
||||
current_module = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "module"
|
||||
)
|
||||
try:
|
||||
current_index = (
|
||||
modules.index(current_module) if current_module else 0
|
||||
)
|
||||
except ValueError:
|
||||
current_index = 0
|
||||
if direction == "next":
|
||||
new_index = (current_index + 1) % len(modules)
|
||||
else:
|
||||
new_index = (current_index - 1) % len(modules)
|
||||
new_module = modules[new_index]
|
||||
discovery = self.env["runtime"]["SpeechDiscoveryManager"]
|
||||
voices = discovery.get_cached_voices(new_module)
|
||||
if voices is not None:
|
||||
self._module_request_pending = False
|
||||
return self._apply_module(
|
||||
new_module, voices, announce_result
|
||||
)
|
||||
|
||||
self._module_request_pending = True
|
||||
discovery.request_voices(
|
||||
new_module,
|
||||
lambda _module, found, voice_error: self._finish_module_cycle(
|
||||
new_module, found, voice_error
|
||||
),
|
||||
)
|
||||
return False
|
||||
except Exception as error:
|
||||
self._module_request_pending = False
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"QuickMenuManager cycle_speech_module: Error: {error}",
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
return False
|
||||
|
||||
def _finish_module_cycle(self, module, voices, error):
|
||||
self._module_request_pending = False
|
||||
if error:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"QuickMenuManager voice discovery failed: {error}",
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
self._apply_module(module, voices, False)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"{module}; voice discovery failed", interrupt=True
|
||||
)
|
||||
return
|
||||
self._apply_module(module, voices, True)
|
||||
|
||||
def _apply_module(self, module, voices, announce_result):
|
||||
settings_manager = self.env["runtime"]["SettingsManager"]
|
||||
settings_manager.set_setting("speech", "module", module)
|
||||
speech_driver = self.env["runtime"].get("SpeechDriver")
|
||||
if speech_driver:
|
||||
speech_driver.set_module(module)
|
||||
|
||||
if voices:
|
||||
default_voice = self._select_default_voice(voices)
|
||||
voice_name, voice_language = self._split_voice(default_voice)
|
||||
settings_manager.set_setting("speech", "voice", voice_name)
|
||||
if speech_driver:
|
||||
if voice_language:
|
||||
speech_driver.set_language(voice_language)
|
||||
speech_driver.set_voice(voice_name)
|
||||
|
||||
if announce_result:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
module, interrupt=True
|
||||
)
|
||||
return True
|
||||
|
||||
def cycle_speech_voice(self, direction):
|
||||
try:
|
||||
current_module = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "module"
|
||||
)
|
||||
if not current_module:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"No module selected", interrupt=True
|
||||
)
|
||||
return False
|
||||
|
||||
# Get available voices for this module
|
||||
voices = self.get_module_voices(current_module)
|
||||
if not voices:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"No voices for module {current_module}", interrupt=True
|
||||
)
|
||||
discovery = self.env["runtime"]["SpeechDiscoveryManager"]
|
||||
voices = discovery.get_cached_voices(current_module)
|
||||
if voices is not None:
|
||||
return self._apply_voice(direction, voices, False)
|
||||
if current_module in self._voice_requests_pending:
|
||||
return False
|
||||
|
||||
# Get current voice
|
||||
current_voice = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "voice"
|
||||
self._voice_requests_pending.add(current_module)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Loading voices for {current_module}", interrupt=True
|
||||
)
|
||||
|
||||
# Find current index (handle Voxin voice|language format)
|
||||
current_index = 0
|
||||
if current_voice:
|
||||
try:
|
||||
# Try exact match first
|
||||
current_index = voices.index(current_voice)
|
||||
except ValueError:
|
||||
# For Voxin, compare just the voice name part
|
||||
for i, voice in enumerate(voices):
|
||||
voice_name = voice.split("|")[0] if "|" in voice else voice
|
||||
if voice_name == current_voice:
|
||||
current_index = i
|
||||
break
|
||||
|
||||
# Cycle to next/previous
|
||||
if direction == "next":
|
||||
new_index = (current_index + 1) % len(voices)
|
||||
else: # prev
|
||||
new_index = (current_index - 1) % len(voices)
|
||||
|
||||
new_voice = voices[new_index]
|
||||
|
||||
# Parse voice name and language for modules like Voxin
|
||||
voice_name = new_voice
|
||||
voice_lang = None
|
||||
if "|" in new_voice:
|
||||
# Format: "voicename|language" (e.g., "daniel-embedded-high|en-GB")
|
||||
voice_name, voice_lang = new_voice.split("|", 1)
|
||||
|
||||
# Update setting (runtime only) - store the voice name only
|
||||
self.env["runtime"]["SettingsManager"].set_setting(
|
||||
"speech", "voice", voice_name
|
||||
discovery.request_voices(
|
||||
current_module,
|
||||
lambda module, found, error: self._finish_voice_cycle(
|
||||
direction, module, found, error
|
||||
),
|
||||
)
|
||||
return False
|
||||
except Exception as error:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"QuickMenuManager cycle_speech_voice: Error: {error}",
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
return False
|
||||
|
||||
# Apply voice to speech driver immediately
|
||||
if "SpeechDriver" in self.env["runtime"]:
|
||||
try:
|
||||
# Set language first if available
|
||||
if voice_lang:
|
||||
self.env["runtime"]["SpeechDriver"].set_language(
|
||||
voice_lang
|
||||
)
|
||||
# Then set voice
|
||||
self.env["runtime"]["SpeechDriver"].set_voice(voice_name)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
(f"QuickMenuManager cycle_speech_voice: "
|
||||
f"Error applying voice: {e}"),
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
def _finish_voice_cycle(self, direction, module, voices, error):
|
||||
self._voice_requests_pending.discard(module)
|
||||
if error:
|
||||
self._report_discovery_error("voice", error)
|
||||
return
|
||||
self._apply_voice(direction, voices, True)
|
||||
|
||||
# Announce new voice (voice name only, not language)
|
||||
def _apply_voice(self, direction, voices, announce_result):
|
||||
if not voices:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"No voices available", interrupt=True
|
||||
)
|
||||
return False
|
||||
|
||||
settings_manager = self.env["runtime"]["SettingsManager"]
|
||||
current_voice = settings_manager.get_setting("speech", "voice")
|
||||
current_index = 0
|
||||
for voice_index, voice in enumerate(voices):
|
||||
voice_name, _language = self._split_voice(voice)
|
||||
if voice == current_voice or voice_name == current_voice:
|
||||
current_index = voice_index
|
||||
break
|
||||
if direction == "next":
|
||||
new_index = (current_index + 1) % len(voices)
|
||||
else:
|
||||
new_index = (current_index - 1) % len(voices)
|
||||
|
||||
voice_name, voice_language = self._split_voice(voices[new_index])
|
||||
settings_manager.set_setting("speech", "voice", voice_name)
|
||||
speech_driver = self.env["runtime"].get("SpeechDriver")
|
||||
if speech_driver:
|
||||
if voice_language:
|
||||
speech_driver.set_language(voice_language)
|
||||
speech_driver.set_voice(voice_name)
|
||||
if announce_result:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
voice_name, interrupt=True
|
||||
)
|
||||
return True
|
||||
|
||||
return True
|
||||
@staticmethod
|
||||
def _split_voice(voice):
|
||||
if "|" in voice:
|
||||
return tuple(voice.split("|", 1))
|
||||
return voice, None
|
||||
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"QuickMenuManager cycle_speech_voice: Error: {e}",
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
return False
|
||||
def _report_discovery_error(self, kind, error):
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"QuickMenuManager {kind} discovery failed: {error}",
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"{kind.capitalize()} discovery failed", interrupt=True
|
||||
)
|
||||
|
||||
def get_current_entry(self):
|
||||
if len(self.quickMenu) == 0:
|
||||
|
||||
@@ -22,6 +22,8 @@ runtime_data = {
|
||||
"SettingsManager": None,
|
||||
"FenrirManager": None,
|
||||
"EventManager": None,
|
||||
"BackgroundTaskManager": None,
|
||||
"ProcessManager": None,
|
||||
"SpeechDiscoveryManager": None,
|
||||
"DiffReviewManager": None,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from configparser import ConfigParser
|
||||
from fenrirscreenreader.core import applicationManager
|
||||
from fenrirscreenreader.core import attributeManager
|
||||
from fenrirscreenreader.core import barrierManager
|
||||
from fenrirscreenreader.core import backgroundTaskManager
|
||||
from fenrirscreenreader.core import clipboardSyncManager
|
||||
from fenrirscreenreader.core import commandManager
|
||||
from fenrirscreenreader.core import cursorManager
|
||||
@@ -32,6 +33,7 @@ from fenrirscreenreader.core import remoteManager
|
||||
from fenrirscreenreader.core import sayAllManager
|
||||
from fenrirscreenreader.core import screenManager
|
||||
from fenrirscreenreader.core import speechHistoryManager
|
||||
from fenrirscreenreader.core import speechDiscoveryManager
|
||||
from fenrirscreenreader.core import tableManager
|
||||
from fenrirscreenreader.core import textManager
|
||||
from fenrirscreenreader.core import vmenuManager
|
||||
@@ -753,6 +755,11 @@ class SettingsManager:
|
||||
] = processManager.ProcessManager()
|
||||
environment["runtime"]["ProcessManager"].initialize(environment)
|
||||
|
||||
environment["runtime"][
|
||||
"BackgroundTaskManager"
|
||||
] = backgroundTaskManager.BackgroundTaskManager()
|
||||
environment["runtime"]["BackgroundTaskManager"].initialize(environment)
|
||||
|
||||
environment["runtime"]["OutputManager"] = outputManager.OutputManager()
|
||||
environment["runtime"]["OutputManager"].initialize(environment)
|
||||
|
||||
@@ -807,6 +814,10 @@ class SettingsManager:
|
||||
environment["runtime"]["BarrierManager"].initialize(environment)
|
||||
environment["runtime"]["SayAllManager"] = sayAllManager.SayAllManager()
|
||||
environment["runtime"]["SayAllManager"].initialize(environment)
|
||||
environment["runtime"][
|
||||
"SpeechDiscoveryManager"
|
||||
] = speechDiscoveryManager.SpeechDiscoveryManager()
|
||||
environment["runtime"]["SpeechDiscoveryManager"].initialize(environment)
|
||||
environment["runtime"]["VmenuManager"] = vmenuManager.VmenuManager()
|
||||
environment["runtime"]["VmenuManager"].initialize(environment)
|
||||
environment["runtime"][
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.utils.speechd_utils import (
|
||||
get_synthesis_voice_name,
|
||||
parse_synthesis_voice_line,
|
||||
)
|
||||
|
||||
|
||||
MODULE_QUERY_TIMEOUT = 5
|
||||
VOICE_QUERY_TIMEOUT = 8
|
||||
VOICE_TEST_TIMEOUT = 8
|
||||
|
||||
|
||||
def query_speechd_modules(timeout=MODULE_QUERY_TIMEOUT):
|
||||
"""Return available Speech Dispatcher output modules."""
|
||||
result = subprocess.run(
|
||||
["spd-say", "-O"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr.strip() or "module discovery failed")
|
||||
return [
|
||||
line.strip()
|
||||
for line in result.stdout.splitlines()[1:]
|
||||
if line.strip() and line.strip().lower() != "dummy"
|
||||
]
|
||||
|
||||
|
||||
def query_speechd_voices(module, timeout=VOICE_QUERY_TIMEOUT):
|
||||
"""Return the synthesis voices exposed by one output module."""
|
||||
result = subprocess.run(
|
||||
["spd-say", "-o", module, "-L"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
result.stderr.strip() or f"voice discovery failed for {module}"
|
||||
)
|
||||
|
||||
voices = []
|
||||
for line in result.stdout.splitlines()[1:]:
|
||||
if not line.strip():
|
||||
continue
|
||||
voice = get_synthesis_voice_name(module, line)
|
||||
if module.lower() == "voxin":
|
||||
voice_data = parse_synthesis_voice_line(line)
|
||||
if voice_data is not None:
|
||||
voice_name, language, _variant = voice_data
|
||||
voice = f"{voice_name}|{language}" if language else None
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
return voices
|
||||
|
||||
|
||||
def test_speechd_voice(module, voice, message, timeout=VOICE_TEST_TIMEOUT):
|
||||
"""Play one bounded voice test and return when Speech Dispatcher finishes."""
|
||||
result = subprocess.run(
|
||||
["spd-say", "-C", "-w", "-o", module, "-y", voice, message],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr.strip() or "voice test failed")
|
||||
return True
|
||||
|
||||
|
||||
class SpeechDiscoveryManager:
|
||||
"""Cache and coordinate asynchronous Speech Dispatcher discovery."""
|
||||
|
||||
def __init__(self):
|
||||
self.env = None
|
||||
self.cache_timeout = 300
|
||||
self._modules = None
|
||||
self._modules_timestamp = 0
|
||||
self._voices = {}
|
||||
self._voice_timestamps = {}
|
||||
self._module_callbacks = []
|
||||
self._voice_callbacks = {}
|
||||
self._task_ids = set()
|
||||
self._module_task_id = None
|
||||
self._voice_task_ids = {}
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
|
||||
def shutdown(self):
|
||||
task_manager = self.env["runtime"].get("BackgroundTaskManager")
|
||||
if task_manager:
|
||||
for task_id in self._task_ids:
|
||||
task_manager.cancel_task(task_id)
|
||||
self._task_ids.clear()
|
||||
self._module_callbacks = []
|
||||
self._voice_callbacks = {}
|
||||
|
||||
def get_cached_modules(self):
|
||||
if not self._cache_is_current(self._modules_timestamp):
|
||||
return None
|
||||
return list(self._modules) if self._modules is not None else None
|
||||
|
||||
def get_cached_voices(self, module):
|
||||
if not self._cache_is_current(self._voice_timestamps.get(module, 0)):
|
||||
return None
|
||||
voices = self._voices.get(module)
|
||||
return list(voices) if voices is not None else None
|
||||
|
||||
def request_modules(self, callback, refresh=False):
|
||||
modules = None if refresh else self.get_cached_modules()
|
||||
if modules is not None:
|
||||
callback(modules, "")
|
||||
return None
|
||||
|
||||
self._module_callbacks.append(callback)
|
||||
if self._module_task_id is not None:
|
||||
return self._module_task_id
|
||||
|
||||
task_id = self._submit_task(query_speechd_modules, self._finish_modules)
|
||||
if task_id is None:
|
||||
self._finish_modules(self._submission_failure())
|
||||
return None
|
||||
self._module_task_id = task_id
|
||||
return task_id
|
||||
|
||||
def request_voices(self, module, callback, refresh=False):
|
||||
voices = None if refresh else self.get_cached_voices(module)
|
||||
if voices is not None:
|
||||
callback(module, voices, "")
|
||||
return None
|
||||
|
||||
callbacks = self._voice_callbacks.setdefault(module, [])
|
||||
callbacks.append(callback)
|
||||
if module in self._voice_task_ids:
|
||||
return self._voice_task_ids[module]
|
||||
|
||||
task_id = self._submit_task(
|
||||
query_speechd_voices,
|
||||
lambda result: self._finish_voices(module, result),
|
||||
module,
|
||||
)
|
||||
if task_id is None:
|
||||
self._finish_voices(module, self._submission_failure())
|
||||
return None
|
||||
self._voice_task_ids[module] = task_id
|
||||
return task_id
|
||||
|
||||
def request_voice_test(self, module, voice, message, callback):
|
||||
task_id = self._submit_task(
|
||||
test_speechd_voice,
|
||||
lambda result: callback(
|
||||
bool(result.get("succeeded")), result.get("error", "")
|
||||
),
|
||||
module,
|
||||
voice,
|
||||
message,
|
||||
)
|
||||
if task_id is None:
|
||||
callback(False, "background task manager is unavailable")
|
||||
return task_id
|
||||
|
||||
def invalidate_cache(self):
|
||||
self._modules = None
|
||||
self._modules_timestamp = 0
|
||||
self._voices = {}
|
||||
self._voice_timestamps = {}
|
||||
|
||||
def _cache_is_current(self, timestamp):
|
||||
return timestamp > 0 and time.time() - timestamp < self.cache_timeout
|
||||
|
||||
def _submit_task(self, function, callback, *args):
|
||||
task_manager = self.env["runtime"]["BackgroundTaskManager"]
|
||||
|
||||
def finish(result):
|
||||
self._task_ids.discard(result.get("task_id"))
|
||||
callback(result)
|
||||
|
||||
task_id = task_manager.submit_task(function, finish, *args)
|
||||
if task_id is None:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"SpeechDiscoveryManager could not submit background task",
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
return None
|
||||
self._task_ids.add(task_id)
|
||||
return task_id
|
||||
|
||||
@staticmethod
|
||||
def _submission_failure():
|
||||
return {
|
||||
"task_id": None,
|
||||
"succeeded": False,
|
||||
"value": None,
|
||||
"error": "background task manager is unavailable",
|
||||
}
|
||||
|
||||
def _finish_modules(self, result):
|
||||
self._module_task_id = None
|
||||
callbacks = self._module_callbacks
|
||||
self._module_callbacks = []
|
||||
error = result.get("error", "")
|
||||
modules = []
|
||||
if result.get("succeeded"):
|
||||
modules = list(result.get("value") or [])
|
||||
self._modules = modules
|
||||
self._modules_timestamp = time.time()
|
||||
self._run_callbacks(callbacks, list(modules), error)
|
||||
|
||||
def _finish_voices(self, module, result):
|
||||
self._voice_task_ids.pop(module, None)
|
||||
callbacks = self._voice_callbacks.pop(module, [])
|
||||
error = result.get("error", "")
|
||||
voices = []
|
||||
if result.get("succeeded"):
|
||||
voices = list(result.get("value") or [])
|
||||
self._voices[module] = voices
|
||||
self._voice_timestamps[module] = time.time()
|
||||
self._run_callbacks(callbacks, module, list(voices), error)
|
||||
|
||||
def _run_callbacks(self, callbacks, *args):
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(*args)
|
||||
except Exception as error:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"SpeechDiscoveryManager callback failed: " + str(error),
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
@@ -4,5 +4,5 @@
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
version = "2026.08.06"
|
||||
version = "2026.08.21"
|
||||
code_name = "testing"
|
||||
|
||||
@@ -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:
|
||||
@@ -244,6 +267,9 @@ class driver(inputDriver):
|
||||
return None
|
||||
return int(prop.value[0])
|
||||
|
||||
def is_active(self):
|
||||
return self.active
|
||||
|
||||
def format_window_id(self, window_id):
|
||||
if window_id is None:
|
||||
return "None"
|
||||
@@ -297,6 +323,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()
|
||||
@@ -304,6 +334,9 @@ class driver(inputDriver):
|
||||
if event_type == X.FocusOut:
|
||||
self.active = False
|
||||
self.reset_input_state()
|
||||
output_manager = self.env["runtime"].get("OutputManager")
|
||||
if output_manager:
|
||||
output_manager.interrupt_output_async()
|
||||
return
|
||||
if event_type not in [X.KeyPress, X.KeyRelease]:
|
||||
return
|
||||
@@ -316,6 +349,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 +371,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 +817,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 +880,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()
|
||||
|
||||
|
||||
@@ -49,6 +49,23 @@ class PTYConstants:
|
||||
|
||||
|
||||
class FenrirScreen(pyte.Screen):
|
||||
@property
|
||||
def display(self):
|
||||
def render(line):
|
||||
skip_stub = False
|
||||
for x in range(self.columns):
|
||||
if skip_stub:
|
||||
skip_stub = False
|
||||
continue
|
||||
char = line[x].data
|
||||
if not char:
|
||||
yield " "
|
||||
continue
|
||||
skip_stub = pyte.screens.wcwidth(char[0]) == 2
|
||||
yield char
|
||||
|
||||
return ["".join(render(self.buffer[y])) for y in range(self.lines)]
|
||||
|
||||
def set_margins(self, *args, **kwargs):
|
||||
kwargs.pop("private", None)
|
||||
super(FenrirScreen, self).set_margins(*args, **kwargs)
|
||||
@@ -445,11 +462,10 @@ class driver(screenDriver):
|
||||
)
|
||||
|
||||
def record_stdin_keypress(self, msg_bytes):
|
||||
if msg_bytes != b"\t":
|
||||
return
|
||||
key_name = self.stdin_key_name(msg_bytes)
|
||||
try:
|
||||
self.env["runtime"]["InputManager"].record_unmanaged_keypress(
|
||||
"KEY_TAB"
|
||||
key_name
|
||||
)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
@@ -457,6 +473,38 @@ class driver(screenDriver):
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
|
||||
def stdin_key_name(self, msg_bytes):
|
||||
key_map = {
|
||||
b"\t": "KEY_TAB",
|
||||
b"\x08": "KEY_BACKSPACE",
|
||||
b"\x7f": "KEY_BACKSPACE",
|
||||
b"\x1b[A": "KEY_UP",
|
||||
b"\x1bOA": "KEY_UP",
|
||||
b"\x1b[B": "KEY_DOWN",
|
||||
b"\x1bOB": "KEY_DOWN",
|
||||
b"\x1b[C": "KEY_RIGHT",
|
||||
b"\x1bOC": "KEY_RIGHT",
|
||||
b"\x1b[D": "KEY_LEFT",
|
||||
b"\x1bOD": "KEY_LEFT",
|
||||
b"\x1b[H": "KEY_HOME",
|
||||
b"\x1bOH": "KEY_HOME",
|
||||
b"\x1b[F": "KEY_END",
|
||||
b"\x1bOF": "KEY_END",
|
||||
b"\x1b[5~": "KEY_PAGEUP",
|
||||
b"\x1b[6~": "KEY_PAGEDOWN",
|
||||
b"\x1b[3~": "KEY_DELETE",
|
||||
b"\r": "KEY_ENTER",
|
||||
b"\n": "KEY_ENTER",
|
||||
b" ": "KEY_SPACE",
|
||||
}
|
||||
if msg_bytes in key_map:
|
||||
return key_map[msg_bytes]
|
||||
if len(msg_bytes) == 1:
|
||||
char = chr(msg_bytes[0])
|
||||
if char.isascii() and char.isalnum():
|
||||
return "KEY_" + char.upper()
|
||||
return "KEY_TERMINAL_INPUT"
|
||||
|
||||
def synthesize_backspace_shortcut(self, msg_bytes, event_queue):
|
||||
if not self.is_backspace_shortcut_sequence(msg_bytes):
|
||||
return False
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -68,11 +68,14 @@ def _run_command(command, display, input_text=None):
|
||||
input_bytes = None
|
||||
if input_text is not None:
|
||||
input_bytes = input_text.encode(_ENCODING)
|
||||
output_target = (
|
||||
subprocess.PIPE if input_text is None else subprocess.DEVNULL
|
||||
)
|
||||
return subprocess.run(
|
||||
command,
|
||||
input=input_bytes,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=output_target,
|
||||
stderr=output_target,
|
||||
env=_env_for_display(display),
|
||||
timeout=_CLIPBOARD_TIMEOUT,
|
||||
check=False,
|
||||
@@ -80,7 +83,9 @@ def _run_command(command, display, input_text=None):
|
||||
|
||||
|
||||
def _command_error(command, result):
|
||||
stderr = result.stderr.decode(_ENCODING, errors="replace").strip()
|
||||
stderr = (result.stderr or b"").decode(
|
||||
_ENCODING, errors="replace"
|
||||
).strip()
|
||||
if stderr:
|
||||
return RuntimeError(stderr)
|
||||
return RuntimeError(
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,109 @@
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import Mock
|
||||
|
||||
from fenrirscreenreader.core.backgroundTaskManager import BackgroundTaskManager
|
||||
from fenrirscreenreader.core.eventData import FenrirEventType
|
||||
|
||||
|
||||
def wait_for_call(mock, timeout=1.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while not mock.called and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert mock.called
|
||||
|
||||
|
||||
def test_worker_returns_result_through_event_queue_before_callback_runs():
|
||||
event_manager = Mock()
|
||||
environment = {
|
||||
"runtime": {
|
||||
"DebugManager": Mock(),
|
||||
"EventManager": event_manager,
|
||||
}
|
||||
}
|
||||
manager = BackgroundTaskManager(worker_count=1)
|
||||
manager.initialize(environment)
|
||||
callback = Mock()
|
||||
main_thread_id = threading.get_ident()
|
||||
|
||||
try:
|
||||
task_id = manager.submit_task(lambda value: value * 2, callback, 21)
|
||||
wait_for_call(event_manager.put_to_event_queue)
|
||||
|
||||
callback.assert_not_called()
|
||||
event_type, result = event_manager.put_to_event_queue.call_args.args
|
||||
assert event_type == FenrirEventType.background_task_result
|
||||
assert result == {
|
||||
"task_id": task_id,
|
||||
"succeeded": True,
|
||||
"value": 42,
|
||||
"error": "",
|
||||
}
|
||||
|
||||
callback.side_effect = lambda _result: setattr(
|
||||
callback, "thread_id", threading.get_ident()
|
||||
)
|
||||
manager.handle_result(result)
|
||||
callback.assert_called_once_with(result)
|
||||
assert callback.thread_id == main_thread_id
|
||||
finally:
|
||||
manager.shutdown()
|
||||
|
||||
|
||||
def test_cancelled_task_result_is_not_delivered():
|
||||
environment = {
|
||||
"runtime": {
|
||||
"DebugManager": Mock(),
|
||||
"EventManager": Mock(),
|
||||
}
|
||||
}
|
||||
manager = BackgroundTaskManager(worker_count=1)
|
||||
manager.initialize(environment)
|
||||
callback = Mock()
|
||||
|
||||
try:
|
||||
task_id = manager.submit_task(lambda: "late", callback)
|
||||
manager.cancel_task(task_id)
|
||||
manager.handle_result(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"succeeded": True,
|
||||
"value": "late",
|
||||
"error": "",
|
||||
}
|
||||
)
|
||||
callback.assert_not_called()
|
||||
finally:
|
||||
manager.shutdown()
|
||||
|
||||
|
||||
def test_external_tasks_cannot_starve_default_tasks():
|
||||
event_manager = Mock()
|
||||
environment = {
|
||||
"runtime": {
|
||||
"DebugManager": Mock(),
|
||||
"EventManager": event_manager,
|
||||
}
|
||||
}
|
||||
manager = BackgroundTaskManager(worker_count=1, external_worker_count=2)
|
||||
manager.initialize(environment)
|
||||
release_external = threading.Event()
|
||||
external_started = [threading.Event(), threading.Event()]
|
||||
|
||||
def block_external(started):
|
||||
started.set()
|
||||
release_external.wait(timeout=2)
|
||||
|
||||
try:
|
||||
for started in external_started:
|
||||
manager.submit_external_task(block_external, Mock(), started)
|
||||
for started in external_started:
|
||||
assert started.wait(timeout=1)
|
||||
|
||||
manager.submit_task(lambda: "voice result", Mock())
|
||||
wait_for_call(event_manager.put_to_event_queue)
|
||||
result = event_manager.put_to_event_queue.call_args.args[1]
|
||||
assert result["value"] == "voice result"
|
||||
finally:
|
||||
release_external.set()
|
||||
manager.shutdown()
|
||||
@@ -0,0 +1,72 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
from fenrirscreenreader.commands.commands import export_clipboard_to_x
|
||||
from fenrirscreenreader.commands.commands import import_clipboard_from_x
|
||||
|
||||
|
||||
class FakeTaskManager:
|
||||
def __init__(self):
|
||||
self.callback = None
|
||||
self.function = None
|
||||
self.args = None
|
||||
|
||||
def submit_task(self, function, callback, *args):
|
||||
self.function = function
|
||||
self.callback = callback
|
||||
self.args = args
|
||||
return 7
|
||||
|
||||
def cancel_task(self, _task_id):
|
||||
pass
|
||||
|
||||
|
||||
def test_clipboard_import_changes_history_only_after_result_event():
|
||||
task_manager = FakeTaskManager()
|
||||
memory_manager = Mock()
|
||||
environment = {
|
||||
"runtime": {
|
||||
"BackgroundTaskManager": task_manager,
|
||||
"MemoryManager": memory_manager,
|
||||
"OutputManager": Mock(),
|
||||
}
|
||||
}
|
||||
command = import_clipboard_from_x.command()
|
||||
command.initialize(environment)
|
||||
|
||||
command.run()
|
||||
memory_manager.add_value_to_first_index.assert_not_called()
|
||||
|
||||
task_manager.callback(
|
||||
{"task_id": 7, "succeeded": True, "value": "copied text"}
|
||||
)
|
||||
memory_manager.add_value_to_first_index.assert_called_once_with(
|
||||
"clipboardHistory", "copied text"
|
||||
)
|
||||
|
||||
|
||||
def test_clipboard_export_updates_sync_state_only_after_result_event():
|
||||
task_manager = FakeTaskManager()
|
||||
memory_manager = Mock(
|
||||
is_index_list_empty=Mock(return_value=False),
|
||||
get_index_list_element=Mock(return_value="copied text"),
|
||||
)
|
||||
sync_manager = Mock()
|
||||
environment = {
|
||||
"runtime": {
|
||||
"BackgroundTaskManager": task_manager,
|
||||
"ClipboardSyncManager": sync_manager,
|
||||
"DebugManager": Mock(),
|
||||
"MemoryManager": memory_manager,
|
||||
"OutputManager": Mock(),
|
||||
}
|
||||
}
|
||||
command = export_clipboard_to_x.command()
|
||||
command.initialize(environment)
|
||||
|
||||
command.run()
|
||||
sync_manager.mark_written_to_x.assert_not_called()
|
||||
|
||||
task_manager.callback(
|
||||
{"task_id": 7, "succeeded": True, "value": True}
|
||||
)
|
||||
sync_manager.mark_written_to_x.assert_called_once_with("copied text")
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import time
|
||||
from unittest.mock import Mock, call
|
||||
|
||||
import pytest
|
||||
|
||||
from fenrirscreenreader.core.fenrirManager import FenrirManager
|
||||
|
||||
|
||||
def _build_manager(input_active=True, deepest_input=None):
|
||||
manager = FenrirManager.__new__(FenrirManager)
|
||||
input_manager = Mock(
|
||||
get_last_input_time=Mock(return_value=time.time()),
|
||||
get_last_deepest_input=Mock(return_value=deepest_input or []),
|
||||
)
|
||||
command_manager = Mock(execute_default_trigger=Mock())
|
||||
screen_manager = Mock(handle_screen_update=Mock())
|
||||
cursor_manager = Mock(
|
||||
is_cursor_vertical_move=Mock(return_value=True),
|
||||
is_cursor_horizontal_move=Mock(return_value=False),
|
||||
)
|
||||
manager.environment = {
|
||||
"runtime": {
|
||||
"InputDriver": Mock(is_active=Mock(return_value=input_active)),
|
||||
"InputManager": input_manager,
|
||||
"ScreenManager": screen_manager,
|
||||
"CursorManager": cursor_manager,
|
||||
"CommandManager": command_manager,
|
||||
}
|
||||
}
|
||||
return manager, screen_manager, input_manager, command_manager
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_screen_update_while_x11_target_is_unfocused_updates_state_silently():
|
||||
manager, screen_manager, input_manager, command_manager = _build_manager(
|
||||
input_active=False,
|
||||
deepest_input=["KEY_DOWN"],
|
||||
)
|
||||
event = {"data": {"text": "background repaint"}}
|
||||
|
||||
manager.handle_screen_update(event)
|
||||
|
||||
screen_manager.handle_screen_update.assert_called_once_with(event["data"])
|
||||
command_manager.execute_default_trigger.assert_not_called()
|
||||
input_manager.clear_last_deep_input.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_programmatic_cursor_move_does_not_run_cursor_change_triggers():
|
||||
manager, _screen_manager, _input_manager, command_manager = _build_manager(
|
||||
deepest_input=[]
|
||||
)
|
||||
|
||||
manager.handle_screen_update({"data": {"text": "spinner repaint"}})
|
||||
|
||||
command_manager.execute_default_trigger.assert_called_once_with(
|
||||
"onScreenUpdate"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_navigation_cursor_move_runs_cursor_change_triggers():
|
||||
manager, _screen_manager, _input_manager, command_manager = _build_manager(
|
||||
deepest_input=["KEY_DOWN"]
|
||||
)
|
||||
|
||||
manager.handle_screen_update({"data": {"text": "cursor moved"}})
|
||||
|
||||
assert command_manager.execute_default_trigger.call_args_list == [
|
||||
call("onCursorChange"),
|
||||
call("onScreenUpdate"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("key_name", ["KEY_RIGHT", "KEY_BACKSPACE"])
|
||||
def test_recent_cursor_input_survives_update_before_cursor_moves(key_name):
|
||||
manager, _screen_manager, input_manager, command_manager = _build_manager(
|
||||
deepest_input=[key_name]
|
||||
)
|
||||
manager.environment["runtime"][
|
||||
"CursorManager"
|
||||
].is_cursor_vertical_move.return_value = False
|
||||
manager.environment["runtime"][
|
||||
"CursorManager"
|
||||
].is_cursor_horizontal_move.return_value = False
|
||||
|
||||
manager.handle_screen_update({"data": {"text": "intermediate repaint"}})
|
||||
|
||||
command_manager.execute_default_trigger.assert_called_once_with(
|
||||
"onScreenUpdate"
|
||||
)
|
||||
input_manager.clear_last_deep_input.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_fenrir_review_command_does_not_enable_screen_cursor_speech():
|
||||
manager, _screen_manager, _input_manager, command_manager = _build_manager(
|
||||
deepest_input=["KEY_FENRIR", "KEY_KP8"]
|
||||
)
|
||||
|
||||
manager.handle_screen_update({"data": {"text": "background repaint"}})
|
||||
|
||||
command_manager.execute_default_trigger.assert_called_once_with(
|
||||
"onScreenUpdate"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_heartbeat_triggers_are_silent_while_x11_target_is_unfocused():
|
||||
manager, _screen_manager, _input_manager, command_manager = _build_manager(
|
||||
input_active=False
|
||||
)
|
||||
|
||||
manager.handle_heart_beat({"data": None})
|
||||
|
||||
command_manager.execute_default_trigger.assert_not_called()
|
||||
@@ -0,0 +1,422 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from fenrirscreenreader.core.eventData import FenrirEventType
|
||||
from fenrirscreenreader.core.fenrirManager import FenrirManager
|
||||
from fenrirscreenreader.core.inputManager import InputManager
|
||||
|
||||
|
||||
def create_input_manager():
|
||||
input_driver = Mock(get_led_state=Mock(return_value=False))
|
||||
settings_manager = Mock()
|
||||
settings_manager.get_setting_as_float.return_value = 0.2
|
||||
manager = InputManager()
|
||||
manager.env = {
|
||||
"input": {
|
||||
"curr_input": [],
|
||||
"prev_input": [],
|
||||
"event_buffer": [object()],
|
||||
"shortcut_repeat": 1,
|
||||
"old_num_lock": False,
|
||||
"new_num_lock": False,
|
||||
"old_caps_lock": False,
|
||||
"new_caps_lock": False,
|
||||
"old_scroll_lock": False,
|
||||
"new_scroll_lock": False,
|
||||
},
|
||||
"runtime": {
|
||||
"DebugManager": Mock(),
|
||||
"InputDriver": input_driver,
|
||||
"SettingsManager": settings_manager,
|
||||
},
|
||||
}
|
||||
manager.lastDeepestInput = []
|
||||
manager.lastInputTime = 0
|
||||
manager.handle_led_states = Mock()
|
||||
return manager
|
||||
|
||||
|
||||
def send_input(manager, event_name, event_state):
|
||||
manager.handle_input_event(
|
||||
{"event_name": event_name, "event_state": event_state}
|
||||
)
|
||||
|
||||
|
||||
def create_handle_input_manager(no_key_pressed):
|
||||
manager = FenrirManager.__new__(FenrirManager)
|
||||
manager.modifierInput = False
|
||||
manager.modifier_prefix_input = True
|
||||
manager.singleKeyCommand = False
|
||||
manager.command = ""
|
||||
manager.detect_shortcut_command = Mock()
|
||||
manager.update_key_forward = Mock()
|
||||
|
||||
input_manager = Mock(
|
||||
convert_event_name=Mock(side_effect=lambda key_name: key_name),
|
||||
no_key_pressed=Mock(return_value=no_key_pressed),
|
||||
)
|
||||
manager.environment = {
|
||||
"input": {"key_forward": 0},
|
||||
"runtime": {
|
||||
"DebugManager": Mock(write_debug_out=Mock()),
|
||||
"InputManager": input_manager,
|
||||
"ScreenManager": Mock(is_ignored_screen=Mock(return_value=False)),
|
||||
"HelpManager": Mock(is_tutorial_mode=Mock(return_value=False)),
|
||||
"VmenuManager": Mock(get_active=Mock(return_value=False)),
|
||||
"DiffReviewManager": Mock(is_active=Mock(return_value=False)),
|
||||
"SpeechHistoryManager": Mock(is_active=Mock(return_value=False)),
|
||||
"CommandManager": Mock(execute_default_trigger=Mock()),
|
||||
},
|
||||
}
|
||||
return manager, input_manager
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_input_manager_recognizes_conventional_modifier_prefix():
|
||||
input_manager = InputManager()
|
||||
input_manager.env = {"input": {"curr_input": ["KEY_CTRL", "KEY_SHIFT"]}}
|
||||
|
||||
assert input_manager.curr_input_is_modifier_prefix() is True
|
||||
|
||||
input_manager.env["input"]["curr_input"].append("KEY_S")
|
||||
|
||||
assert input_manager.curr_input_is_modifier_prefix() is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_bare_key_taps_advance_shortcut_repeat(monkeypatch):
|
||||
manager = create_input_manager()
|
||||
monkeypatch.setattr(
|
||||
"fenrirscreenreader.core.inputManager.time.time", lambda: 1.0
|
||||
)
|
||||
|
||||
send_input(manager, "KEY_KP5", 1)
|
||||
assert manager.env["input"]["shortcut_repeat"] == 1
|
||||
send_input(manager, "KEY_KP5", 0)
|
||||
send_input(manager, "KEY_KP5", 1)
|
||||
assert manager.env["input"]["shortcut_repeat"] == 2
|
||||
send_input(manager, "KEY_KP5", 0)
|
||||
send_input(manager, "KEY_KP5", 1)
|
||||
assert manager.env["input"]["shortcut_repeat"] == 3
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tutorial_toggle_runs_while_tutorial_mode_is_active():
|
||||
manager = FenrirManager.__new__(FenrirManager)
|
||||
command_manager = Mock()
|
||||
command_manager.command_exists.return_value = False
|
||||
manager.environment = {
|
||||
"runtime": {
|
||||
"HelpManager": Mock(
|
||||
is_tutorial_mode=Mock(return_value=True)
|
||||
),
|
||||
"CommandManager": command_manager,
|
||||
"ReadAllManager": Mock(is_active=Mock(return_value=False)),
|
||||
"VmenuManager": Mock(get_active=Mock(return_value=False)),
|
||||
"SpeechHistoryManager": Mock(is_active=Mock(return_value=False)),
|
||||
}
|
||||
}
|
||||
|
||||
manager.handle_execute_command({"data": "TOGGLE_TUTORIAL_MODE"})
|
||||
|
||||
command_manager.run_command.assert_called_once_with(
|
||||
"TOGGLE_TUTORIAL_MODE", "commands"
|
||||
)
|
||||
command_manager.execute_command.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tutorial_mode_swallows_input_on_ignored_screen():
|
||||
manager, input_manager = create_handle_input_manager(
|
||||
no_key_pressed=False
|
||||
)
|
||||
manager.environment["runtime"]["HelpManager"].is_tutorial_mode.return_value = (
|
||||
True
|
||||
)
|
||||
manager.environment["runtime"]["ScreenManager"].is_ignored_screen.return_value = (
|
||||
True
|
||||
)
|
||||
|
||||
manager.handle_input(
|
||||
{"data": {"event_name": "KEY_A", "event_state": 1}}
|
||||
)
|
||||
|
||||
input_manager.clear_event_buffer.assert_called()
|
||||
input_manager.write_event_buffer.assert_not_called()
|
||||
input_manager.key_echo.assert_called_once_with(
|
||||
{"event_name": "KEY_A", "event_state": 1}
|
||||
)
|
||||
manager.detect_shortcut_command.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tutorial_mode_promotes_non_fenrir_chord_to_command():
|
||||
manager = FenrirManager.__new__(FenrirManager)
|
||||
manager.modifierInput = False
|
||||
manager.modifier_prefix_input = False
|
||||
manager.singleKeyCommand = False
|
||||
manager.command = ""
|
||||
input_manager = Mock(
|
||||
is_key_press=Mock(return_value=False),
|
||||
curr_input_has_command_modifier=Mock(return_value=False),
|
||||
curr_input_is_modifier_prefix=Mock(return_value=False),
|
||||
no_key_pressed=Mock(return_value=False),
|
||||
get_curr_shortcut=Mock(
|
||||
return_value=str([1, ["KEY_CTRL", "KEY_S"]])
|
||||
),
|
||||
get_command_for_shortcut=Mock(return_value="SPELL_CHECK"),
|
||||
)
|
||||
event_manager = Mock()
|
||||
manager.environment = {
|
||||
"input": {
|
||||
"key_forward": 0,
|
||||
"prev_input": ["KEY_CTRL"],
|
||||
"curr_input": ["KEY_CTRL", "KEY_S"],
|
||||
},
|
||||
"runtime": {
|
||||
"InputManager": input_manager,
|
||||
"EventManager": event_manager,
|
||||
"HelpManager": Mock(
|
||||
is_tutorial_mode=Mock(return_value=True)
|
||||
),
|
||||
"VmenuManager": Mock(get_active=Mock(return_value=False)),
|
||||
"DiffReviewManager": Mock(is_active=Mock(return_value=False)),
|
||||
"SpeechHistoryManager": Mock(is_active=Mock(return_value=False)),
|
||||
},
|
||||
}
|
||||
|
||||
manager.detect_shortcut_command()
|
||||
|
||||
event_manager.put_to_event_queue.assert_called_once_with(
|
||||
FenrirEventType.execute_command, "SPELL_CHECK"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_bare_key_taps_resolve_progressive_shortcuts(monkeypatch):
|
||||
manager = create_input_manager()
|
||||
monkeypatch.setattr(
|
||||
"fenrirscreenreader.core.inputManager.time.time", lambda: 1.0
|
||||
)
|
||||
manager.env["runtime"]["CursorManager"] = Mock(
|
||||
should_process_numpad_commands=Mock(return_value=True)
|
||||
)
|
||||
manager.env["bindings"] = {
|
||||
str([1, ["KEY_KP5"]]): "REVIEW_CURR_WORD",
|
||||
str([2, ["KEY_KP5"]]): "REVIEW_CURR_WORD_SPELL",
|
||||
str([3, ["KEY_KP5"]]): "REVIEW_CURR_WORD_PHONETIC",
|
||||
}
|
||||
|
||||
resolved_commands = []
|
||||
for _tap in range(3):
|
||||
send_input(manager, "KEY_KP5", 1)
|
||||
shortcut = manager.get_curr_shortcut()
|
||||
resolved_commands.append(manager.get_command_for_shortcut(shortcut))
|
||||
send_input(manager, "KEY_KP5", 0)
|
||||
|
||||
assert resolved_commands == [
|
||||
"REVIEW_CURR_WORD",
|
||||
"REVIEW_CURR_WORD_SPELL",
|
||||
"REVIEW_CURR_WORD_PHONETIC",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_different_bare_key_starts_new_repeat_sequence(monkeypatch):
|
||||
manager = create_input_manager()
|
||||
monkeypatch.setattr(
|
||||
"fenrirscreenreader.core.inputManager.time.time", lambda: 1.0
|
||||
)
|
||||
|
||||
send_input(manager, "KEY_KP5", 1)
|
||||
send_input(manager, "KEY_KP5", 0)
|
||||
send_input(manager, "KEY_KP5", 1)
|
||||
assert manager.env["input"]["shortcut_repeat"] == 2
|
||||
send_input(manager, "KEY_KP5", 0)
|
||||
send_input(manager, "KEY_KP2", 1)
|
||||
assert manager.env["input"]["shortcut_repeat"] == 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_bare_key_tap_after_timeout_starts_new_sequence(monkeypatch):
|
||||
manager = create_input_manager()
|
||||
current_time = [1.0]
|
||||
monkeypatch.setattr(
|
||||
"fenrirscreenreader.core.inputManager.time.time",
|
||||
lambda: current_time[0],
|
||||
)
|
||||
|
||||
send_input(manager, "KEY_KP5", 1)
|
||||
send_input(manager, "KEY_KP5", 0)
|
||||
current_time[0] = 2.0
|
||||
send_input(manager, "KEY_KP5", 1)
|
||||
|
||||
assert manager.env["input"]["shortcut_repeat"] == 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_modifier_chord_taps_still_advance_shortcut_repeat(monkeypatch):
|
||||
manager = create_input_manager()
|
||||
monkeypatch.setattr(
|
||||
"fenrirscreenreader.core.inputManager.time.time", lambda: 1.0
|
||||
)
|
||||
|
||||
send_input(manager, "KEY_FENRIR", 1)
|
||||
send_input(manager, "KEY_T", 1)
|
||||
assert manager.env["input"]["shortcut_repeat"] == 1
|
||||
send_input(manager, "KEY_T", 0)
|
||||
send_input(manager, "KEY_T", 1)
|
||||
assert manager.env["input"]["shortcut_repeat"] == 2
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_first_conventional_modifier_starts_deferred_prefix():
|
||||
manager = FenrirManager.__new__(FenrirManager)
|
||||
manager.modifierInput = False
|
||||
manager.modifier_prefix_input = False
|
||||
manager.singleKeyCommand = False
|
||||
manager.command = ""
|
||||
input_manager = Mock(
|
||||
is_key_press=Mock(return_value=True),
|
||||
curr_input_has_command_modifier=Mock(return_value=False),
|
||||
curr_input_is_modifier_prefix=Mock(return_value=True),
|
||||
get_curr_shortcut=Mock(return_value=str([1, ["KEY_CTRL"]])),
|
||||
get_command_for_shortcut=Mock(return_value=""),
|
||||
)
|
||||
manager.environment = {
|
||||
"input": {
|
||||
"key_forward": 0,
|
||||
"prev_input": [],
|
||||
"curr_input": ["KEY_CTRL"],
|
||||
},
|
||||
"runtime": {
|
||||
"InputManager": input_manager,
|
||||
"EventManager": Mock(put_to_event_queue=Mock()),
|
||||
"VmenuManager": Mock(get_active=Mock(return_value=False)),
|
||||
"DiffReviewManager": Mock(is_active=Mock(return_value=False)),
|
||||
"SpeechHistoryManager": Mock(is_active=Mock(return_value=False)),
|
||||
},
|
||||
}
|
||||
|
||||
manager.detect_shortcut_command()
|
||||
|
||||
assert manager.modifier_prefix_input is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_fenrir_shortcut_is_dispatched_when_ctrl_is_pressed_first():
|
||||
manager = FenrirManager.__new__(FenrirManager)
|
||||
manager.modifierInput = False
|
||||
manager.modifier_prefix_input = True
|
||||
manager.singleKeyCommand = False
|
||||
manager.command = ""
|
||||
|
||||
event_manager = Mock(put_to_event_queue=Mock())
|
||||
input_manager = Mock(
|
||||
is_key_press=Mock(return_value=False),
|
||||
no_key_pressed=Mock(return_value=False),
|
||||
curr_input_has_command_modifier=Mock(return_value=True),
|
||||
get_curr_shortcut=Mock(
|
||||
side_effect=[
|
||||
str([1, ["KEY_CTRL", "KEY_FENRIR"]]),
|
||||
str([1, ["KEY_CTRL", "KEY_FENRIR", "KEY_S"]]),
|
||||
]
|
||||
),
|
||||
get_command_for_shortcut=Mock(side_effect=["", "SAVE_SETTINGS"]),
|
||||
)
|
||||
manager.environment = {
|
||||
"input": {
|
||||
"key_forward": 0,
|
||||
"prev_input": ["KEY_CTRL"],
|
||||
"curr_input": ["KEY_CTRL", "KEY_FENRIR"],
|
||||
},
|
||||
"runtime": {
|
||||
"InputManager": input_manager,
|
||||
"EventManager": event_manager,
|
||||
"VmenuManager": Mock(get_active=Mock(return_value=False)),
|
||||
"DiffReviewManager": Mock(is_active=Mock(return_value=False)),
|
||||
"SpeechHistoryManager": Mock(is_active=Mock(return_value=False)),
|
||||
},
|
||||
}
|
||||
|
||||
manager.detect_shortcut_command()
|
||||
|
||||
assert manager.modifierInput is True
|
||||
assert manager.modifier_prefix_input is False
|
||||
event_manager.put_to_event_queue.assert_not_called()
|
||||
|
||||
manager.environment["input"]["prev_input"] = [
|
||||
"KEY_CTRL",
|
||||
"KEY_FENRIR",
|
||||
]
|
||||
manager.environment["input"]["curr_input"] = [
|
||||
"KEY_CTRL",
|
||||
"KEY_FENRIR",
|
||||
"KEY_S",
|
||||
]
|
||||
manager.detect_shortcut_command()
|
||||
|
||||
event_manager.put_to_event_queue.assert_called_once_with(
|
||||
FenrirEventType.execute_command, "SAVE_SETTINGS"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_action_key_disarms_modifier_prefix():
|
||||
manager = FenrirManager.__new__(FenrirManager)
|
||||
manager.modifierInput = False
|
||||
manager.modifier_prefix_input = True
|
||||
manager.singleKeyCommand = False
|
||||
manager.command = ""
|
||||
input_manager = Mock(
|
||||
is_key_press=Mock(return_value=False),
|
||||
no_key_pressed=Mock(return_value=False),
|
||||
curr_input_has_command_modifier=Mock(return_value=False),
|
||||
curr_input_is_modifier_prefix=Mock(return_value=False),
|
||||
get_curr_shortcut=Mock(return_value=str([1, ["KEY_C", "KEY_CTRL"]])),
|
||||
get_command_for_shortcut=Mock(return_value=""),
|
||||
)
|
||||
manager.environment = {
|
||||
"input": {
|
||||
"key_forward": 0,
|
||||
"prev_input": ["KEY_CTRL"],
|
||||
"curr_input": ["KEY_C", "KEY_CTRL"],
|
||||
},
|
||||
"runtime": {
|
||||
"InputManager": input_manager,
|
||||
"EventManager": Mock(put_to_event_queue=Mock()),
|
||||
"VmenuManager": Mock(get_active=Mock(return_value=False)),
|
||||
"DiffReviewManager": Mock(is_active=Mock(return_value=False)),
|
||||
"SpeechHistoryManager": Mock(is_active=Mock(return_value=False)),
|
||||
},
|
||||
}
|
||||
|
||||
manager.detect_shortcut_command()
|
||||
|
||||
assert manager.modifier_prefix_input is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_modifier_prefix_is_not_forwarded_before_chord_is_known():
|
||||
manager, input_manager = create_handle_input_manager(False)
|
||||
|
||||
manager.handle_input(
|
||||
{"data": {"event_name": "KEY_CTRL", "event_state": 1}}
|
||||
)
|
||||
|
||||
input_manager.write_event_buffer.assert_not_called()
|
||||
input_manager.clear_event_buffer.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_modifier_prefix_is_forwarded_when_released_unused():
|
||||
manager, input_manager = create_handle_input_manager(True)
|
||||
|
||||
manager.handle_input(
|
||||
{"data": {"event_name": "KEY_CTRL", "event_state": 0}}
|
||||
)
|
||||
|
||||
input_manager.write_event_buffer.assert_called_once_with()
|
||||
input_manager.clear_last_deep_input.assert_not_called()
|
||||
assert manager.modifier_prefix_input is False
|
||||
@@ -10,6 +10,7 @@ from fenrirscreenreader.core.fenrirManager import FenrirManager
|
||||
def test_speech_history_plain_key_modal_command_is_dispatched():
|
||||
manager = FenrirManager.__new__(FenrirManager)
|
||||
manager.modifierInput = False
|
||||
manager.modifier_prefix_input = False
|
||||
manager.singleKeyCommand = False
|
||||
manager.command = ""
|
||||
|
||||
@@ -50,6 +51,7 @@ def test_speech_history_plain_key_modal_command_is_dispatched():
|
||||
def test_vmenu_plain_key_modal_command_is_dispatched():
|
||||
manager = FenrirManager.__new__(FenrirManager)
|
||||
manager.modifierInput = False
|
||||
manager.modifier_prefix_input = False
|
||||
manager.singleKeyCommand = False
|
||||
manager.command = ""
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -142,6 +142,37 @@ def test_progress_detector_beeps_for_interruptible_status_without_ellipsis():
|
||||
command.play_activity_beep.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_progress_detector_beeps_for_plain_working_elapsed_status():
|
||||
progress_module = _load_progress_module()
|
||||
command = progress_module.command()
|
||||
sample = "Working (21s • esc to interrupt)"
|
||||
command.env = {
|
||||
"commandBuffer": {
|
||||
"progress_monitoring": True,
|
||||
"lastProgressValue": -1,
|
||||
"lastProgressTime": 0,
|
||||
},
|
||||
"runtime": {
|
||||
"DebugManager": Mock(write_debug_out=Mock()),
|
||||
"ScreenManager": Mock(is_screen_change=Mock(return_value=False)),
|
||||
"CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)),
|
||||
},
|
||||
"screen": {
|
||||
"new_delta": sample,
|
||||
"new_delta_is_typing": False,
|
||||
"new_content_text": sample,
|
||||
"old_cursor": {"x": 0, "y": 0},
|
||||
"new_cursor": {"x": 0, "y": 0},
|
||||
},
|
||||
}
|
||||
command.play_activity_beep = Mock()
|
||||
|
||||
command.run()
|
||||
|
||||
command.play_activity_beep.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_progress_detector_allows_long_key_value_status_delta():
|
||||
progress_module = _load_progress_module()
|
||||
|
||||
@@ -39,6 +39,26 @@ def test_private_sgr_sequence_from_fullscreen_apps_does_not_crash():
|
||||
assert screen["text"].splitlines()[0] == "X "
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_overwritten_wide_character_stub_renders_as_space():
|
||||
terminal = Terminal(4, 1, DummyProcessInput())
|
||||
|
||||
terminal.feed("界\rX".encode())
|
||||
screen = terminal.get_screen_content()
|
||||
|
||||
assert screen["text"] == "X "
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_wide_character_and_stub_keep_terminal_width():
|
||||
terminal = Terminal(4, 1, DummyProcessInput())
|
||||
|
||||
terminal.feed("界".encode())
|
||||
screen = terminal.get_screen_content()
|
||||
|
||||
assert screen["text"] == "界 "
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_optional_float_setting_uses_default_when_missing():
|
||||
settings_manager = type(
|
||||
@@ -140,7 +160,22 @@ def test_pty_raw_tab_records_recent_tab_keypress():
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pty_plain_stdin_does_not_record_tab_keypress():
|
||||
@pytest.mark.parametrize(
|
||||
("sequence", "key_name"),
|
||||
[
|
||||
(b"a", "KEY_A"),
|
||||
(b"\x7f", "KEY_BACKSPACE"),
|
||||
(b"\x1b[D", "KEY_LEFT"),
|
||||
(b"\x1bOD", "KEY_LEFT"),
|
||||
(b"\x1b[C", "KEY_RIGHT"),
|
||||
(b"\x1bOC", "KEY_RIGHT"),
|
||||
(b"\x1b[A", "KEY_UP"),
|
||||
(b"\x1bOA", "KEY_UP"),
|
||||
(b"\x1b[B", "KEY_DOWN"),
|
||||
(b"\x1bOB", "KEY_DOWN"),
|
||||
],
|
||||
)
|
||||
def test_pty_forwarded_stdin_records_recent_terminal_input(sequence, key_name):
|
||||
pty_driver = PtyDriver()
|
||||
settings_manager = Mock()
|
||||
settings_manager.get_setting_as_bool.return_value = False
|
||||
@@ -155,10 +190,10 @@ def test_pty_plain_stdin_does_not_record_tab_keypress():
|
||||
}
|
||||
pty_driver.inject_text_to_screen = Mock()
|
||||
|
||||
pty_driver.handle_stdin_input(b"a", Mock())
|
||||
pty_driver.handle_stdin_input(sequence, Mock())
|
||||
|
||||
input_manager.record_unmanaged_keypress.assert_not_called()
|
||||
pty_driver.inject_text_to_screen.assert_called_once_with(b"a")
|
||||
input_manager.record_unmanaged_keypress.assert_called_once_with(key_name)
|
||||
pty_driver.inject_text_to_screen.assert_called_once_with(sequence)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
|
||||
@@ -8,26 +8,19 @@ from fenrirscreenreader.commands.commands import subprocess as subprocess_comman
|
||||
@pytest.mark.unit
|
||||
def test_script_command_executes_without_shell(monkeypatch):
|
||||
process = Mock()
|
||||
process.communicate.return_value = (b"done", b"")
|
||||
process.returncode = 0
|
||||
process.communicate.return_value = ("done", "")
|
||||
popen = Mock(return_value=process)
|
||||
monkeypatch.setattr(subprocess_command, "Popen", popen)
|
||||
output_manager = Mock()
|
||||
command = subprocess_command.command()
|
||||
command.initialize(
|
||||
{
|
||||
"general": {"curr_user": "Username"},
|
||||
"runtime": {"OutputManager": output_manager},
|
||||
},
|
||||
"/tmp/script with spaces",
|
||||
)
|
||||
monkeypatch.setattr(subprocess_command.subprocess, "Popen", popen)
|
||||
|
||||
command._thread_run()
|
||||
result = subprocess_command.run_script(
|
||||
"/tmp/script with spaces", "Username"
|
||||
)
|
||||
|
||||
popen.assert_called_once_with(
|
||||
["/tmp/script with spaces", "Username"],
|
||||
stdout=subprocess_command.PIPE,
|
||||
stderr=subprocess_command.PIPE,
|
||||
)
|
||||
output_manager.present_text.assert_called_once_with(
|
||||
"done", sound_icon="", interrupt=False
|
||||
stdout=subprocess_command.subprocess.PIPE,
|
||||
stderr=subprocess_command.subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
assert result == {"return_code": 0, "stdout": "done", "stderr": ""}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import importlib
|
||||
from unittest.mock import Mock
|
||||
|
||||
|
||||
subprocess_command = importlib.import_module(
|
||||
"fenrirscreenreader.commands.commands.subprocess"
|
||||
)
|
||||
|
||||
|
||||
def test_external_script_output_is_delivered_by_main_loop_callback(tmp_path):
|
||||
script_path = tmp_path / "helper.sh"
|
||||
script_path.write_text("#!/bin/sh\necho ready\n", encoding="utf-8")
|
||||
script_path.chmod(0o755)
|
||||
task_manager = Mock()
|
||||
task_manager.submit_external_task.return_value = 7
|
||||
output_manager = Mock()
|
||||
environment = {
|
||||
"runtime": {
|
||||
"BackgroundTaskManager": task_manager,
|
||||
"OutputManager": output_manager,
|
||||
},
|
||||
"general": {"curr_user": "Username"},
|
||||
}
|
||||
script_command = subprocess_command.command()
|
||||
script_command.initialize(environment, str(script_path))
|
||||
|
||||
script_command.run()
|
||||
|
||||
output_manager.present_text.assert_not_called()
|
||||
function, callback, path, current_user = (
|
||||
task_manager.submit_external_task.call_args.args
|
||||
)
|
||||
assert function is subprocess_command.run_script
|
||||
assert path == str(script_path)
|
||||
assert current_user == "Username"
|
||||
|
||||
callback(
|
||||
{
|
||||
"task_id": 7,
|
||||
"succeeded": True,
|
||||
"value": {"return_code": 0, "stdout": "ready\n", "stderr": ""},
|
||||
"error": "",
|
||||
}
|
||||
)
|
||||
output_manager.present_text.assert_called_once_with(
|
||||
"ready\n", sound_icon="", interrupt=False
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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,152 @@ 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()
|
||||
output_manager = Mock()
|
||||
x11.env = {"runtime": {"OutputManager": output_manager}}
|
||||
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()
|
||||
output_manager.interrupt_output_async.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_x11_reports_target_focus_state():
|
||||
x11 = X11Driver()
|
||||
x11.active = False
|
||||
|
||||
assert x11.is_active() is False
|
||||
|
||||
x11.active = True
|
||||
|
||||
assert x11.is_active() is True
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@@ -37,6 +37,25 @@ def test_write_text_uses_display_env_without_mutating_process_env(
|
||||
assert os.environ.get("DISPLAY") is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_write_text_does_not_capture_daemonized_writer_pipes(monkeypatch):
|
||||
monkeypatch.setattr(x_clipboard.shutil, "which", command_exists)
|
||||
|
||||
def run_command(command, **kwargs):
|
||||
if (
|
||||
kwargs["stdout"] == subprocess.PIPE
|
||||
or kwargs["stderr"] == subprocess.PIPE
|
||||
):
|
||||
raise subprocess.TimeoutExpired(command, 2.0)
|
||||
return subprocess.CompletedProcess(
|
||||
command, 0, stdout=None, stderr=None
|
||||
)
|
||||
|
||||
monkeypatch.setattr(x_clipboard.subprocess, "run", run_command)
|
||||
|
||||
assert x_clipboard.write_text("clipboard text", ":3") is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_read_text_scans_displays_until_text_is_found(monkeypatch):
|
||||
monkeypatch.delenv("DISPLAY", raising=False)
|
||||
|
||||
+60
-31
@@ -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
|
||||