Compare commits
4 Commits
fd5fe5b328
...
testing
| Author | SHA1 | Date | |
|---|---|---|---|
| eb93a8aa62 | |||
| ad863ad706 | |||
| 447e2cc896 | |||
| 2b7d205f06 |
@@ -19,14 +19,14 @@ volume=0.7
|
|||||||
|
|
||||||
# shell commands for generic sound driver
|
# shell commands for generic sound driver
|
||||||
# the folowing variable are substituted
|
# the folowing variable are substituted
|
||||||
# fenrirVolume = the current volume setting
|
# fenrir_volume = the current volume setting
|
||||||
# fenrirSoundFile = the soundfile for an soundicon
|
# fenrir_sound_file = the soundfile for an soundicon
|
||||||
# fenrirFrequence = the frequency to play
|
# fenrir_frequency = the frequency to play
|
||||||
# fenrirDuration = the duration of the frequency
|
# fenrir_duration = the duration of the frequency
|
||||||
# the following command is used to play a soundfile
|
# 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
|
#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
|
# Enable progress bar monitoring with ascending tones by default
|
||||||
progress_monitoring=True
|
progress_monitoring=True
|
||||||
@@ -114,17 +114,17 @@ max_batch_lines=100
|
|||||||
# Only enable flood control if this many new lines appear in the window
|
# Only enable flood control if this many new lines appear in the window
|
||||||
flood_line_threshold=500
|
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
|
# the following variables are replaced with values
|
||||||
# fenrirText = is the text that should be spoken
|
# fenrir_text = is the text that should be spoken
|
||||||
# fenrirModule = may be the speech module like used in speech-dispatcher, not every TTY need this
|
# fenrir_module = may be the speech module like used in speech-dispatcher, not every TTY need this
|
||||||
# fenrirLanguage = the language
|
# fenrir_language = the language
|
||||||
# fenrirVoice = is the current voice that should be used. Set the voice variable above.
|
# 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
|
# the current volume, pitch and rate is calculated like this
|
||||||
# value = min + settingValue * (min - max )
|
# value = min + settingValue * (min - max )
|
||||||
# fenrirVolume = is replaced with the current volume
|
# fenrir_volume = is replaced with the current volume
|
||||||
# fenrirPitch = is replaced with the current pitch
|
# fenrir_pitch = is replaced with the current pitch
|
||||||
# fenrirRate = is replaced with the current speed (speech rate)
|
# 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"
|
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
|
# 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
|
replace_undefined_punctuation_with_space=True
|
||||||
# Pause speech briefly at newline characters for better readability
|
# Pause speech briefly at newline characters for better readability
|
||||||
new_line_pause=True
|
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")
|
# Convert text emoticons like :) to descriptive text (e.g., "smiling face")
|
||||||
emoticons=True
|
emoticons=True
|
||||||
# Define the Fenrir modifier key(s) - used to trigger Fenrir commands
|
# 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
|
# verbose = include diff line content during navigation
|
||||||
diff_verbosity=compact
|
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]
|
[focus]
|
||||||
# Follow and announce text cursor position changes
|
# Follow and announce text cursor position changes
|
||||||
cursor=True
|
cursor=True
|
||||||
|
|||||||
+15
-13
@@ -1506,38 +1506,38 @@ Values: `+0.0+` is quietest, `+1.0+` is loudest.
|
|||||||
The generic sound driver uses shell commands for play sound and
|
The generic sound driver uses shell commands for play sound and
|
||||||
frequencies.
|
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.
|
sound file.
|
||||||
|
|
||||||
....
|
....
|
||||||
generic_play_file_command=<your command for playing a 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.
|
frequencies.
|
||||||
|
|
||||||
....
|
....
|
||||||
generic_frequency_command=<your command for playing a frequence>
|
generic_frequency_command=<your command for playing a frequence>
|
||||||
....
|
....
|
||||||
|
|
||||||
The following variables are substituted in `+genericPlayFileCommand+`
|
The following variables are substituted in `+generic_play_file_command+`
|
||||||
and `+genericFrequencyCommand+`:
|
and `+generic_frequency_command+`:
|
||||||
|
|
||||||
* `+fenrirVolume+` = the current volume setting
|
* `+fenrir_volume+` = the current volume setting
|
||||||
* `+fenrirSoundFile+` = the sound file for an sound icon
|
* `+fenrir_sound_file+` = the sound file for an sound icon
|
||||||
* `+fenrirFrequence+` = the frequency to play
|
* `+fenrir_frequency+` = the frequency to play
|
||||||
* `+fenrirDuration+` = the duration of the frequency
|
* `+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
|
==== Speech
|
||||||
@@ -1921,7 +1921,8 @@ link:#export clipboard to file[export clipboard to file]. The variable
|
|||||||
`+$user+` is replaced by the current logged username.
|
`+$user+` is replaced by the current logged username.
|
||||||
|
|
||||||
....
|
....
|
||||||
clipboardExportPath=/tmp/fenrirClipboard
|
[clipboard]
|
||||||
|
clipboard_export_path=/tmp/fenrirClipboard
|
||||||
....
|
....
|
||||||
|
|
||||||
Values: Text, Systemfilepath
|
Values: Text, Systemfilepath
|
||||||
@@ -1929,6 +1930,7 @@ Values: Text, Systemfilepath
|
|||||||
The number of available clipboards:
|
The number of available clipboards:
|
||||||
|
|
||||||
....
|
....
|
||||||
|
[clipboard]
|
||||||
number_of_clipboards=10
|
number_of_clipboards=10
|
||||||
....
|
....
|
||||||
|
|
||||||
|
|||||||
+27
-25
@@ -857,21 +857,21 @@ Values: ''0.0'' is quietest, ''1.0'' is loudest.
|
|||||||
=== Generic Driver ===
|
=== Generic Driver ===
|
||||||
The generic sound driver uses shell commands for play sound and frequencies.
|
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>
|
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>
|
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
|
* ''fenrir_volume'' = the current volume setting
|
||||||
* ''fenrirSoundFile'' = the sound file for an sound icon
|
* ''fenrir_sound_file'' = the sound file for an sound icon
|
||||||
* ''fenrirFrequence'' = the frequency to play
|
* ''fenrir_frequency'' = the frequency to play
|
||||||
* ''fenrirDuration'' = the duration of the frequency
|
* ''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 ====
|
==== Speech ====
|
||||||
Speech is configured in section ''[speech]''.
|
Speech is configured in section ''[speech]''.
|
||||||
Turn speech on or off:
|
Turn speech on or off:
|
||||||
@@ -945,22 +945,22 @@ Values: on=''True'', off=''False''
|
|||||||
=== Generic Driver ===
|
=== Generic Driver ===
|
||||||
The generic speech driver uses shell commands for speech synthisus.
|
The generic speech driver uses shell commands for speech synthisus.
|
||||||
|
|
||||||
''genericSpeechCommand'' defines the command that is executed for creating speech
|
''generic_speech_command'' defines the command that is executed for creating speech
|
||||||
The following variables are substituted in ''genericSpeechCommand'':
|
The following variables are substituted in ''generic_speech_command'':
|
||||||
* ''FenrirText'' = is the text that should be spoken
|
* ''fenrir_text'' = is the text that should be spoken
|
||||||
* ''fenrirModule'' = may be the speech module like used in speech-dispatcher, not every TTY needs this
|
* ''fenrir_module'' = may be the speech module like used in speech-dispatcher, not every TTY needs this
|
||||||
* ''fenrirLanguage'' = the language to speak in
|
* ''fenrir_language'' = the language to speak in
|
||||||
* ''fenrirVoice'' = is the current voice that should be used
|
* ''fenrir_voice'' = is the current voice that should be used
|
||||||
* ''fenrirVolume'' = is replaced with the current volume
|
* ''fenrir_volume'' = is replaced with the current volume
|
||||||
* ''fenrirPitch'' = is replaced with the current pitch
|
* ''fenrir_pitch'' = is replaced with the current pitch
|
||||||
* ''fenrirRate'' = is replaced with the current speed (speech rate)
|
* ''fenrir_rate'' = is replaced with the current speed (speech rate)
|
||||||
|
|
||||||
Example genericSpeechCommand (default):
|
Example generic_speech_command (default):
|
||||||
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"
|
||||||
|
|
||||||
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_max_volume=200
|
||||||
fenrir_min_pitch=0
|
fenrir_min_pitch=0
|
||||||
fenrir_max_pitch=99
|
fenrir_max_pitch=99
|
||||||
@@ -1133,10 +1133,12 @@ Values: on=''True'', off=''False''
|
|||||||
Specify the path where the clipboard should be exported to.
|
Specify the path where the clipboard should be exported to.
|
||||||
See [[#export clipboard to file|export clipboard to file]].
|
See [[#export clipboard to file|export clipboard to file]].
|
||||||
The variable ''$user'' is replaced by the current logged username.
|
The variable ''$user'' is replaced by the current logged username.
|
||||||
clipboardExportPath=/tmp/fenrirClipboard
|
[clipboard]
|
||||||
|
clipboard_export_path=/tmp/fenrirClipboard
|
||||||
Values: Text, Systemfilepath
|
Values: Text, Systemfilepath
|
||||||
|
|
||||||
The number of available clipboards:
|
The number of available clipboards:
|
||||||
|
[clipboard]
|
||||||
number_of_clipboards=10
|
number_of_clipboards=10
|
||||||
Values: Integer, 1 - 999
|
Values: Integer, 1 - 999
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class command:
|
|||||||
self.env["runtime"]["MemoryManager"].add_index_list(
|
self.env["runtime"]["MemoryManager"].add_index_list(
|
||||||
"clipboardHistory",
|
"clipboardHistory",
|
||||||
self.env["runtime"]["SettingsManager"].get_setting_as_int(
|
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):
|
def run(self):
|
||||||
clipboard_file_path = self.env["runtime"][
|
clipboard_file_path = self.env["runtime"][
|
||||||
"SettingsManager"
|
"SettingsManager"
|
||||||
].get_setting("general", "clipboard_export_path")
|
].get_setting("clipboard", "clipboard_export_path")
|
||||||
clipboard_file_path = clipboard_file_path.replace(
|
clipboard_file_path = clipboard_file_path.replace(
|
||||||
"$user", self.env["general"]["curr_user"]
|
"$user", self.env["general"]["curr_user"]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,12 +5,9 @@
|
|||||||
# By Chrys, Storm Dragon, and contributors.
|
# By Chrys, Storm Dragon, and contributors.
|
||||||
|
|
||||||
import _thread
|
import _thread
|
||||||
import importlib
|
|
||||||
import os
|
|
||||||
|
|
||||||
import pyperclip
|
|
||||||
|
|
||||||
from fenrirscreenreader.core.i18n import _
|
from fenrirscreenreader.core.i18n import _
|
||||||
|
from fenrirscreenreader.utils import x_clipboard
|
||||||
|
|
||||||
|
|
||||||
class command:
|
class command:
|
||||||
@@ -46,36 +43,20 @@ class command:
|
|||||||
"MemoryManager"
|
"MemoryManager"
|
||||||
].get_index_list_element("clipboardHistory")
|
].get_index_list_element("clipboardHistory")
|
||||||
|
|
||||||
# Remember original display environment variable if it exists
|
try:
|
||||||
original_display = os.environ.get("DISPLAY", "")
|
success = x_clipboard.write_text(
|
||||||
success = False
|
clipboard, scan_displays=True
|
||||||
|
)
|
||||||
# Try different display options
|
except Exception:
|
||||||
for i in range(10):
|
success = False
|
||||||
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)
|
|
||||||
|
|
||||||
# Notify the user of the result
|
# Notify the user of the result
|
||||||
if success:
|
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(
|
self.env["runtime"]["OutputManager"].present_text(
|
||||||
_("exported to the X session."), interrupt=True
|
_("exported to the X session."), interrupt=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class command:
|
|||||||
def run(self):
|
def run(self):
|
||||||
clipboard_file_path = self.env["runtime"][
|
clipboard_file_path = self.env["runtime"][
|
||||||
"SettingsManager"
|
"SettingsManager"
|
||||||
].get_setting("general", "clipboard_export_path")
|
].get_setting("clipboard", "clipboard_export_path")
|
||||||
clipboard_file_path = clipboard_file_path.replace(
|
clipboard_file_path = clipboard_file_path.replace(
|
||||||
"$user", self.env["general"]["curr_user"]
|
"$user", self.env["general"]["curr_user"]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,12 +5,9 @@
|
|||||||
# By Chrys, Storm Dragon, and contributors.
|
# By Chrys, Storm Dragon, and contributors.
|
||||||
|
|
||||||
import _thread
|
import _thread
|
||||||
import importlib
|
|
||||||
import os
|
|
||||||
|
|
||||||
import pyperclip
|
|
||||||
|
|
||||||
from fenrirscreenreader.core.i18n import _
|
from fenrirscreenreader.core.i18n import _
|
||||||
|
from fenrirscreenreader.utils import x_clipboard
|
||||||
|
|
||||||
|
|
||||||
class command:
|
class command:
|
||||||
@@ -32,33 +29,12 @@ class command:
|
|||||||
|
|
||||||
def _thread_run(self):
|
def _thread_run(self):
|
||||||
try:
|
try:
|
||||||
# Remember original display environment variable if it exists
|
try:
|
||||||
original_display = os.environ.get("DISPLAY", "")
|
clipboard_content = x_clipboard.read_text(
|
||||||
clipboard_content = None
|
scan_displays=True
|
||||||
|
)
|
||||||
# Try different display options
|
except Exception:
|
||||||
for i in range(10):
|
clipboard_content = None
|
||||||
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)
|
|
||||||
|
|
||||||
# Process the clipboard content if we found any
|
# Process the clipboard content if we found any
|
||||||
if clipboard_content and isinstance(clipboard_content, str):
|
if clipboard_content and isinstance(clipboard_content, str):
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class command:
|
|||||||
self.env["runtime"]["MemoryManager"].add_index_list(
|
self.env["runtime"]["MemoryManager"].add_index_list(
|
||||||
"clipboardHistory",
|
"clipboardHistory",
|
||||||
self.env["runtime"]["SettingsManager"].get_setting_as_int(
|
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"] = {
|
self.config["general"] = {
|
||||||
"punctuation_level": "some",
|
"punctuation_level": "some",
|
||||||
"debug_level": "0",
|
"debug_level": "0",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Basic clipboard defaults
|
||||||
|
self.config["clipboard"] = {
|
||||||
"number_of_clipboards": "50",
|
"number_of_clipboards": "50",
|
||||||
|
"clipboard_export_path": "/tmp/fenrirClipboard",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Write the configuration
|
# 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': '',
|
# 'curr_command': '',
|
||||||
"lastCommandExecutionTime": time.time(),
|
"lastCommandExecutionTime": time.time(),
|
||||||
"lastCommandRequestTime": time.time(),
|
"lastCommandRequestTime": time.time(),
|
||||||
|
"lastCommand": "",
|
||||||
|
"lastCommandSection": "",
|
||||||
|
"lastCommandRunTime": 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -474,6 +474,10 @@ class CommandManager:
|
|||||||
def run_command(self, command, section="commands"):
|
def run_command(self, command, section="commands"):
|
||||||
if self.command_exists(command, section):
|
if self.command_exists(command, section):
|
||||||
try:
|
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(
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
"run_command command:" + section + "." + command,
|
"run_command command:" + section + "." + command,
|
||||||
debug.DebugLevel.INFO,
|
debug.DebugLevel.INFO,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ general_data = {
|
|||||||
"CursorManager",
|
"CursorManager",
|
||||||
"ApplicationManager",
|
"ApplicationManager",
|
||||||
"CommandManager",
|
"CommandManager",
|
||||||
|
"ClipboardSyncManager",
|
||||||
"ScreenManager",
|
"ScreenManager",
|
||||||
"InputManager",
|
"InputManager",
|
||||||
"OutputManager",
|
"OutputManager",
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ class RemoteManager:
|
|||||||
def export_clipboard(self):
|
def export_clipboard(self):
|
||||||
clipboard_file_path = self.env["runtime"][
|
clipboard_file_path = self.env["runtime"][
|
||||||
"SettingsManager"
|
"SettingsManager"
|
||||||
].get_setting("general", "clipboard_export_path")
|
].get_setting("clipboard", "clipboard_export_path")
|
||||||
clipboard_file_path = clipboard_file_path.replace(
|
clipboard_file_path = clipboard_file_path.replace(
|
||||||
"$user", self.env["general"]["curr_user"]
|
"$user", self.env["general"]["curr_user"]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ runtime_data = {
|
|||||||
"RemoteDriver": None,
|
"RemoteDriver": None,
|
||||||
"InputManager": None,
|
"InputManager": None,
|
||||||
"CommandManager": None,
|
"CommandManager": None,
|
||||||
|
"ClipboardSyncManager": None,
|
||||||
"ScreenManager": None,
|
"ScreenManager": None,
|
||||||
"OutputManager": None,
|
"OutputManager": None,
|
||||||
"SpeechHistoryManager": None,
|
"SpeechHistoryManager": None,
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ settings_data = {
|
|||||||
"driver": "genericDriver",
|
"driver": "genericDriver",
|
||||||
"theme": "default",
|
"theme": "default",
|
||||||
"volume": 1.0,
|
"volume": 1.0,
|
||||||
"generic_play_file_command": "play -q -v fenrirVolume fenrirSoundFile",
|
"generic_play_file_command": (
|
||||||
"generic_frequency_command": "play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence",
|
"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,
|
"progress_monitoring": True,
|
||||||
},
|
},
|
||||||
"speech": {
|
"speech": {
|
||||||
@@ -38,7 +43,10 @@ settings_data = {
|
|||||||
"batch_flush_interval": 0.5,
|
"batch_flush_interval": 0.5,
|
||||||
"max_batch_lines": 100,
|
"max_batch_lines": 100,
|
||||||
"flood_line_threshold": 500,
|
"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_min_volume": 0,
|
||||||
"fenrir_max_volume": 200,
|
"fenrir_max_volume": 200,
|
||||||
"fenrir_min_pitch": 0,
|
"fenrir_min_pitch": 0,
|
||||||
@@ -62,8 +70,6 @@ settings_data = {
|
|||||||
"respect_punctuation_pause": True,
|
"respect_punctuation_pause": True,
|
||||||
"replace_undefined_punctuation_with_space": True,
|
"replace_undefined_punctuation_with_space": True,
|
||||||
"new_line_pause": True,
|
"new_line_pause": True,
|
||||||
"number_of_clipboards": 10,
|
|
||||||
"clipboard_export_path": "/tmp/fenrirClipboard",
|
|
||||||
"emoticons": True,
|
"emoticons": True,
|
||||||
"fenrir_keys": "KEY_KP0,KEY_META",
|
"fenrir_keys": "KEY_KP0,KEY_META",
|
||||||
"script_keys": "KEY_COMPOSE",
|
"script_keys": "KEY_COMPOSE",
|
||||||
@@ -81,6 +87,13 @@ settings_data = {
|
|||||||
"diff_presentation": "both",
|
"diff_presentation": "both",
|
||||||
"diff_verbosity": "compact",
|
"diff_verbosity": "compact",
|
||||||
},
|
},
|
||||||
|
"clipboard": {
|
||||||
|
"number_of_clipboards": 10,
|
||||||
|
"clipboard_export_path": "/tmp/fenrirClipboard",
|
||||||
|
"sync_enabled": False,
|
||||||
|
"sync_display": "",
|
||||||
|
"sync_interval": 0.5,
|
||||||
|
},
|
||||||
"focus": {
|
"focus": {
|
||||||
"cursor": True,
|
"cursor": True,
|
||||||
"highlight": False,
|
"highlight": False,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from configparser import ConfigParser
|
|||||||
from fenrirscreenreader.core import applicationManager
|
from fenrirscreenreader.core import applicationManager
|
||||||
from fenrirscreenreader.core import attributeManager
|
from fenrirscreenreader.core import attributeManager
|
||||||
from fenrirscreenreader.core import barrierManager
|
from fenrirscreenreader.core import barrierManager
|
||||||
|
from fenrirscreenreader.core import clipboardSyncManager
|
||||||
from fenrirscreenreader.core import commandManager
|
from fenrirscreenreader.core import commandManager
|
||||||
from fenrirscreenreader.core import cursorManager
|
from fenrirscreenreader.core import cursorManager
|
||||||
from fenrirscreenreader.core import debug
|
from fenrirscreenreader.core import debug
|
||||||
@@ -771,6 +772,13 @@ class SettingsManager:
|
|||||||
] = commandManager.CommandManager()
|
] = commandManager.CommandManager()
|
||||||
environment["runtime"]["CommandManager"].initialize(environment)
|
environment["runtime"]["CommandManager"].initialize(environment)
|
||||||
|
|
||||||
|
environment["runtime"][
|
||||||
|
"ClipboardSyncManager"
|
||||||
|
] = clipboardSyncManager.ClipboardSyncManager()
|
||||||
|
environment["runtime"]["ClipboardSyncManager"].initialize(
|
||||||
|
environment
|
||||||
|
)
|
||||||
|
|
||||||
environment["runtime"]["HelpManager"] = helpManager.HelpManager()
|
environment["runtime"]["HelpManager"] = helpManager.HelpManager()
|
||||||
environment["runtime"]["HelpManager"].initialize(environment)
|
environment["runtime"]["HelpManager"].initialize(environment)
|
||||||
|
|
||||||
|
|||||||
@@ -4,5 +4,5 @@
|
|||||||
# Fenrir TTY screen reader
|
# Fenrir TTY screen reader
|
||||||
# By Chrys, Storm Dragon, and contributors.
|
# By Chrys, Storm Dragon, and contributors.
|
||||||
|
|
||||||
version = "2026.06.01"
|
version = "2026.06.27"
|
||||||
code_name = "master"
|
code_name = "testing"
|
||||||
|
|||||||
@@ -304,6 +304,11 @@ class driver(screenDriver):
|
|||||||
"keyboard", "interrupt_on_key_press_filter"
|
"keyboard", "interrupt_on_key_press_filter"
|
||||||
).strip():
|
).strip():
|
||||||
return
|
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()
|
self.start_stdin_interrupt_thread()
|
||||||
|
|
||||||
def start_stdin_interrupt_thread(self):
|
def start_stdin_interrupt_thread(self):
|
||||||
@@ -335,10 +340,43 @@ class driver(screenDriver):
|
|||||||
return
|
return
|
||||||
if self.handle_vmenu_stdin_input(msg_bytes, event_queue):
|
if self.handle_vmenu_stdin_input(msg_bytes, event_queue):
|
||||||
return
|
return
|
||||||
|
if self.suppress_recent_review_stdin_tail(msg_bytes):
|
||||||
|
return
|
||||||
self.record_stdin_keypress(msg_bytes)
|
self.record_stdin_keypress(msg_bytes)
|
||||||
self.interrupt_output_on_stdin_input(msg_bytes)
|
self.interrupt_output_on_stdin_input(msg_bytes)
|
||||||
self.inject_text_to_screen(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):
|
def handle_vmenu_stdin_input(self, msg_bytes, event_queue):
|
||||||
if not self.is_vmenu_active():
|
if not self.is_vmenu_active():
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -26,15 +26,15 @@ class driver(sound_driver):
|
|||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
proc: Currently running subprocess for sound playback
|
proc: Currently running subprocess for sound playback
|
||||||
soundFileCommand (str): Command template for playing sound files
|
sound_file_command (str): Command template for playing sound files
|
||||||
frequenceCommand (str): Command template for generating frequencies
|
frequency_command (str): Command template for generating frequencies
|
||||||
"""
|
"""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
sound_driver.__init__(self)
|
sound_driver.__init__(self)
|
||||||
self.proc = None
|
self.proc = None
|
||||||
self.soundType = ""
|
self.sound_type = ""
|
||||||
self.soundFileCommand = ""
|
self.sound_file_command = ""
|
||||||
self.frequenceCommand = ""
|
self.frequency_command = ""
|
||||||
|
|
||||||
def initialize(self, environment):
|
def initialize(self, environment):
|
||||||
"""Initialize the generic sound driver.
|
"""Initialize the generic sound driver.
|
||||||
@@ -46,17 +46,20 @@ class driver(sound_driver):
|
|||||||
environment: Fenrir environment dictionary with settings
|
environment: Fenrir environment dictionary with settings
|
||||||
"""
|
"""
|
||||||
self.env = environment
|
self.env = environment
|
||||||
self.soundFileCommand = self.env["runtime"][
|
self.sound_file_command = self.env["runtime"][
|
||||||
"SettingsManager"
|
"SettingsManager"
|
||||||
].get_setting("sound", "generic_play_file_command")
|
].get_setting("sound", "generic_play_file_command")
|
||||||
self.frequenceCommand = self.env["runtime"][
|
self.frequency_command = self.env["runtime"][
|
||||||
"SettingsManager"
|
"SettingsManager"
|
||||||
].get_setting("sound", "generic_frequency_command")
|
].get_setting("sound", "generic_frequency_command")
|
||||||
if self.soundFileCommand == "":
|
if self.sound_file_command == "":
|
||||||
self.soundFileCommand = "play -q -v fenrirVolume fenrirSoundFile"
|
self.sound_file_command = (
|
||||||
if self.frequenceCommand == "":
|
"play -q -v fenrir_volume fenrir_sound_file"
|
||||||
self.frequenceCommand = (
|
)
|
||||||
"play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence"
|
if self.frequency_command == "":
|
||||||
|
self.frequency_command = (
|
||||||
|
"play -q -v fenrir_volume -n -c1 synth "
|
||||||
|
"fenrir_duration sine fenrir_frequency"
|
||||||
)
|
)
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
|
|
||||||
@@ -75,22 +78,22 @@ class driver(sound_driver):
|
|||||||
return
|
return
|
||||||
if interrupt:
|
if interrupt:
|
||||||
self.cancel()
|
self.cancel()
|
||||||
popen_frequence_command = shlex.split(self.frequenceCommand)
|
popen_frequency_command = shlex.split(self.frequency_command)
|
||||||
for idx, word in enumerate(popen_frequence_command):
|
for idx, word in enumerate(popen_frequency_command):
|
||||||
word = word.replace(
|
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("fenrir_duration", str(duration))
|
||||||
word = word.replace("fenrirFrequence", str(frequence))
|
word = word.replace("fenrir_frequency", str(frequence))
|
||||||
popen_frequence_command[idx] = word
|
popen_frequency_command[idx] = word
|
||||||
self.proc = subprocess.Popen(
|
self.proc = subprocess.Popen(
|
||||||
popen_frequence_command,
|
popen_frequency_command,
|
||||||
stdin=None,
|
stdin=None,
|
||||||
stdout=None,
|
stdout=None,
|
||||||
stderr=None,
|
stderr=None,
|
||||||
shell=False,
|
shell=False,
|
||||||
)
|
)
|
||||||
self.soundType = "frequence"
|
self.sound_type = "frequence"
|
||||||
|
|
||||||
def play_sound_file(self, file_path, interrupt=True):
|
def play_sound_file(self, file_path, interrupt=True):
|
||||||
"""Play a sound file.
|
"""Play a sound file.
|
||||||
@@ -108,13 +111,13 @@ class driver(sound_driver):
|
|||||||
|
|
||||||
if not os.path.isfile(file_path) or ".." in file_path:
|
if not os.path.isfile(file_path) or ".." in file_path:
|
||||||
return
|
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):
|
for idx, word in enumerate(popen_sound_file_command):
|
||||||
word = word.replace("fenrirVolume", str(self.volume))
|
word = word.replace("fenrir_volume", str(self.volume))
|
||||||
word = word.replace("fenrirSoundFile", shlex.quote(str(file_path)))
|
word = word.replace("fenrir_sound_file", str(file_path))
|
||||||
popen_sound_file_command[idx] = word
|
popen_sound_file_command[idx] = word
|
||||||
self.proc = subprocess.Popen(popen_sound_file_command, shell=False)
|
self.proc = subprocess.Popen(popen_sound_file_command, shell=False)
|
||||||
self.soundType = "file"
|
self.sound_type = "file"
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
"""Cancel currently playing sound.
|
"""Cancel currently playing sound.
|
||||||
@@ -123,9 +126,9 @@ class driver(sound_driver):
|
|||||||
"""
|
"""
|
||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
return
|
return
|
||||||
if self.soundType == "":
|
if self.sound_type == "":
|
||||||
return
|
return
|
||||||
if self.soundType == "file":
|
if self.sound_type == "file":
|
||||||
self.proc.kill()
|
self.proc.kill()
|
||||||
try:
|
try:
|
||||||
# Wait for process to finish to prevent zombies
|
# Wait for process to finish to prevent zombies
|
||||||
@@ -134,7 +137,7 @@ class driver(sound_driver):
|
|||||||
pass # Process already terminated
|
pass # Process already terminated
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pass # Handle any other wait errors
|
pass # Handle any other wait errors
|
||||||
if self.soundType == "frequence":
|
if self.sound_type == "frequence":
|
||||||
self.proc.kill()
|
self.proc.kill()
|
||||||
try:
|
try:
|
||||||
# Wait for process to finish to prevent zombies
|
# Wait for process to finish to prevent zombies
|
||||||
@@ -143,4 +146,4 @@ class driver(sound_driver):
|
|||||||
pass # Process already terminated
|
pass # Process already terminated
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pass # Handle any other wait errors
|
pass # Handle any other wait errors
|
||||||
self.soundType = ""
|
self.sound_type = ""
|
||||||
|
|||||||
@@ -30,49 +30,52 @@ class driver(speech_driver):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
speech_driver.__init__(self)
|
speech_driver.__init__(self)
|
||||||
self.proc = None
|
self.proc = None
|
||||||
self.speechThread = Thread(target=self.worker)
|
self.speech_thread = Thread(target=self.worker)
|
||||||
self.lock = Lock()
|
self.lock = Lock()
|
||||||
self.textQueue = SpeakQueue()
|
self.text_queue = SpeakQueue()
|
||||||
|
|
||||||
def initialize(self, environment):
|
def initialize(self, environment):
|
||||||
self.env = environment
|
self.env = environment
|
||||||
self.minVolume = self.env["runtime"][
|
self.min_volume = self.env["runtime"][
|
||||||
"SettingsManager"
|
"SettingsManager"
|
||||||
].get_setting_as_int("speech", "fenrir_min_volume")
|
].get_setting_as_int("speech", "fenrir_min_volume")
|
||||||
self.maxVolume = self.env["runtime"][
|
self.max_volume = self.env["runtime"][
|
||||||
"SettingsManager"
|
"SettingsManager"
|
||||||
].get_setting_as_int("speech", "fenrir_max_volume")
|
].get_setting_as_int("speech", "fenrir_max_volume")
|
||||||
self.minPitch = self.env["runtime"][
|
self.min_pitch = self.env["runtime"][
|
||||||
"SettingsManager"
|
"SettingsManager"
|
||||||
].get_setting_as_int("speech", "fenrir_min_pitch")
|
].get_setting_as_int("speech", "fenrir_min_pitch")
|
||||||
self.maxPitch = self.env["runtime"][
|
self.max_pitch = self.env["runtime"][
|
||||||
"SettingsManager"
|
"SettingsManager"
|
||||||
].get_setting_as_int("speech", "fenrir_max_pitch")
|
].get_setting_as_int("speech", "fenrir_max_pitch")
|
||||||
self.minRate = self.env["runtime"][
|
self.min_rate = self.env["runtime"][
|
||||||
"SettingsManager"
|
"SettingsManager"
|
||||||
].get_setting_as_int("speech", "fenrir_min_rate")
|
].get_setting_as_int("speech", "fenrir_min_rate")
|
||||||
self.maxRate = self.env["runtime"][
|
self.max_rate = self.env["runtime"][
|
||||||
"SettingsManager"
|
"SettingsManager"
|
||||||
].get_setting_as_int("speech", "fenrir_max_rate")
|
].get_setting_as_int("speech", "fenrir_max_rate")
|
||||||
|
|
||||||
self.speechCommand = self.env["runtime"][
|
self.speech_command = self.env["runtime"][
|
||||||
"SettingsManager"
|
"SettingsManager"
|
||||||
].get_setting("speech", "generic_speech_command")
|
].get_setting("speech", "generic_speech_command")
|
||||||
if self.speechCommand == "":
|
if self.speech_command == "":
|
||||||
self.speechCommand = 'espeak -a fenrirVolume -s fenrirRate -p fenrirPitch -v fenrirVoice -- "fenrirText"'
|
self.speech_command = (
|
||||||
|
"espeak -a fenrir_volume -s fenrir_rate -p fenrir_pitch "
|
||||||
|
'-v fenrir_voice -- "fenrir_text"'
|
||||||
|
)
|
||||||
if False: # for debugging overwrite here
|
if False: # for debugging overwrite here
|
||||||
# self.speechCommand = 'spd-say --wait -r 100 -i 100 "fenrirText"'
|
# self.speech_command = 'spd-say --wait -r 100 -i 100 "fenrir_text"'
|
||||||
self.speechCommand = 'flite -t "fenrirText"'
|
self.speech_command = 'flite -t "fenrir_text"'
|
||||||
|
|
||||||
self._is_initialized = True
|
self._is_initialized = True
|
||||||
if self._is_initialized:
|
if self._is_initialized:
|
||||||
self.speechThread.start()
|
self.speech_thread.start()
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
if not self._is_initialized:
|
if not self._is_initialized:
|
||||||
return
|
return
|
||||||
self.cancel()
|
self.cancel()
|
||||||
self.textQueue.put(-1)
|
self.text_queue.put(-1)
|
||||||
|
|
||||||
def speak(self, text, queueable=True, ignore_punctuation=False):
|
def speak(self, text, queueable=True, ignore_punctuation=False):
|
||||||
if not self._is_initialized:
|
if not self._is_initialized:
|
||||||
@@ -88,7 +91,7 @@ class driver(speech_driver):
|
|||||||
"language": self.language,
|
"language": self.language,
|
||||||
"voice": self.voice,
|
"voice": self.voice,
|
||||||
}
|
}
|
||||||
self.textQueue.put(utterance.copy())
|
self.text_queue.put(utterance.copy())
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
if not self._is_initialized:
|
if not self._is_initialized:
|
||||||
@@ -129,7 +132,7 @@ class driver(speech_driver):
|
|||||||
def clear_buffer(self):
|
def clear_buffer(self):
|
||||||
if not self._is_initialized:
|
if not self._is_initialized:
|
||||||
return
|
return
|
||||||
self.textQueue.clear()
|
self.text_queue.clear()
|
||||||
|
|
||||||
def set_voice(self, voice):
|
def set_voice(self, voice):
|
||||||
if not self._is_initialized:
|
if not self._is_initialized:
|
||||||
@@ -140,13 +143,13 @@ class driver(speech_driver):
|
|||||||
if not self._is_initialized:
|
if not self._is_initialized:
|
||||||
return
|
return
|
||||||
self.pitch = str(
|
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):
|
def set_rate(self, rate):
|
||||||
if not self._is_initialized:
|
if not self._is_initialized:
|
||||||
return
|
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):
|
def set_module(self, module):
|
||||||
if not self._is_initialized:
|
if not self._is_initialized:
|
||||||
@@ -162,12 +165,29 @@ class driver(speech_driver):
|
|||||||
if not self._is_initialized:
|
if not self._is_initialized:
|
||||||
return
|
return
|
||||||
self.volume = str(
|
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):
|
def worker(self):
|
||||||
while True:
|
while True:
|
||||||
utterance = self.textQueue.get()
|
utterance = self.text_queue.get()
|
||||||
|
|
||||||
if isinstance(utterance, int):
|
if isinstance(utterance, int):
|
||||||
if utterance == -1:
|
if utterance == -1:
|
||||||
@@ -209,21 +229,7 @@ class driver(speech_driver):
|
|||||||
if not isinstance(utterance["rate"], str):
|
if not isinstance(utterance["rate"], str):
|
||||||
utterance["rate"] = ""
|
utterance["rate"] = ""
|
||||||
|
|
||||||
popen_speech_command = shlex.split(self.speechCommand)
|
popen_speech_command = self._build_speech_command(utterance)
|
||||||
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
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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])
|
||||||
@@ -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)
|
||||||
@@ -99,7 +99,7 @@ def test_litetalk_driver_writes_settings_and_cancel(serial_pair):
|
|||||||
speech_driver.set_pitch(0.0)
|
speech_driver.set_pitch(0.0)
|
||||||
speech_driver.set_volume(0.5)
|
speech_driver.set_volume(0.5)
|
||||||
speech_driver.cancel()
|
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:
|
finally:
|
||||||
speech_driver.shutdown()
|
speech_driver.shutdown()
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ def test_pty_stdin_input_interrupts_output_when_all_keys_interrupt_enabled():
|
|||||||
"runtime": {
|
"runtime": {
|
||||||
"SettingsManager": settings_manager,
|
"SettingsManager": settings_manager,
|
||||||
"OutputManager": output_manager,
|
"OutputManager": output_manager,
|
||||||
|
"DebugManager": Mock(write_debug_out=Mock()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +255,7 @@ def test_pty_stdin_input_honors_interrupt_disabled():
|
|||||||
"runtime": {
|
"runtime": {
|
||||||
"SettingsManager": settings_manager,
|
"SettingsManager": settings_manager,
|
||||||
"OutputManager": output_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": {
|
"runtime": {
|
||||||
"SettingsManager": settings_manager,
|
"SettingsManager": settings_manager,
|
||||||
"OutputManager": output_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()
|
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
|
@pytest.mark.unit
|
||||||
def test_pty_backspace_with_fenrir_key_synthesizes_shortcut_events():
|
def test_pty_backspace_with_fenrir_key_synthesizes_shortcut_events():
|
||||||
pty_driver = PtyDriver()
|
pty_driver = PtyDriver()
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user