4 Commits

29 changed files with 1165 additions and 196 deletions
+28 -19
View File
@@ -19,14 +19,14 @@ volume=0.7
# shell commands for generic sound driver
# the folowing variable are substituted
# fenrirVolume = the current volume setting
# fenrirSoundFile = the soundfile for an soundicon
# fenrirFrequence = the frequency to play
# fenrirDuration = the duration of the frequency
# fenrir_volume = the current volume setting
# fenrir_sound_file = the soundfile for an soundicon
# fenrir_frequency = the frequency to play
# fenrir_duration = the duration of the frequency
# the following command is used to play a soundfile
generic_play_file_command=play -q -v fenrirVolume fenrirSoundFile
generic_play_file_command=play -q -v fenrir_volume fenrir_sound_file
#the following command is used to generate a frequency beep
generic_frequency_command=play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence
generic_frequency_command=play -q -v fenrir_volume -n -c1 synth fenrir_duration sine fenrir_frequency
# Enable progress bar monitoring with ascending tones by default
progress_monitoring=True
@@ -114,17 +114,17 @@ max_batch_lines=100
# Only enable flood control if this many new lines appear in the window
flood_line_threshold=500
# genericSpeechCommand is the command that is executed for talking
# generic_speech_command is the command that is executed for talking
# the following variables are replaced with values
# fenrirText = is the text that should be spoken
# fenrirModule = may be the speech module like used in speech-dispatcher, not every TTY need this
# fenrirLanguage = the language
# fenrirVoice = is the current voice that should be used. Set the voice variable above.
# fenrir_text = is the text that should be spoken
# fenrir_module = may be the speech module like used in speech-dispatcher, not every TTY need this
# fenrir_language = the language
# fenrir_voice = is the current voice that should be used. Set the voice variable above.
# the current volume, pitch and rate is calculated like this
# value = min + settingValue * (min - max )
# fenrirVolume = is replaced with the current volume
# fenrirPitch = is replaced with the current pitch
# fenrirRate = is replaced with the current speed (speech rate)
# fenrir_volume = is replaced with the current volume
# fenrir_pitch = is replaced with the current pitch
# fenrir_rate = is replaced with the current speed (speech rate)
generic_speech_command=espeak-ng -a fenrir_volume -s fenrir_rate -p fenrir_pitch -v fenrir_voice -- "fenrir_text"
# min and max values of the TTS system that is used in generic_speech_command
@@ -198,11 +198,6 @@ respect_punctuation_pause=True
replace_undefined_punctuation_with_space=True
# Pause speech briefly at newline characters for better readability
new_line_pause=True
number_of_clipboards=50
# used path for "export_clipboard_to_file"
# $user is replaced by username
#clipboardExportPath=/home/$user/fenrirClipboard
clipboard_export_path=/tmp/fenrirClipboard
# Convert text emoticons like :) to descriptive text (e.g., "smiling face")
emoticons=True
# Define the Fenrir modifier key(s) - used to trigger Fenrir commands
@@ -263,6 +258,20 @@ diff_presentation=both
# verbose = include diff line content during navigation
diff_verbosity=compact
[clipboard]
# Number of clipboard history entries Fenrir keeps.
number_of_clipboards=50
# used path for "export_clipboard_to_file"
# $user is replaced by username
#clipboard_export_path=/home/$user/fenrirClipboard
clipboard_export_path=/tmp/fenrirClipboard
# Keep Fenrir's clipboard history and the X clipboard synchronized.
# In fenrir -x, an empty sync_display uses the current DISPLAY.
# In console/TTY mode, set sync_display explicitly, for example :0.
sync_enabled=False
sync_display=
sync_interval=0.5
[focus]
# Follow and announce text cursor position changes
cursor=True
+15 -13
View File
@@ -1506,38 +1506,38 @@ Values: `+0.0+` is quietest, `+1.0+` is loudest.
The generic sound driver uses shell commands for play sound and
frequencies.
`+genericPlayFileCommand+` defines the command that is used to play a
`+generic_play_file_command+` defines the command that is used to play a
sound file.
....
generic_play_file_command=<your command for playing a file>
....
`+genericFrequencyCommand+` defines the command that is used playing
`+generic_frequency_command+` defines the command that is used playing
frequencies.
....
generic_frequency_command=<your command for playing a frequence>
....
The following variables are substituted in `+genericPlayFileCommand+`
and `+genericFrequencyCommand+`:
The following variables are substituted in `+generic_play_file_command+`
and `+generic_frequency_command+`:
* `+fenrirVolume+` = the current volume setting
* `+fenrirSoundFile+` = the sound file for an sound icon
* `+fenrirFrequence+` = the frequency to play
* `+fenrirDuration+` = the duration of the frequency
* `+fenrir_volume+` = the current volume setting
* `+fenrir_sound_file+` = the sound file for an sound icon
* `+fenrir_frequency+` = the frequency to play
* `+fenrir_duration+` = the duration of the frequency
Example genericPlayFileCommand (default)
Example generic_play_file_command (default)
....
generic_play_file_command=play -q -v fenrirVolume fenrirSoundFile
generic_play_file_command=play -q -v fenrir_volume fenrir_sound_file
....
Example genericFrequencyCommand (default)
Example generic_frequency_command (default)
....
generic_frequency_command=play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence
generic_frequency_command=play -q -v fenrir_volume -n -c1 synth fenrir_duration sine fenrir_frequency
....
==== Speech
@@ -1921,7 +1921,8 @@ link:#export clipboard to file[export clipboard to file]. The variable
`+$user+` is replaced by the current logged username.
....
clipboardExportPath=/tmp/fenrirClipboard
[clipboard]
clipboard_export_path=/tmp/fenrirClipboard
....
Values: Text, Systemfilepath
@@ -1929,6 +1930,7 @@ Values: Text, Systemfilepath
The number of available clipboards:
....
[clipboard]
number_of_clipboards=10
....
+27 -25
View File
@@ -857,21 +857,21 @@ Values: ''0.0'' is quietest, ''1.0'' is loudest.
=== Generic Driver ===
The generic sound driver uses shell commands for play sound and frequencies.
''genericPlayFileCommand'' defines the command that is used to play a sound file.
''generic_play_file_command'' defines the command that is used to play a sound file.
generic_play_file_command=<your command for playing a file>
''genericFrequencyCommand'' defines the command that is used playing frequencies.
''generic_frequency_command'' defines the command that is used playing frequencies.
generic_frequency_command=<your command for playing a frequence>
The following variables are substituted in ''genericPlayFileCommand'' and ''genericFrequencyCommand'':
* ''fenrirVolume'' = the current volume setting
* ''fenrirSoundFile'' = the sound file for an sound icon
* ''fenrirFrequence'' = the frequency to play
* ''fenrirDuration'' = the duration of the frequency
The following variables are substituted in ''generic_play_file_command'' and ''generic_frequency_command'':
* ''fenrir_volume'' = the current volume setting
* ''fenrir_sound_file'' = the sound file for an sound icon
* ''fenrir_frequency'' = the frequency to play
* ''fenrir_duration'' = the duration of the frequency
Example genericPlayFileCommand (default)
generic_play_file_command=play -q -v fenrirVolume fenrirSoundFile
Example genericFrequencyCommand (default)
generic_frequency_command=play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence
Example generic_play_file_command (default)
generic_play_file_command=play -q -v fenrir_volume fenrir_sound_file
Example generic_frequency_command (default)
generic_frequency_command=play -q -v fenrir_volume -n -c1 synth fenrir_duration sine fenrir_frequency
==== Speech ====
Speech is configured in section ''[speech]''.
Turn speech on or off:
@@ -945,22 +945,22 @@ Values: on=''True'', off=''False''
=== Generic Driver ===
The generic speech driver uses shell commands for speech synthisus.
''genericSpeechCommand'' defines the command that is executed for creating speech
The following variables are substituted in ''genericSpeechCommand'':
* ''FenrirText'' = is the text that should be spoken
* ''fenrirModule'' = may be the speech module like used in speech-dispatcher, not every TTY needs this
* ''fenrirLanguage'' = the language to speak in
* ''fenrirVoice'' = is the current voice that should be used
* ''fenrirVolume'' = is replaced with the current volume
* ''fenrirPitch'' = is replaced with the current pitch
* ''fenrirRate'' = is replaced with the current speed (speech rate)
''generic_speech_command'' defines the command that is executed for creating speech
The following variables are substituted in ''generic_speech_command'':
* ''fenrir_text'' = is the text that should be spoken
* ''fenrir_module'' = may be the speech module like used in speech-dispatcher, not every TTY needs this
* ''fenrir_language'' = the language to speak in
* ''fenrir_voice'' = is the current voice that should be used
* ''fenrir_volume'' = is replaced with the current volume
* ''fenrir_pitch'' = is replaced with the current pitch
* ''fenrir_rate'' = is replaced with the current speed (speech rate)
Example genericSpeechCommand (default):
generic_speech_command=espeak -a fenrirVolume -s fenrirRate -p fenrirPitch -v fenrirVoice "fenrirText"
Example generic_speech_command (default):
generic_speech_command=espeak -a fenrir_volume -s fenrir_rate -p fenrir_pitch -v fenrir_voice "fenrir_text"
These are the minimum and maximum values of the TTS system used in genericSpeechCommand. They are needed to calculate the abstract range in volume, rate and pitch 0.0 - 1.0.
These are the minimum and maximum values of the TTS system used in generic_speech_command. They are needed to calculate the abstract range in volume, rate and pitch 0.0 - 1.0.
FenrirMinVolume=0
fenrir_min_volume=0
fenrir_max_volume=200
fenrir_min_pitch=0
fenrir_max_pitch=99
@@ -1133,10 +1133,12 @@ Values: on=''True'', off=''False''
Specify the path where the clipboard should be exported to.
See [[#export clipboard to file|export clipboard to file]].
The variable ''$user'' is replaced by the current logged username.
clipboardExportPath=/tmp/fenrirClipboard
[clipboard]
clipboard_export_path=/tmp/fenrirClipboard
Values: Text, Systemfilepath
The number of available clipboards:
[clipboard]
number_of_clipboards=10
Values: Integer, 1 - 999
@@ -22,7 +22,7 @@ class command:
self.env["runtime"]["MemoryManager"].add_index_list(
"clipboardHistory",
self.env["runtime"]["SettingsManager"].get_setting_as_int(
"general", "number_of_clipboards"
"clipboard", "number_of_clipboards"
),
)
@@ -26,7 +26,7 @@ class command:
def run(self):
clipboard_file_path = self.env["runtime"][
"SettingsManager"
].get_setting("general", "clipboard_export_path")
].get_setting("clipboard", "clipboard_export_path")
clipboard_file_path = clipboard_file_path.replace(
"$user", self.env["general"]["curr_user"]
)
@@ -5,12 +5,9 @@
# By Chrys, Storm Dragon, and contributors.
import _thread
import importlib
import os
import pyperclip
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils import x_clipboard
class command:
@@ -46,36 +43,20 @@ class command:
"MemoryManager"
].get_index_list_element("clipboardHistory")
# Remember original display environment variable if it exists
original_display = os.environ.get("DISPLAY", "")
success = False
# Try different display options
for i in range(10):
display = f":{i}"
try:
# Set display environment variable
os.environ["DISPLAY"] = display
# Attempt to set clipboard content
# Weird workaround for some distros
importlib.reload(pyperclip)
pyperclip.copy(clipboard)
# If we get here without exception, we found a working
# display
success = True
break
except Exception:
# Failed for this display, try next one
continue
# Restore original display setting
if original_display:
os.environ["DISPLAY"] = original_display
else:
os.environ.pop("DISPLAY", None)
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
)
@@ -26,7 +26,7 @@ class command:
def run(self):
clipboard_file_path = self.env["runtime"][
"SettingsManager"
].get_setting("general", "clipboard_export_path")
].get_setting("clipboard", "clipboard_export_path")
clipboard_file_path = clipboard_file_path.replace(
"$user", self.env["general"]["curr_user"]
)
@@ -5,12 +5,9 @@
# By Chrys, Storm Dragon, and contributors.
import _thread
import importlib
import os
import pyperclip
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils import x_clipboard
class command:
@@ -32,33 +29,12 @@ class command:
def _thread_run(self):
try:
# Remember original display environment variable if it exists
original_display = os.environ.get("DISPLAY", "")
clipboard_content = None
# Try different display options
for i in range(10):
display = f":{i}"
try:
# Set display environment variable
os.environ["DISPLAY"] = display
# Attempt to get clipboard content
# Weird workaround for some distros
importlib.reload(pyperclip)
clipboard_content = pyperclip.paste()
# If we get here without exception, we found a working
# display
if clipboard_content:
break
except Exception:
# Failed for this display, try next one
continue
# Restore original display setting
if original_display:
os.environ["DISPLAY"] = original_display
else:
os.environ.pop("DISPLAY", None)
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):
@@ -18,7 +18,7 @@ class command:
self.env["runtime"]["MemoryManager"].add_index_list(
"clipboardHistory",
self.env["runtime"]["SettingsManager"].get_setting_as_int(
"general", "number_of_clipboards"
"clipboard", "number_of_clipboards"
),
)
@@ -141,7 +141,12 @@ class command(config_command):
self.config["general"] = {
"punctuation_level": "some",
"debug_level": "0",
}
# Basic clipboard defaults
self.config["clipboard"] = {
"number_of_clipboards": "50",
"clipboard_export_path": "/tmp/fenrirClipboard",
}
# Write the configuration
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import os
import threading
import time
from fenrirscreenreader.core import debug
from fenrirscreenreader.utils import x_clipboard
class ClipboardSyncManager:
def __init__(self):
self.env = None
self.enabled = False
self.display = ""
self.interval = 0.5
self.running = False
self.thread = None
self.last_written_to_x = None
self.last_imported_from_x = None
self.last_observed_fenrir = None
self.last_observed_x = None
def initialize(self, environment):
self.env = environment
self.enabled, self.display = self._resolve_startup()
self.interval = max(
0.1,
self.env["runtime"]["SettingsManager"].get_setting_as_float(
"clipboard", "sync_interval"
),
)
if self.enabled:
self._debug(
"Clipboard sync enabled for display "
+ self.display
+ " at interval "
+ str(self.interval)
)
self.start()
else:
self._debug("Clipboard sync disabled at startup")
def shutdown(self):
self.stop()
def mark_written_to_x(self, text):
if not isinstance(text, str) or not text:
return
self.last_written_to_x = text
self.last_observed_fenrir = text
self.last_observed_x = text
def _resolve_startup(self):
settings_manager = self.env["runtime"]["SettingsManager"]
if not settings_manager.get_setting_as_bool(
"clipboard", "sync_enabled"
):
return False, ""
configured_display = settings_manager.get_setting(
"clipboard", "sync_display"
)
screen_driver = settings_manager.get_setting("screen", "driver")
keyboard_driver = settings_manager.get_setting("keyboard", "driver")
is_x_mode = (
screen_driver == "ptyDriver" and keyboard_driver == "x11Driver"
)
if configured_display:
return True, configured_display
if is_x_mode:
current_display = os.environ.get("DISPLAY", "")
return bool(current_display), current_display
return False, ""
def start(self):
if self.running:
return
self.running = True
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
def stop(self):
self.running = False
if self.thread:
self.thread.join(timeout=1.0)
self.thread = None
def _run(self):
while self.running:
self.poll_once()
time.sleep(self.interval)
def poll_once(self):
fenrir_text = self._get_fenrir_clipboard_text()
x_text = self._get_x_clipboard_text()
if fenrir_text and x_text and fenrir_text == x_text:
self.last_observed_fenrir = fenrir_text
self.last_observed_x = x_text
return
fenrir_changed = (
fenrir_text
and fenrir_text != self.last_observed_fenrir
)
x_changed = (
x_text
and x_text != self.last_observed_x
)
if fenrir_changed:
if self._write_x_clipboard_text(fenrir_text):
self.last_written_to_x = fenrir_text
self.last_observed_x = fenrir_text
self.last_observed_fenrir = fenrir_text
return
if x_changed:
self.env["runtime"]["MemoryManager"].add_value_to_first_index(
"clipboardHistory", x_text
)
self.last_imported_from_x = x_text
self.last_observed_fenrir = x_text
self.last_observed_x = x_text
return
if fenrir_text:
self.last_observed_fenrir = fenrir_text
if x_text:
self.last_observed_x = x_text
def _get_fenrir_clipboard_text(self):
memory_manager = self.env["runtime"]["MemoryManager"]
if memory_manager.is_index_list_empty("clipboardHistory"):
return None
text = memory_manager.get_index_list_element("clipboardHistory")
if isinstance(text, str) and text:
return text
return None
def _get_x_clipboard_text(self):
try:
text = x_clipboard.read_text(self.display)
if isinstance(text, str) and text:
return text
return None
except Exception as error:
self.env["runtime"]["DebugManager"].write_debug_out(
"ClipboardSyncManager paste failed: " + str(error),
debug.DebugLevel.INFO,
)
return None
def _write_x_clipboard_text(self, text):
try:
return x_clipboard.write_text(text, self.display)
except Exception as error:
self._debug("ClipboardSyncManager copy failed: " + str(error))
return False
def _debug(self, message):
self.env["runtime"]["DebugManager"].write_debug_out(
message,
debug.DebugLevel.INFO,
)
@@ -22,4 +22,7 @@ command_info = {
# 'curr_command': '',
"lastCommandExecutionTime": time.time(),
"lastCommandRequestTime": time.time(),
"lastCommand": "",
"lastCommandSection": "",
"lastCommandRunTime": 0,
}
@@ -474,6 +474,10 @@ class CommandManager:
def run_command(self, command, section="commands"):
if self.command_exists(command, section):
try:
command_time = time.time()
self.env["commandInfo"]["lastCommand"] = command
self.env["commandInfo"]["lastCommandSection"] = section
self.env["commandInfo"]["lastCommandRunTime"] = command_time
self.env["runtime"]["DebugManager"].write_debug_out(
"run_command command:" + section + "." + command,
debug.DebugLevel.INFO,
@@ -17,6 +17,7 @@ general_data = {
"CursorManager",
"ApplicationManager",
"CommandManager",
"ClipboardSyncManager",
"ScreenManager",
"InputManager",
"OutputManager",
+1 -1
View File
@@ -422,7 +422,7 @@ class RemoteManager:
def export_clipboard(self):
clipboard_file_path = self.env["runtime"][
"SettingsManager"
].get_setting("general", "clipboard_export_path")
].get_setting("clipboard", "clipboard_export_path")
clipboard_file_path = clipboard_file_path.replace(
"$user", self.env["general"]["curr_user"]
)
@@ -14,6 +14,7 @@ runtime_data = {
"RemoteDriver": None,
"InputManager": None,
"CommandManager": None,
"ClipboardSyncManager": None,
"ScreenManager": None,
"OutputManager": None,
"SpeechHistoryManager": None,
+18 -5
View File
@@ -12,8 +12,13 @@ settings_data = {
"driver": "genericDriver",
"theme": "default",
"volume": 1.0,
"generic_play_file_command": "play -q -v fenrirVolume fenrirSoundFile",
"generic_frequency_command": "play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence",
"generic_play_file_command": (
"play -q -v fenrir_volume fenrir_sound_file"
),
"generic_frequency_command": (
"play -q -v fenrir_volume -n -c1 synth "
"fenrir_duration sine fenrir_frequency"
),
"progress_monitoring": True,
},
"speech": {
@@ -38,7 +43,10 @@ settings_data = {
"batch_flush_interval": 0.5,
"max_batch_lines": 100,
"flood_line_threshold": 500,
"generic_speech_command": 'espeak -a fenrirVolume -s fenrirRate -p fenrirPitch -v fenrirVoice "fenrirText"',
"generic_speech_command": (
"espeak -a fenrir_volume -s fenrir_rate -p fenrir_pitch "
'-v fenrir_voice "fenrir_text"'
),
"fenrir_min_volume": 0,
"fenrir_max_volume": 200,
"fenrir_min_pitch": 0,
@@ -62,8 +70,6 @@ settings_data = {
"respect_punctuation_pause": True,
"replace_undefined_punctuation_with_space": True,
"new_line_pause": True,
"number_of_clipboards": 10,
"clipboard_export_path": "/tmp/fenrirClipboard",
"emoticons": True,
"fenrir_keys": "KEY_KP0,KEY_META",
"script_keys": "KEY_COMPOSE",
@@ -81,6 +87,13 @@ settings_data = {
"diff_presentation": "both",
"diff_verbosity": "compact",
},
"clipboard": {
"number_of_clipboards": 10,
"clipboard_export_path": "/tmp/fenrirClipboard",
"sync_enabled": False,
"sync_display": "",
"sync_interval": 0.5,
},
"focus": {
"cursor": True,
"highlight": False,
@@ -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 clipboardSyncManager
from fenrirscreenreader.core import commandManager
from fenrirscreenreader.core import cursorManager
from fenrirscreenreader.core import debug
@@ -771,6 +772,13 @@ class SettingsManager:
] = commandManager.CommandManager()
environment["runtime"]["CommandManager"].initialize(environment)
environment["runtime"][
"ClipboardSyncManager"
] = clipboardSyncManager.ClipboardSyncManager()
environment["runtime"]["ClipboardSyncManager"].initialize(
environment
)
environment["runtime"]["HelpManager"] = helpManager.HelpManager()
environment["runtime"]["HelpManager"].initialize(environment)
+2 -2
View File
@@ -4,5 +4,5 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
version = "2026.06.01"
code_name = "master"
version = "2026.06.27"
code_name = "testing"
@@ -304,6 +304,11 @@ class driver(screenDriver):
"keyboard", "interrupt_on_key_press_filter"
).strip():
return
self.env["runtime"]["DebugManager"].write_debug_out(
"ptyDriver interrupt_output_on_stdin_input: "
+ repr(msg_bytes),
debug.DebugLevel.INFO,
)
self.start_stdin_interrupt_thread()
def start_stdin_interrupt_thread(self):
@@ -335,10 +340,43 @@ class driver(screenDriver):
return
if self.handle_vmenu_stdin_input(msg_bytes, event_queue):
return
if self.suppress_recent_review_stdin_tail(msg_bytes):
return
self.record_stdin_keypress(msg_bytes)
self.interrupt_output_on_stdin_input(msg_bytes)
self.inject_text_to_screen(msg_bytes)
def suppress_recent_review_stdin_tail(self, msg_bytes):
if not self.is_keyboard_control_sequence(msg_bytes):
return False
try:
command_info = self.env["commandInfo"]
last_command = command_info.get("lastCommand", "")
last_section = command_info.get("lastCommandSection", "")
last_run_time = command_info.get("lastCommandRunTime", 0)
except Exception:
return False
if last_section != "commands" or not last_command.startswith("REVIEW_"):
return False
if time.time() - last_run_time > 0.8:
return False
self.env["runtime"]["DebugManager"].write_debug_out(
"ptyDriver suppressing recent review stdin tail: "
+ repr(msg_bytes),
debug.DebugLevel.INFO,
)
return True
def is_keyboard_control_sequence(self, msg_bytes):
if not msg_bytes:
return False
if msg_bytes.startswith(b"\x1b"):
return True
if len(msg_bytes) == 1:
value = msg_bytes[0]
return value < 32 and msg_bytes not in [b"\t", b"\n", b"\r"]
return False
def handle_vmenu_stdin_input(self, msg_bytes, event_queue):
if not self.is_vmenu_active():
return False
@@ -26,15 +26,15 @@ class driver(sound_driver):
Attributes:
proc: Currently running subprocess for sound playback
soundFileCommand (str): Command template for playing sound files
frequenceCommand (str): Command template for generating frequencies
sound_file_command (str): Command template for playing sound files
frequency_command (str): Command template for generating frequencies
"""
def __init__(self):
sound_driver.__init__(self)
self.proc = None
self.soundType = ""
self.soundFileCommand = ""
self.frequenceCommand = ""
self.sound_type = ""
self.sound_file_command = ""
self.frequency_command = ""
def initialize(self, environment):
"""Initialize the generic sound driver.
@@ -46,17 +46,20 @@ class driver(sound_driver):
environment: Fenrir environment dictionary with settings
"""
self.env = environment
self.soundFileCommand = self.env["runtime"][
self.sound_file_command = self.env["runtime"][
"SettingsManager"
].get_setting("sound", "generic_play_file_command")
self.frequenceCommand = self.env["runtime"][
self.frequency_command = self.env["runtime"][
"SettingsManager"
].get_setting("sound", "generic_frequency_command")
if self.soundFileCommand == "":
self.soundFileCommand = "play -q -v fenrirVolume fenrirSoundFile"
if self.frequenceCommand == "":
self.frequenceCommand = (
"play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence"
if self.sound_file_command == "":
self.sound_file_command = (
"play -q -v fenrir_volume fenrir_sound_file"
)
if self.frequency_command == "":
self.frequency_command = (
"play -q -v fenrir_volume -n -c1 synth "
"fenrir_duration sine fenrir_frequency"
)
self._initialized = True
@@ -75,22 +78,22 @@ class driver(sound_driver):
return
if interrupt:
self.cancel()
popen_frequence_command = shlex.split(self.frequenceCommand)
for idx, word in enumerate(popen_frequence_command):
popen_frequency_command = shlex.split(self.frequency_command)
for idx, word in enumerate(popen_frequency_command):
word = word.replace(
"fenrirVolume", str(self.volume * adjust_volume)
"fenrir_volume", str(self.volume * adjust_volume)
)
word = word.replace("fenrirDuration", str(duration))
word = word.replace("fenrirFrequence", str(frequence))
popen_frequence_command[idx] = word
word = word.replace("fenrir_duration", str(duration))
word = word.replace("fenrir_frequency", str(frequence))
popen_frequency_command[idx] = word
self.proc = subprocess.Popen(
popen_frequence_command,
popen_frequency_command,
stdin=None,
stdout=None,
stderr=None,
shell=False,
)
self.soundType = "frequence"
self.sound_type = "frequence"
def play_sound_file(self, file_path, interrupt=True):
"""Play a sound file.
@@ -108,13 +111,13 @@ class driver(sound_driver):
if not os.path.isfile(file_path) or ".." in file_path:
return
popen_sound_file_command = shlex.split(self.soundFileCommand)
popen_sound_file_command = shlex.split(self.sound_file_command)
for idx, word in enumerate(popen_sound_file_command):
word = word.replace("fenrirVolume", str(self.volume))
word = word.replace("fenrirSoundFile", shlex.quote(str(file_path)))
word = word.replace("fenrir_volume", str(self.volume))
word = word.replace("fenrir_sound_file", str(file_path))
popen_sound_file_command[idx] = word
self.proc = subprocess.Popen(popen_sound_file_command, shell=False)
self.soundType = "file"
self.sound_type = "file"
def cancel(self):
"""Cancel currently playing sound.
@@ -123,9 +126,9 @@ class driver(sound_driver):
"""
if not self._initialized:
return
if self.soundType == "":
if self.sound_type == "":
return
if self.soundType == "file":
if self.sound_type == "file":
self.proc.kill()
try:
# Wait for process to finish to prevent zombies
@@ -134,7 +137,7 @@ class driver(sound_driver):
pass # Process already terminated
except Exception as e:
pass # Handle any other wait errors
if self.soundType == "frequence":
if self.sound_type == "frequence":
self.proc.kill()
try:
# Wait for process to finish to prevent zombies
@@ -143,4 +146,4 @@ class driver(sound_driver):
pass # Process already terminated
except Exception as e:
pass # Handle any other wait errors
self.soundType = ""
self.sound_type = ""
@@ -30,49 +30,52 @@ class driver(speech_driver):
def __init__(self):
speech_driver.__init__(self)
self.proc = None
self.speechThread = Thread(target=self.worker)
self.speech_thread = Thread(target=self.worker)
self.lock = Lock()
self.textQueue = SpeakQueue()
self.text_queue = SpeakQueue()
def initialize(self, environment):
self.env = environment
self.minVolume = self.env["runtime"][
self.min_volume = self.env["runtime"][
"SettingsManager"
].get_setting_as_int("speech", "fenrir_min_volume")
self.maxVolume = self.env["runtime"][
self.max_volume = self.env["runtime"][
"SettingsManager"
].get_setting_as_int("speech", "fenrir_max_volume")
self.minPitch = self.env["runtime"][
self.min_pitch = self.env["runtime"][
"SettingsManager"
].get_setting_as_int("speech", "fenrir_min_pitch")
self.maxPitch = self.env["runtime"][
self.max_pitch = self.env["runtime"][
"SettingsManager"
].get_setting_as_int("speech", "fenrir_max_pitch")
self.minRate = self.env["runtime"][
self.min_rate = self.env["runtime"][
"SettingsManager"
].get_setting_as_int("speech", "fenrir_min_rate")
self.maxRate = self.env["runtime"][
self.max_rate = self.env["runtime"][
"SettingsManager"
].get_setting_as_int("speech", "fenrir_max_rate")
self.speechCommand = self.env["runtime"][
self.speech_command = self.env["runtime"][
"SettingsManager"
].get_setting("speech", "generic_speech_command")
if self.speechCommand == "":
self.speechCommand = 'espeak -a fenrirVolume -s fenrirRate -p fenrirPitch -v fenrirVoice -- "fenrirText"'
if self.speech_command == "":
self.speech_command = (
"espeak -a fenrir_volume -s fenrir_rate -p fenrir_pitch "
'-v fenrir_voice -- "fenrir_text"'
)
if False: # for debugging overwrite here
# self.speechCommand = 'spd-say --wait -r 100 -i 100 "fenrirText"'
self.speechCommand = 'flite -t "fenrirText"'
# self.speech_command = 'spd-say --wait -r 100 -i 100 "fenrir_text"'
self.speech_command = 'flite -t "fenrir_text"'
self._is_initialized = True
if self._is_initialized:
self.speechThread.start()
self.speech_thread.start()
def shutdown(self):
if not self._is_initialized:
return
self.cancel()
self.textQueue.put(-1)
self.text_queue.put(-1)
def speak(self, text, queueable=True, ignore_punctuation=False):
if not self._is_initialized:
@@ -88,7 +91,7 @@ class driver(speech_driver):
"language": self.language,
"voice": self.voice,
}
self.textQueue.put(utterance.copy())
self.text_queue.put(utterance.copy())
def cancel(self):
if not self._is_initialized:
@@ -129,7 +132,7 @@ class driver(speech_driver):
def clear_buffer(self):
if not self._is_initialized:
return
self.textQueue.clear()
self.text_queue.clear()
def set_voice(self, voice):
if not self._is_initialized:
@@ -140,13 +143,13 @@ class driver(speech_driver):
if not self._is_initialized:
return
self.pitch = str(
self.minPitch + pitch * (self.maxPitch - self.minPitch)
self.min_pitch + pitch * (self.max_pitch - self.min_pitch)
)
def set_rate(self, rate):
if not self._is_initialized:
return
self.rate = str(self.minRate + rate * (self.maxRate - self.minRate))
self.rate = str(self.min_rate + rate * (self.max_rate - self.min_rate))
def set_module(self, module):
if not self._is_initialized:
@@ -162,12 +165,29 @@ class driver(speech_driver):
if not self._is_initialized:
return
self.volume = str(
self.minVolume + volume * (self.maxVolume - self.minVolume)
self.min_volume + volume * (self.max_volume - self.min_volume)
)
def _build_speech_command(self, utterance):
replacements = {
"fenrir_volume": str(utterance["volume"]),
"fenrir_module": str(utterance["module"]),
"fenrir_language": str(utterance["language"]),
"fenrir_voice": str(utterance["voice"]),
"fenrir_pitch": str(utterance["pitch"]),
"fenrir_rate": str(utterance["rate"]),
"fenrir_text": shlex.quote(str(utterance["text"])),
}
speech_command = shlex.split(self.speech_command)
for idx, word in enumerate(speech_command):
for placeholder, value in replacements.items():
word = word.replace(placeholder, value)
speech_command[idx] = word
return speech_command
def worker(self):
while True:
utterance = self.textQueue.get()
utterance = self.text_queue.get()
if isinstance(utterance, int):
if utterance == -1:
@@ -209,21 +229,7 @@ class driver(speech_driver):
if not isinstance(utterance["rate"], str):
utterance["rate"] = ""
popen_speech_command = shlex.split(self.speechCommand)
for idx, word in enumerate(popen_speech_command):
word = word.replace("fenrirVolume", str(utterance["volume"]))
word = word.replace("fenrirModule", str(utterance["module"]))
word = word.replace(
"fenrirLanguage", str(utterance["language"])
)
word = word.replace("fenrirVoice", str(utterance["voice"]))
word = word.replace("fenrirPitch", str(utterance["pitch"]))
word = word.replace("fenrirRate", str(utterance["rate"]))
# Properly quote text to prevent command injection
word = word.replace(
"fenrirText", shlex.quote(str(utterance["text"]))
)
popen_speech_command[idx] = word
popen_speech_command = self._build_speech_command(utterance)
try:
self.env["runtime"]["DebugManager"].write_debug_out(
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import os
import shutil
import subprocess
import threading
_display_lock = threading.RLock()
_CLIPBOARD_TIMEOUT = 2.0
_ENCODING = "utf-8"
def _command_exists(command):
return shutil.which(command) is not None
def _env_for_display(display):
env = os.environ.copy()
if display:
env["DISPLAY"] = display
return env
def _display_candidates(display="", scan_displays=False):
if display:
return [display]
current_display = os.environ.get("DISPLAY", "")
candidates = []
if current_display:
candidates.append(current_display)
if scan_displays:
candidates.extend(f":{index}" for index in range(10))
if not candidates:
candidates.append("")
return list(dict.fromkeys(candidates))
def _read_commands(display):
commands = []
if display:
if _command_exists("xclip"):
commands.append(["xclip", "-selection", "clipboard", "-o"])
if _command_exists("xsel"):
commands.append(["xsel", "-b", "-o"])
elif os.environ.get("WAYLAND_DISPLAY") and _command_exists("wl-paste"):
commands.append(["wl-paste", "-n", "-t", "text"])
return commands
def _write_commands(display):
commands = []
if display:
if _command_exists("xclip"):
commands.append(["xclip", "-selection", "clipboard"])
if _command_exists("xsel"):
commands.append(["xsel", "-b", "-i"])
elif os.environ.get("WAYLAND_DISPLAY") and _command_exists("wl-copy"):
commands.append(["wl-copy"])
return commands
def _run_command(command, display, input_text=None):
input_bytes = None
if input_text is not None:
input_bytes = input_text.encode(_ENCODING)
return subprocess.run(
command,
input=input_bytes,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=_env_for_display(display),
timeout=_CLIPBOARD_TIMEOUT,
check=False,
)
def _command_error(command, result):
stderr = result.stderr.decode(_ENCODING, errors="replace").strip()
if stderr:
return RuntimeError(stderr)
return RuntimeError(
"clipboard command failed: "
+ " ".join(command)
+ " exited with "
+ str(result.returncode)
)
def read_text(display="", scan_displays=False):
last_error = None
command_succeeded = False
with _display_lock:
for candidate in _display_candidates(display, scan_displays):
commands = _read_commands(candidate)
if not commands:
last_error = RuntimeError("no supported clipboard reader found")
for command in commands:
try:
result = _run_command(command, candidate)
if result.returncode != 0:
last_error = _command_error(command, result)
continue
command_succeeded = True
text = result.stdout.decode(_ENCODING)
if text:
return text
except Exception as error:
last_error = error
if last_error:
if command_succeeded:
return None
raise last_error
return None
def write_text(text, display="", scan_displays=False):
if not isinstance(text, str) or not text:
return False
last_error = None
with _display_lock:
for candidate in _display_candidates(display, scan_displays):
commands = _write_commands(candidate)
if not commands:
last_error = RuntimeError("no supported clipboard writer found")
for command in commands:
try:
result = _run_command(command, candidate, text)
if result.returncode == 0:
return True
last_error = _command_error(command, result)
except Exception as error:
last_error = error
if last_error:
raise last_error
return False
+272
View File
@@ -0,0 +1,272 @@
from unittest.mock import Mock
import pytest
from fenrirscreenreader.core.clipboardSyncManager import ClipboardSyncManager
def build_env(
sync_enabled=False,
sync_display="",
screen_driver="vcsaDriver",
keyboard_driver="evdevDriver",
fenrir_text=None,
):
def get_setting(section, setting):
values = {
("clipboard", "sync_display"): sync_display,
("screen", "driver"): screen_driver,
("keyboard", "driver"): keyboard_driver,
}
return values[(section, setting)]
settings_manager = Mock(
get_setting_as_bool=Mock(return_value=sync_enabled),
get_setting_as_float=Mock(return_value=0.5),
get_setting=Mock(side_effect=get_setting),
)
memory_manager = Mock(
is_index_list_empty=Mock(return_value=fenrir_text is None),
get_index_list_element=Mock(return_value=fenrir_text),
add_value_to_first_index=Mock(),
)
return {
"runtime": {
"SettingsManager": settings_manager,
"MemoryManager": memory_manager,
"DebugManager": Mock(write_debug_out=Mock()),
}
}
@pytest.mark.unit
def test_clipboard_sync_disabled_by_default():
manager = ClipboardSyncManager()
manager.start = Mock()
manager.initialize(build_env(sync_enabled=False))
assert manager.enabled is False
manager.start.assert_not_called()
@pytest.mark.unit
def test_clipboard_sync_enabled_in_x_mode_with_current_display(monkeypatch):
monkeypatch.setenv("DISPLAY", ":1")
manager = ClipboardSyncManager()
manager.start = Mock()
manager.initialize(
build_env(
sync_enabled=True,
screen_driver="ptyDriver",
keyboard_driver="x11Driver",
)
)
assert manager.enabled is True
assert manager.display == ":1"
manager.start.assert_called_once_with()
@pytest.mark.unit
def test_clipboard_sync_console_skips_empty_display(monkeypatch):
monkeypatch.delenv("DISPLAY", raising=False)
manager = ClipboardSyncManager()
manager.start = Mock()
manager.initialize(build_env(sync_enabled=True))
assert manager.enabled is False
manager.start.assert_not_called()
@pytest.mark.unit
def test_clipboard_sync_console_allows_configured_display(monkeypatch):
monkeypatch.delenv("DISPLAY", raising=False)
manager = ClipboardSyncManager()
manager.start = Mock()
manager.initialize(build_env(sync_enabled=True, sync_display=":0"))
assert manager.enabled is True
assert manager.display == ":0"
manager.start.assert_called_once_with()
@pytest.mark.unit
def test_mark_written_to_x_updates_observed_state():
manager = ClipboardSyncManager()
manager.mark_written_to_x("manual export")
assert manager.last_written_to_x == "manual export"
assert manager.last_observed_fenrir == "manual export"
assert manager.last_observed_x == "manual export"
@pytest.mark.unit
def test_fenrir_to_x_write_is_not_reimported(monkeypatch):
env = build_env(fenrir_text="from fenrir")
manager = ClipboardSyncManager()
manager.env = env
manager.display = ":1"
write_text = Mock(return_value=True)
read_text = Mock(side_effect=[None, "from fenrir"])
monkeypatch.setattr(
"fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text",
write_text,
)
monkeypatch.setattr(
"fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text",
read_text,
)
manager.poll_once()
manager.poll_once()
write_text.assert_called_once_with("from fenrir", ":1")
env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called()
@pytest.mark.unit
def test_conflicting_clipboards_do_not_swap_values(monkeypatch):
env = build_env(fenrir_text="from fenrir")
manager = ClipboardSyncManager()
manager.env = env
manager.display = ":1"
write_text = Mock(return_value=True)
read_text = Mock(return_value="from x")
monkeypatch.setattr(
"fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text",
write_text,
)
monkeypatch.setattr(
"fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text",
read_text,
)
manager.poll_once()
write_text.assert_called_once_with("from fenrir", ":1")
env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called()
assert manager.last_written_to_x == "from fenrir"
assert manager.last_observed_x == "from fenrir"
assert manager.last_observed_fenrir == "from fenrir"
@pytest.mark.unit
def test_matching_clipboards_are_observed_without_duplicate_import(monkeypatch):
env = build_env(fenrir_text="same text")
manager = ClipboardSyncManager()
manager.env = env
manager.display = ":1"
write_text = Mock(return_value=True)
read_text = Mock(return_value="same text")
monkeypatch.setattr(
"fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text",
write_text,
)
monkeypatch.setattr(
"fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text",
read_text,
)
manager.poll_once()
write_text.assert_not_called()
env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called()
assert manager.last_observed_x == "same text"
assert manager.last_observed_fenrir == "same text"
@pytest.mark.unit
def test_x_to_fenrir_import_is_not_reexported(monkeypatch):
env = build_env(fenrir_text=None)
manager = ClipboardSyncManager()
manager.env = env
manager.display = ":1"
write_text = Mock(return_value=True)
read_text = Mock(return_value="from x")
monkeypatch.setattr(
"fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text",
write_text,
)
monkeypatch.setattr(
"fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text",
read_text,
)
manager.poll_once()
env["runtime"]["MemoryManager"].is_index_list_empty.return_value = False
env["runtime"]["MemoryManager"].get_index_list_element.return_value = (
"from x"
)
manager.poll_once()
env["runtime"]["MemoryManager"].add_value_to_first_index.assert_called_once_with(
"clipboardHistory", "from x"
)
write_text.assert_not_called()
@pytest.mark.unit
def test_selected_imported_text_can_export_after_x_changes(monkeypatch):
env = build_env(fenrir_text=None)
manager = ClipboardSyncManager()
manager.env = env
manager.display = ":1"
write_text = Mock(return_value=True)
read_text = Mock(side_effect=["from x", "other x"])
monkeypatch.setattr(
"fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text",
write_text,
)
monkeypatch.setattr(
"fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text",
read_text,
)
manager.poll_once()
env["runtime"]["MemoryManager"].is_index_list_empty.return_value = False
env["runtime"]["MemoryManager"].get_index_list_element.return_value = (
"from x"
)
manager.last_observed_fenrir = "different fenrir entry"
manager.poll_once()
write_text.assert_called_once_with("from x", ":1")
@pytest.mark.unit
@pytest.mark.parametrize("x_value", [None, "", object()])
def test_x_clipboard_non_text_values_are_ignored(monkeypatch, x_value):
env = build_env(fenrir_text=None)
manager = ClipboardSyncManager()
manager.env = env
manager.display = ":1"
monkeypatch.setattr(
"fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text",
Mock(return_value=x_value),
)
manager.poll_once()
env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called()
@pytest.mark.unit
def test_x_clipboard_paste_exception_is_ignored(monkeypatch):
env = build_env(fenrir_text=None)
manager = ClipboardSyncManager()
manager.env = env
manager.display = ":1"
monkeypatch.setattr(
"fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text",
Mock(side_effect=RuntimeError("no text target")),
)
manager.poll_once()
env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called()
env["runtime"]["DebugManager"].write_debug_out.assert_called_once()
+114
View File
@@ -0,0 +1,114 @@
from configparser import ConfigParser
from pathlib import Path
from unittest.mock import Mock
import pytest
from fenrirscreenreader.core.settingsData import settings_data
from fenrirscreenreader.soundDriver import genericDriver
class SettingsManager:
def __init__(self, settings):
self.settings = settings
def get_setting(self, section, setting):
return self.settings[setting]
def _sound_driver(settings):
sound_driver = genericDriver.driver()
sound_driver.initialize(
{"runtime": {"SettingsManager": SettingsManager(settings)}}
)
sound_driver.set_volume(0.75)
return sound_driver
def _configured_sound_settings():
config = ConfigParser(interpolation=None)
config.read(Path(__file__).parents[2] / "config/settings/settings.conf")
return {
"generic_play_file_command": config.get(
"sound", "generic_play_file_command"
),
"generic_frequency_command": config.get(
"sound", "generic_frequency_command"
),
}
def test_play_frequency_replaces_snake_case_placeholders(monkeypatch):
popen = Mock()
monkeypatch.setattr(genericDriver.subprocess, "Popen", popen)
sound_driver = _sound_driver(
{
"generic_play_file_command": "player fenrir_sound_file",
"generic_frequency_command": (
"tone --volume fenrir_volume --duration fenrir_duration "
"--frequency fenrir_frequency"
),
}
)
sound_driver.play_frequence(
440, 0.25, adjust_volume=0.5, interrupt=False
)
assert popen.call_args.args[0] == [
"tone",
"--volume",
"0.375",
"--duration",
"0.25",
"--frequency",
"440",
]
def test_play_sound_file_replaces_snake_case_placeholders(
monkeypatch, tmp_path
):
popen = Mock()
monkeypatch.setattr(genericDriver.subprocess, "Popen", popen)
sound_file = tmp_path / "sound file.ogg"
sound_file.write_bytes(b"")
sound_driver = _sound_driver(
{
"generic_play_file_command": (
"player --volume fenrir_volume fenrir_sound_file"
),
"generic_frequency_command": "tone fenrir_frequency",
}
)
sound_driver.play_sound_file(str(sound_file), interrupt=False)
assert popen.call_args.args[0] == [
"player",
"--volume",
"0.75",
str(sound_file),
]
@pytest.mark.parametrize(
"settings",
[settings_data["sound"], _configured_sound_settings()],
)
def test_default_sound_commands_use_supported_placeholders(
monkeypatch, tmp_path, settings
):
popen = Mock()
monkeypatch.setattr(genericDriver.subprocess, "Popen", popen)
sound_file = tmp_path / "sound.ogg"
sound_file.write_bytes(b"")
sound_driver = _sound_driver(settings)
sound_driver.play_frequence(
440, 0.25, adjust_volume=0.5, interrupt=False
)
sound_driver.play_sound_file(str(sound_file), interrupt=False)
for call in popen.call_args_list:
assert not any("fenrir_" in argument for argument in call.args[0])
+73
View File
@@ -0,0 +1,73 @@
from configparser import ConfigParser
from pathlib import Path
import pytest
from fenrirscreenreader.core.settingsData import settings_data
from fenrirscreenreader.speechDriver import genericDriver
def test_build_speech_command_replaces_snake_case_placeholders():
speech_driver = genericDriver.driver()
speech_driver.speech_command = (
"synth --volume fenrir_volume --module fenrir_module "
"--language fenrir_language --voice fenrir_voice "
"--pitch fenrir_pitch --rate fenrir_rate fenrir_text"
)
utterance = {
"volume": "150",
"module": "module-name",
"language": "en-US",
"voice": "voice-name",
"pitch": "50",
"rate": "240",
"text": "hello",
}
assert speech_driver._build_speech_command(utterance) == [
"synth",
"--volume",
"150",
"--module",
"module-name",
"--language",
"en-US",
"--voice",
"voice-name",
"--pitch",
"50",
"--rate",
"240",
"hello",
]
def _configured_speech_command():
config = ConfigParser(interpolation=None)
config.read(Path(__file__).parents[2] / "config/settings/settings.conf")
return config.get("speech", "generic_speech_command")
@pytest.mark.parametrize(
"speech_command",
[
settings_data["speech"]["generic_speech_command"],
_configured_speech_command(),
],
)
def test_default_speech_commands_use_supported_placeholders(speech_command):
speech_driver = genericDriver.driver()
speech_driver.speech_command = speech_command
utterance = {
"volume": "150",
"module": "",
"language": "en-US",
"voice": "voice-name",
"pitch": "50",
"rate": "240",
"text": "hello",
}
built_command = speech_driver._build_speech_command(utterance)
assert not any("fenrir_" in argument for argument in built_command)
+1 -1
View File
@@ -99,7 +99,7 @@ def test_litetalk_driver_writes_settings_and_cancel(serial_pair):
speech_driver.set_pitch(0.0)
speech_driver.set_volume(0.5)
speech_driver.cancel()
assert read_available(master_fd, 9) == b"\x019S\x010P\x014V\x18"
assert read_available(master_fd, 10) == b"\x019S\x010P\x014V\x18"
finally:
speech_driver.shutdown()
+57
View File
@@ -70,6 +70,7 @@ def test_pty_stdin_input_interrupts_output_when_all_keys_interrupt_enabled():
"runtime": {
"SettingsManager": settings_manager,
"OutputManager": output_manager,
"DebugManager": Mock(write_debug_out=Mock()),
}
}
@@ -254,6 +255,7 @@ def test_pty_stdin_input_honors_interrupt_disabled():
"runtime": {
"SettingsManager": settings_manager,
"OutputManager": output_manager,
"DebugManager": Mock(write_debug_out=Mock()),
}
}
@@ -273,6 +275,7 @@ def test_pty_stdin_input_leaves_filtered_interrupts_to_key_events():
"runtime": {
"SettingsManager": settings_manager,
"OutputManager": output_manager,
"DebugManager": Mock(write_debug_out=Mock()),
}
}
@@ -281,6 +284,60 @@ def test_pty_stdin_input_leaves_filtered_interrupts_to_key_events():
output_manager.interrupt_output.assert_not_called()
@pytest.mark.unit
def test_pty_recent_review_escape_tail_is_consumed_without_interrupt_or_injection():
pty_driver = PtyDriver()
event_queue = Mock()
settings_manager = Mock()
settings_manager.get_setting_as_bool.return_value = True
settings_manager.get_setting.return_value = ""
output_manager = Mock()
pty_driver.env = {
"commandInfo": {
"lastCommand": "REVIEW_PREV_LINE",
"lastCommandSection": "commands",
"lastCommandRunTime": time.time(),
},
"input": {"curr_input": []},
"runtime": {
"DebugManager": Mock(write_debug_out=Mock()),
"OutputManager": output_manager,
"SettingsManager": settings_manager,
},
}
pty_driver.inject_text_to_screen = Mock()
pty_driver.handle_stdin_input(b"\x1b[7~", event_queue)
output_manager.interrupt_output.assert_not_called()
pty_driver.inject_text_to_screen.assert_not_called()
@pytest.mark.unit
def test_pty_recent_review_does_not_consume_plain_text_input():
pty_driver = PtyDriver()
event_queue = Mock()
settings_manager = Mock()
settings_manager.get_setting_as_bool.return_value = False
pty_driver.env = {
"commandInfo": {
"lastCommand": "REVIEW_PREV_LINE",
"lastCommandSection": "commands",
"lastCommandRunTime": time.time(),
},
"input": {"curr_input": []},
"runtime": {
"DebugManager": Mock(write_debug_out=Mock()),
"SettingsManager": settings_manager,
},
}
pty_driver.inject_text_to_screen = Mock()
pty_driver.handle_stdin_input(b"a", event_queue)
pty_driver.inject_text_to_screen.assert_called_once_with(b"a")
@pytest.mark.unit
def test_pty_backspace_with_fenrir_key_synthesizes_shortcut_events():
pty_driver = PtyDriver()
+90
View File
@@ -0,0 +1,90 @@
import os
import subprocess
from unittest.mock import Mock
import pytest
from fenrirscreenreader.utils import x_clipboard
def command_exists(command):
if command == "xclip":
return "/usr/bin/xclip"
return None
@pytest.mark.unit
def test_write_text_uses_display_env_without_mutating_process_env(
monkeypatch,
):
monkeypatch.delenv("DISPLAY", raising=False)
monkeypatch.setattr(x_clipboard.shutil, "which", command_exists)
run_command = Mock(
return_value=subprocess.CompletedProcess(
["xclip"], 0, stdout=b"", stderr=b""
)
)
monkeypatch.setattr(x_clipboard.subprocess, "run", run_command)
assert x_clipboard.write_text("clipboard text", ":3") is True
command = run_command.call_args.args[0]
kwargs = run_command.call_args.kwargs
assert command == ["xclip", "-selection", "clipboard"]
assert kwargs["input"] == b"clipboard text"
assert kwargs["env"]["DISPLAY"] == ":3"
assert kwargs["timeout"] == 2.0
assert os.environ.get("DISPLAY") is None
@pytest.mark.unit
def test_read_text_scans_displays_until_text_is_found(monkeypatch):
monkeypatch.delenv("DISPLAY", raising=False)
monkeypatch.setattr(x_clipboard.shutil, "which", command_exists)
displays = []
def run_command(command, **kwargs):
displays.append(kwargs["env"].get("DISPLAY"))
if kwargs["env"].get("DISPLAY") == ":1":
return subprocess.CompletedProcess(
command, 0, stdout=b"from x", stderr=b""
)
return subprocess.CompletedProcess(
command, 1, stdout=b"", stderr=b"missing display"
)
monkeypatch.setattr(x_clipboard.subprocess, "run", run_command)
assert x_clipboard.read_text(scan_displays=True) == "from x"
assert displays == [":0", ":1"]
@pytest.mark.unit
def test_read_text_empty_success_returns_none(monkeypatch):
monkeypatch.setenv("DISPLAY", ":2")
monkeypatch.setattr(x_clipboard.shutil, "which", command_exists)
monkeypatch.setattr(
x_clipboard.subprocess,
"run",
Mock(
return_value=subprocess.CompletedProcess(
["xclip"], 0, stdout=b"", stderr=b""
)
),
)
assert x_clipboard.read_text() is None
@pytest.mark.unit
def test_write_text_propagates_clipboard_timeout(monkeypatch):
monkeypatch.setattr(x_clipboard.shutil, "which", command_exists)
timeout = subprocess.TimeoutExpired(["xclip"], 2.0)
monkeypatch.setattr(
x_clipboard.subprocess,
"run",
Mock(side_effect=timeout),
)
with pytest.raises(subprocess.TimeoutExpired):
x_clipboard.write_text("clipboard text", ":3")