23 Commits

Author SHA1 Message Date
Storm Dragon 2e10c1c43b Version bump for new release. 2026-01-28 16:41:44 -05:00
Storm Dragon 1e67876883 RC1 for next release. 2026-01-11 23:19:12 -05:00
Storm Dragon 14cf6b6088 Clarify other OS than Linux support. 2026-01-11 23:15:28 -05:00
Storm Dragon 900a027643 Merge branch 'testing' 2026-01-10 23:12:13 -05:00
Storm Dragon 0e50175463 Removed sound for echo switching. 2026-01-10 23:11:53 -05:00
Storm Dragon 7283f04778 Merge branch 'testing' 2026-01-10 23:03:50 -05:00
Storm Dragon 8d3495f74f Character echo settings toggle key added. Keyboard files updated. 2026-01-10 23:03:22 -05:00
Storm Dragon a6cd47dafc latest code. 2026-01-10 21:55:25 -05:00
Storm Dragon 0bb2e52deb Fixed fluttery caps speech shifts. 2026-01-10 20:49:22 -05:00
Storm Dragon b8eb815a86 Merged latest from testing. 2026-01-08 16:16:20 -05:00
Storm Dragon beca468338 Finally! Fixed bug that was causing interruption when prompt comes back. 2026-01-08 12:37:55 -05:00
Storm Dragon a26fe26c8c Log level now set to 0 by default so there's no longer a ton of log files created that aren't normally needed. 2026-01-05 08:32:07 -05:00
Storm Dragon 508fd11610 Redesigned the flood protection for incoming text, should hopefully be much better. 2026-01-04 00:33:06 -05:00
Storm Dragon afe0e71a1d A tiny bug fix in prompt checker. 2026-01-04 00:05:52 -05:00
Storm Dragon 9e8d0b3869 Latest changes. 2025-12-30 04:10:52 -05:00
Storm Dragon d7f86ca0de Setting added to choose caps notification type, beep, pitch, both or none. 2025-12-30 04:09:25 -05:00
Storm Dragon 49a79d2722 Removed promoted text option. It wasn't all that useful, and the new speech restore performs the same thing more affectively. 2025-12-28 19:07:54 -05:00
Storm Dragon 4ab024d115 Mostly progress bar fixes. 2025-12-22 12:51:15 -05:00
Storm Dragon c4ae27a01b More progress bar tweaks. 2025-12-20 06:59:19 -05:00
Storm Dragon 668d39b444 Merged for wider testing. 2025-12-19 12:56:33 -05:00
Storm Dragon 8b25afbf5a More progress bar updates. 2025-12-19 12:55:17 -05:00
Storm Dragon efeb040f75 Spelling error and case fixes. Everything seems to work so far. 2025-12-19 03:46:18 -05:00
Storm Dragon 7a17b36d50 More update work on readme and settings. 2025-12-19 03:07:46 -05:00
40 changed files with 583 additions and 369 deletions
+41 -38
View File
@@ -1,7 +1,6 @@
# Fenrir
A modern, modular, flexible and fast console screen reader.
It should run on any operating system. If you want to help, or write drivers to make it work on other systems, just let me know.
A modern, modular, flexible and fast console screen reader for Linux.
This software is licensed under the LGPL v3.
**Current maintainer:** Storm Dragon
@@ -24,12 +23,16 @@ This software is licensed under the LGPL v3.
- **Tutorial Mode**: Built-in help system for learning keyboard shortcuts
## OS Requirements
## Platform Support
- Linux (ptyDriver, vcsaDriver, evdevDriver) - Primary platform with full support
- macOS (ptyDriver) - Limited support
- BSD (ptyDriver) - Limited support
- Windows (ptyDriver) - Limited support
Fenrir is a Linux screen reader. Linux is the only officially supported platform.
**Other platforms (macOS, BSD, Windows):** Pull requests adding support for other operating systems may be accepted provided they do not break Linux functionality. However, no special care will be taken to preserve functionality on secondary platforms. If changes to Fenrir break support on a non-Linux OS, it is the responsibility of third-party contributors to submit fixes.
- Linux (ptyDriver, vcsaDriver, evdevDriver) - Full support
- macOS (ptyDriver) - Community-maintained, no guarantees
- BSD (ptyDriver) - Community-maintained, no guarantees
- Windows (ptyDriver) - Community-maintained, no guarantees
## Core Requirements
@@ -222,7 +225,7 @@ Fenrir supports two main keyboard layouts:
Configure in `/etc/fenrir/settings/settings.conf`:
```ini
[keyboard]
keyboardLayout=desktop # or 'laptop'
keyboard_layout=desktop # or 'laptop'
```
### First Time Setup
@@ -255,9 +258,9 @@ Enable remote control in `/etc/fenrir/settings/settings.conf`:
enable=True
driver=unixDriver # or tcpDriver
port=22447 # for TCP driver
socketFile= # custom socket path (optional)
enableSettingsRemote=True # allow settings changes
enableCommandRemote=True # allow command execution
socket_file= # custom socket path (optional)
enable_settings_remote=True # allow settings changes
enable_command_remote=True # allow command execution
```
### Remote Drivers
@@ -299,8 +302,8 @@ echo "setting set speech#pitch=0.6" | socat - UNIX-CLIENT:/tmp/fenrirscreenreade
echo "setting set speech#volume=0.9" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
# Change punctuation level (none/some/most/all)
echo "setting set general#punctuationLevel=all" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
echo "setting set general#punctuationLevel=none" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
echo "setting set general#punctuation_level=all" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
echo "setting set general#punctuation_level=none" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
# Voice and TTS engine control
echo "setting set speech#voice=en-us+f3" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
@@ -311,14 +314,14 @@ echo "setting set sound#enabled=False" | socat - UNIX-CLIENT:/tmp/fenrirscreenre
echo "setting set sound#volume=0.5" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
# Keyboard and input settings
echo "setting set keyboard#charEchoMode=1" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
echo "setting set keyboard#wordEcho=True" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
echo "setting set keyboard#char_echo_mode=1" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
echo "setting set keyboard#word_echo=True" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
# Screen control (ignore specific TTYs)
echo "setting set screen#ignoreScreen=1,2,3" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
echo "setting set screen#ignore_screen=1,2,3" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
# Multiple settings at once
echo "setting set speech#rate=0.8;sound#volume=0.7;general#punctuationLevel=most" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
echo "setting set speech#rate=0.8;sound#volume=0.7;general#punctuation_level=most" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
# Reset all settings to defaults
echo "setting reset" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
@@ -421,7 +424,7 @@ setting <action> [parameters]
- `speech#voice=voice_name` - Voice selection (e.g., "en-us+f3")
- `speech#module=module_name` - TTS module (e.g., "espeak-ng")
- `speech#driver=driver_name` - Speech driver (speechdDriver/genericDriver)
- `speech#autoReadIncoming=True/False` - Auto-read new text
- `speech#auto_read_incoming=True/False` - Auto-read new text
*Sound Settings:*
- `sound#enabled=True/False` - Enable/disable sound
@@ -430,32 +433,32 @@ setting <action> [parameters]
- `sound#theme=theme_name` - Sound theme
*General Settings:*
- `general#punctuationLevel=none/some/most/all` - Punctuation verbosity
- `general#debugLevel=0-3` - Debug level
- `general#punctuation_level=none/some/most/all` - Punctuation verbosity
- `general#debug_level=0-3` - Debug level
- `general#emoticons=True/False` - Enable emoticon replacement
- `general#autoSpellCheck=True/False` - Automatic spell checking
- `general#auto_spell_check=True/False` - Automatic spell checking
*Focus Settings:*
- `focus#cursor=True/False` - Follow text cursor
- `focus#highlight=True/False` - Follow text highlighting
*Keyboard Settings:*
- `keyboard#charEchoMode=0-2` - Character echo (0=none, 1=always, 2=capslock only)
- `keyboard#wordEcho=True/False` - Echo complete words
- `keyboard#charDeleteEcho=True/False` - Echo deleted characters
- `keyboard#interruptOnKeyPress=True/False` - Interrupt speech on key press
- `keyboard#char_echo_mode=0-2` - Character echo (0=none, 1=always, 2=capslock only)
- `keyboard#word_echo=True/False` - Echo complete words
- `keyboard#char_delete_echo=True/False` - Echo deleted characters
- `keyboard#interrupt_on_key_press=True/False` - Interrupt speech on key press
*Screen Settings:*
- `screen#ignoreScreen=1,2,3` - TTY screens to ignore
- `screen#autodetectIgnoreScreen=True/False` - Auto-detect screens to ignore
- `screen#screenUpdateDelay=float` - Screen update delay
- `screen#ignore_screen=1,2,3` - TTY screens to ignore
- `screen#autodetect_ignore_screen=True/False` - Auto-detect screens to ignore
- `screen#screen_update_delay=float` - Screen update delay
*Time Settings:*
- `time#enabled=True/False` - Enable time announcements
- `time#presentTime=True/False` - Announce time
- `time#presentDate=True/False` - Announce date changes
- `time#delaySec=seconds` - Announcement interval
- `time#onMinutes=00,30` - Specific minutes to announce
- `time#present_time=True/False` - Announce time
- `time#present_date=True/False` - Announce date changes
- `time#delay_sec=seconds` - Announcement interval
- `time#on_minutes=00,30` - Specific minutes to announce
## Table Navigation
@@ -623,7 +626,7 @@ rsync -av source/ destination/ # File synchronization
### Customization
Progress monitoring can be configured through settings:
- **Default enabled**: Set `progressMonitoring=True` in sound section
- **Default enabled**: Set `progress_monitoring=True` in sound section
- **Sound integration**: Works with all sound drivers (sox, gstreamer)
- **Remote control**: Enable/disable through remote commands
@@ -677,8 +680,8 @@ send_fenrir_command("setting set speech#rate=0.9")
- TCP driver binds only to localhost (127.0.0.1)
- Socket file permissions are set to write-only (0o222)
- Commands are processed with Fenrir's privileges
- Settings changes can be disabled via `enableSettingsRemote=False`
- Command execution can be disabled via `enableCommandRemote=False`
- Settings changes can be disabled via `enable_settings_remote=False`
- Command execution can be disabled via `enable_command_remote=False`
### Troubleshooting
@@ -688,7 +691,7 @@ send_fenrir_command("setting set speech#rate=0.9")
- Ensure remote driver is enabled in settings
**Commands not working:**
- Verify `enableCommandRemote=True` in settings
- Verify `enable_command_remote=True` in settings
- Check Fenrir debug logs: `/var/log/fenrir.log`
- Test with simple command: `echo "command interrupt" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock`
@@ -710,7 +713,7 @@ fenrir [OPTIONS]
- `-p, --print` - Print debug messages to screen
- `-e, --emulated-pty` - Use PTY emulation with escape sequences for input (enables desktop/X/Wayland usage)
- `-E, --emulated-evdev` - Use PTY emulation with evdev for input (single instance)
- `-F, --force-all-screens` - Force Fenrir to respond on all screens, ignoring ignoreScreen setting
- `-F, --force-all-screens` - Force Fenrir to respond on all screens, ignoring ignore_screen setting
- `-i, -I, --ignore-screen SCREEN` - Ignore specific screen(s). Can be used multiple times. Combines with existing ignore settings.
### Examples:
@@ -724,7 +727,7 @@ sudo fenrir -e
# Override settings via command line
sudo fenrir -o "speech#rate=0.8;sound#volume=0.5"
# Force Fenrir to work on all screens (ignore ignoreScreen setting)
# Force Fenrir to work on all screens (ignore ignore_screen setting)
sudo fenrir -F
# Ignore specific screens
+1
View File
@@ -83,6 +83,7 @@ KEY_FENRIR,KEY_CTRL,KEY_P=toggle_punctuation_level
KEY_FENRIR,KEY_RIGHTBRACE=toggle_auto_spell_check
KEY_FENRIR,KEY_BACKSLASH=toggle_output
KEY_FENRIR,KEY_CTRL,KEY_E=toggle_emoticons
KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_E=cycle_key_echo
key_FENRIR,KEY_KPENTER=toggle_auto_read
KEY_FENRIR,KEY_CTRL,KEY_T=toggle_auto_time
KEY_FENRIR,KEY_KPASTERISK=toggle_highlight_tracking
+1
View File
@@ -81,6 +81,7 @@ KEY_FENRIR,KEY_SHIFT,KEY_CTRL,KEY_P=toggle_punctuation_level
KEY_FENRIR,KEY_RIGHTBRACE=toggle_auto_spell_check
KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_ENTER=toggle_output
KEY_FENRIR,KEY_SHIFT,KEY_E=toggle_emoticons
KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_E=cycle_key_echo
KEY_FENRIR,KEY_CTRL,KEY_T=toggle_auto_time
KEY_FENRIR,KEY_Y=toggle_highlight_tracking
#=toggle_barrier
+31 -14
View File
@@ -3,14 +3,13 @@
enabled=True
# Select the driver used to play sounds, choices are genericDriver and gstreamerDriver.
# Generic driver uses fewer dependencies but spawns a process for each sound played including progress bar beeps
# Gstreamer is the default.
driver=gstreamerDriver
#driver=genericDriver
# Sound themes. These are the pack of sounds used for sound alerts.
# Sound packs may be located at /usr/share/sounds
# For system wide availability, or ~/.local/share/fenrirscreenreader/sounds
# For the current user.
theme=default
# Sound volume controls how loud the sounds for your selected soundpack are.
@@ -46,6 +45,12 @@ rate=0.5
pitch=0.5
# Pitch for capital letters
capital_pitch=0.9
# How to indicate capital letters:
# pitch = change speech pitch (uses capital_pitch value)
# beep = play Caps.wav sound icon
# both = play beep AND change pitch
# none = no special indication
capital_indicator=pitch
# Volume controls the loudness of the voice, select from 0, quietest, to 1.0, loudest.
volume=1.0
@@ -70,6 +75,22 @@ auto_read_incoming=True
# Speak individual numbers instead of whole string.
read_numbers_as_digits = False
# Flood control: batch rapid updates instead of speaking each one
# Number of updates within rapid_update_window to trigger batching
rapid_update_threshold=5
# Time window (seconds) for detecting rapid updates
rapid_update_window=0.3
# How often to speak batched content (seconds)
batch_flush_interval=0.5
# Maximum lines to keep when batching (keeps newest, drops oldest)
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
# the following variables are replaced with values
# fenrirText = is the text that should be spoken
@@ -110,8 +131,10 @@ driver=evdevDriver
device=ALL
# gives Fenrir exclusive access to the keyboard and lets it control keystrokes.
grab_devices=True
ignore_shortcuts=False
# Ignore shortcut bindings and pass all keys through without processing Fenrir commands.
# When True, Fenrir will only monitor screen content without intercepting keyboard input.
# the current shortcut layout located in /etc/fenrirscreenreader/keyboard
ignore_shortcuts=False
keyboard_layout=desktop
# echo chars while typing.
# 0 = None
@@ -130,14 +153,17 @@ interrupt_on_key_press_filter=
double_tap_timeout=0.2
[general]
# Debug levels: 0=DEACTIVE, 1=ERROR, 2=WARNING, 3=INFO (most verbose)
# Debug levels: 0=NONE, 1=ERROR, 2=WARNING, 3=INFO (most verbose)
# For production use, WARNING (2) provides good balance of useful info without spam
debug_level=2
# The default is 0, no logging.
debug_level=0
# debugMode sets where the debug output should send to:
# debugMode=File writes to debug_file (Default:/tmp/fenrir-PID.log)
# debugMode=Print just prints on the screen
debug_mode=File
debug_file=
# Punctuation settings control how punctuation is spoken during text review.
# Profile selects a punctuation definition file from config/punctuation/ (e.g., default.conf)
punctuation_profile=default
punctuation_level=some
respect_punctuation_pause=True
@@ -240,15 +266,6 @@ leave_review_on_cursor_change=True
# Exit review mode when switching to a different TTY/screen
leave_review_on_screen_change=True
[promote]
# Enable promoting (announcing) important text updates automatically
enabled=True
# Seconds of inactivity before promoting text updates (prevents spam during active typing)
inactive_timeout_sec=120
# Comma-separated list of text patterns to promote when detected
# Leave empty to disable pattern-based promotion
list=
[menu]
# Custom path for VMenu (virtual menu) profiles
# Leave empty to use default location (/etc/fenrirscreenreader/vmenu-profiles/)
Binary file not shown.
-2
View File
@@ -46,8 +46,6 @@ ErrorSpeech='ErrorSpeech.wav'
ErrorScreen='ErrorScreen.wav'
# If you cursor over an text that has attributs (like color)
HasAttributes='has_attribute.wav'
# fenrir can promote strings if they appear on the screen.
PromotedText='PromotedText.wav'
# missspelled indicator
mispell='mispell.wav'
# the for capital letter
-2
View File
@@ -50,8 +50,6 @@ ErrorBraille=''
ErrorScreen=''
# If you cursor over an text that has attributs (like color)
HasAttributes=''
# fenrir can promote strings if they appear on the screen.
PromotedText=''
# misspelled indicator
mispell=''
# the for capital letter:
-50
View File
@@ -1095,23 +1095,6 @@ announce=True
interrupt=False
....
==== Promoted List
Promoted Lists are a nice feature if you are away from your computer or
performing more longer tasks. you can define a list of words which you
want to hear a sound icon for after a period of inactivity. Example if
the word "Chrys" appears after 120 Seconds of inactivity:
....
[promote]
enabled=True
inactive_timeout_sec=120
list=Chrys
....
See section link:#Promote[Promote] in `+settings.conf+` for more
information.
=== Dictionary
You can make use of different kinds of built-in dictionary's. A
@@ -2049,39 +2032,6 @@ leave_review_on_screen_change=True
Values: on=`+True+`, off=`+False+`
==== Promote
"Promoted Lists" are configured in the section `+[promote]+`. Turn
Promoted Lists" on or off:
....
enabled=True
....
Values: on=`+True+`, off=`+False+`
The minimum time interval of inactivity to activate promoting. By
default it promotes after 120 Seconds inactivity:
....
inactive_timeout_sec=120
....
Values: in Seconds
Define a list of promoted words comma seperated:
....
list=
....
Values: text (comma seperated) Example to promote the word "nickname" or
a bash prompt:
....
list=nickname,$:,#:
....
==== Time
The automated time announcement is configured in the section `+[time]+`.
-26
View File
@@ -729,15 +729,6 @@ Example on fix minutes in an hour. example every quarter "delaySec=0" and "onMin
onMinutes=00,15,30,45
announce=True
interrupt=False
==== Promoted List ====
Promoted Lists are a nice feature if you are away from your computer or performing more longer tasks.
you can define a list of words which you want to hear a sound icon for after a period of inactivity.
Example if the word "Chrys" appears after 120 Seconds of inactivity:
[promote]
enabled=True
inactive_timeout_sec=120
list=Chrys
See section [[#Promote|Promote]] in ''settings.conf'' for more information.
==== Punctuation ====
Fenrir handles punctuation levels and names for you with several provided dictionaries.
@@ -1199,23 +1190,6 @@ Values: on=''True'', off=''False''
Leave the review mode when changing the screen (From TTY3 to TTY4):
leave_review_on_screen_change=True
Values: on=''True'', off=''False''
==== Promote ====
"Promoted Lists" are configured in the section ''[promote]''.
Turn Promoted Lists" on or off:
enabled=True
Values: on=''True'', off=''False''
The minimum time interval of inactivity to activate promoting.
By default it promotes after 120 Seconds inactivity:
inactive_timeout_sec=120
Values: in Seconds
Define a list of promoted words comma seperated:
list=
Values: text (comma seperated)
Example to promote the word "nickname" or a bash prompt:
list=nickname,$:,#:
==== Time ====
The automated time announcement is configured in the section ''[time]''.
Time announcement is disabled by default.
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("Cycle through key echo modes: character, word, off")
def run(self):
settings_manager = self.env["runtime"]["SettingsManager"]
output_manager = self.env["runtime"]["OutputManager"]
# Get current settings
char_echo_mode = settings_manager.get_setting("keyboard", "char_echo_mode")
word_echo = settings_manager.get_setting_as_bool("keyboard", "word_echo")
# Determine current state and cycle to next
# States: character (char=1, word=False) -> word (char=0, word=True) -> off (char=0, word=False)
if char_echo_mode == "1" and not word_echo:
# Currently character echo, switch to word echo
settings_manager.set_setting("keyboard", "char_echo_mode", "0")
settings_manager.set_setting("keyboard", "word_echo", "True")
output_manager.present_text(
_("Echo by word"), interrupt=True
)
elif word_echo:
# Currently word echo, switch to off
settings_manager.set_setting("keyboard", "char_echo_mode", "0")
settings_manager.set_setting("keyboard", "word_echo", "False")
output_manager.present_text(
_("Echo off"), interrupt=True
)
else:
# Currently off (or caps mode), switch to character echo
settings_manager.set_setting("keyboard", "char_echo_mode", "1")
settings_manager.set_setting("keyboard", "word_echo", "False")
output_manager.present_text(
_("Echo by character"), interrupt=True
)
def set_callback(self, callback):
pass
@@ -22,7 +22,7 @@ class command:
return _("sends the following keypress to the terminal or application")
def run(self):
self.env["input"]["keyForeward"] = 3
self.env["input"]["key_forward"] = 3
self.env["runtime"]["OutputManager"].present_text(
_("Forward next keypress"), interrupt=True
)
@@ -22,8 +22,8 @@ class command:
return _("Interrupts the current presentation")
def run(self):
if len(self.env["input"]["prevDeepestInput"]) > len(
self.env["input"]["currInput"]
if len(self.env["input"]["prev_deepest_input"]) > len(
self.env["input"]["curr_input"]
):
return
self.env["runtime"]["OutputManager"].interrupt_output()
@@ -30,8 +30,8 @@ class command:
return
if self.env["runtime"]["ScreenManager"].is_screen_change():
return
if len(self.env["input"]["currInput"]) <= len(
self.env["input"]["prevInput"]
if len(self.env["input"]["curr_input"]) <= len(
self.env["input"]["prev_input"]
):
return
# if the filter is set
@@ -46,8 +46,8 @@ class command:
.get_setting("keyboard", "interrupt_on_key_press_filter")
.split(",")
)
for currInput in self.env["input"]["currInput"]:
if currInput not in filter_list:
for curr_key in self.env["input"]["curr_input"]:
if curr_key not in filter_list:
return
self.env["runtime"]["OutputManager"].interrupt_output()
@@ -31,7 +31,7 @@ class command:
return
# 2 = caps only
if active == 2:
if not self.env["input"]["newCapsLock"]:
if not self.env["input"]["new_caps_lock"]:
return
# big changes are no char (but the value is bigger than one maybe the
# differ needs longer than you can type, so a little strange random
@@ -71,6 +71,13 @@ class command:
self.env["screen"]["new_cursor"]["y"],
self.env["screen"]["new_content_text"],
)
# Don't interrupt ongoing auto-read announcements
do_interrupt = True
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"speech", "auto_read_incoming"
):
do_interrupt = False
if curr_char.isspace():
# Only announce spaces during pure navigation (arrow keys)
# Check if this is really navigation by looking at input history
@@ -87,14 +94,14 @@ class command:
char_utils.present_char_for_review(
self.env,
curr_char,
interrupt=True,
interrupt=do_interrupt,
announce_capital=True,
flush=False,
)
else:
self.env["runtime"]["OutputManager"].present_text(
curr_char,
interrupt=True,
interrupt=do_interrupt,
ignore_punctuation=True,
announce_capital=True,
flush=False,
@@ -152,11 +152,18 @@ class command:
curr_delta = delta_text
if (len(curr_delta.strip()) != len(curr_delta) and curr_delta.strip() != ""):
curr_delta = curr_delta.strip()
# Don't interrupt ongoing auto-read announcements
do_interrupt = True
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"speech", "auto_read_incoming"
):
do_interrupt = False
# Enhanced announcement with better handling of empty completions
if curr_delta:
self.env["runtime"]["OutputManager"].present_text(
curr_delta, interrupt=True, announce_capital=True, flush=False
curr_delta, interrupt=do_interrupt, announce_capital=True, flush=False
)
def set_callback(self, callback):
@@ -66,8 +66,15 @@ class command:
):
return
# Don't interrupt ongoing auto-read announcements
do_interrupt = True
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"speech", "auto_read_incoming"
):
do_interrupt = False
self.env["runtime"]["OutputManager"].present_text(
curr_word, interrupt=True, flush=False
curr_word, interrupt=do_interrupt, flush=False
)
def set_callback(self, callback):
@@ -30,8 +30,8 @@ class command:
if self.env["runtime"]["ScreenManager"].is_screen_change():
self.lastIdent = 0
return
# this leads to problems in vim -> status line change -> no
# announcement, so we do check the lengh as hack
# Don't announce cursor movements when auto-read is handling incoming text
# This prevents interrupting ongoing auto-read announcements
if self.env["runtime"]["ScreenManager"].is_delta():
return
@@ -44,16 +44,22 @@ class command:
self.env["screen"]["new_cursor"]["y"],
self.env["screen"]["new_content_text"],
)
# Don't interrupt ongoing auto-read announcements with cursor movement
do_interrupt = True
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"speech", "auto_read_incoming"
):
do_interrupt = False
if curr_line.isspace():
self.env["runtime"]["OutputManager"].present_text(
_("blank"), sound_icon="EmptyLine", interrupt=True, flush=False
_("blank"), sound_icon="EmptyLine", interrupt=do_interrupt, flush=False
)
else:
# ident
curr_ident = len(curr_line) - len(curr_line.lstrip())
if self.lastIdent == -1:
self.lastIdent = curr_ident
do_interrupt = True
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"general", "auto_present_indent"
):
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import time
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return ""
def run(self):
if not self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"speech", "auto_read_incoming"
):
return
if "pendingPromptText" not in self.env["commandBuffer"]:
return
pending_text = self.env["commandBuffer"]["pendingPromptText"]
if not pending_text:
return
pending_time = self.env["commandBuffer"].get("pendingPromptTime", 0)
delay = self.env["runtime"]["SettingsManager"].get_setting_as_float(
"speech", "batch_flush_interval"
)
if time.time() - pending_time < delay:
return
self.env["runtime"]["OutputManager"].present_text(
pending_text, interrupt=False, flush=False
)
self.env["commandBuffer"]["pendingPromptText"] = ""
def set_callback(self, callback):
pass
@@ -29,8 +29,8 @@ class command:
return
if self.env["runtime"]["ScreenManager"].is_screen_change():
return
if len(self.env["input"]["currInput"]) <= len(
self.env["input"]["prevInput"]
if len(self.env["input"]["curr_input"]) <= len(
self.env["input"]["prev_input"]
):
return
# if the filter is set
@@ -45,8 +45,8 @@ class command:
.get_setting("keyboard", "interrupt_on_key_press_filter")
.split(",")
)
for currInput in self.env["input"]["currInput"]:
if currInput not in filter_list:
for curr_key in self.env["input"]["curr_input"]:
if curr_key not in filter_list:
return
self.env["runtime"]["OutputManager"].interrupt_output()
@@ -24,7 +24,7 @@ class command:
def run(self):
if self.env["runtime"]["InputManager"].no_key_pressed():
return
if len(self.env["input"]["prevInput"]) > 0:
if len(self.env["input"]["prev_input"]) > 0:
return
if not self.env["commandBuffer"]["enableSpeechOnKeypress"]:
return
@@ -23,11 +23,11 @@ class command:
def run(self):
if (
self.env["input"]["oldCapsLock"]
== self.env["input"]["newCapsLock"]
self.env["input"]["old_caps_lock"]
== self.env["input"]["new_caps_lock"]
):
return
if self.env["input"]["newCapsLock"]:
if self.env["input"]["new_caps_lock"]:
self.env["runtime"]["OutputManager"].present_text(
_("Capslock on"), interrupt=True
)
@@ -23,11 +23,11 @@ class command:
def run(self):
if (
self.env["input"]["oldScrollLock"]
== self.env["input"]["newScrollLock"]
self.env["input"]["old_scroll_lock"]
== self.env["input"]["new_scroll_lock"]
):
return
if self.env["input"]["newScrollLock"]:
if self.env["input"]["new_scroll_lock"]:
self.env["runtime"]["OutputManager"].present_text(
_("Scrolllock on"), interrupt=True
)
@@ -22,12 +22,12 @@ class command:
return "No description found"
def run(self):
if self.env["input"]["oldNumLock"] == self.env["input"]["newNumLock"]:
if self.env["input"]["old_num_lock"] == self.env["input"]["new_num_lock"]:
return
# Only announce numlock changes if an actual numlock key was pressed
# AND the LED state actually changed (some numpads send spurious NUMLOCK events)
current_input = self.env["input"]["currInput"]
current_input = self.env["input"]["curr_input"]
# Check if this is a genuine numlock key press by verifying:
# 1. KEY_NUMLOCK is in the current input sequence
@@ -40,7 +40,7 @@ class command:
)
if is_genuine_numlock:
if self.env["input"]["newNumLock"]:
if self.env["input"]["new_num_lock"]:
self.env["runtime"]["OutputManager"].present_text(
_("Numlock on"), interrupt=True
)
@@ -40,13 +40,19 @@ class command:
):
self.detect_progress(self.env["screen"]["new_delta"])
except Exception as e:
# Silently ignore errors to avoid disrupting normal operation
pass
# Log errors for debugging instead of silently ignoring
self.env["runtime"]["DebugManager"].write_debug_out(
"Progress detector error: " + str(e),
debug.DebugLevel.ERROR,
)
def is_real_progress_update(self):
"""Check if this is a real progress update vs screen change/window switch"""
# If the screen/application changed, it's not a progress update
if self.env["runtime"]["ScreenManager"].is_screen_change():
self.env["runtime"]["DebugManager"].write_debug_out(
"Progress filter: screen change detected", debug.DebugLevel.INFO
)
return False
# If there was a large cursor movement, it's likely navigation, not
@@ -62,6 +68,10 @@ class command:
)
# Large movements suggest navigation, not progress output
if y_move > 2 or x_move > 20:
self.env["runtime"]["DebugManager"].write_debug_out(
f"Progress filter: large cursor move y={y_move} x={x_move}",
debug.DebugLevel.INFO,
)
return False
# Check if delta is too large (screen change) vs small incremental
@@ -71,16 +81,27 @@ class command:
if (
delta_length > 200
): # Allow longer progress lines like Claude Code's status
self.env["runtime"]["DebugManager"].write_debug_out(
f"Progress filter: delta too long ({delta_length})",
debug.DebugLevel.INFO,
)
return False
# If delta contains newlines and is substantial, let incoming handler
# deal with it to avoid interfering with multi-line text output
if '\n' in delta_text and delta_length > 50:
self.env["runtime"]["DebugManager"].write_debug_out(
f"Progress filter: multiline delta ({delta_length} chars)",
debug.DebugLevel.INFO,
)
return False
# Check if current line looks like a prompt - progress unlikely during
# prompts
if self.is_current_line_prompt():
self.env["runtime"]["DebugManager"].write_debug_out(
"Progress filter: prompt detected", debug.DebugLevel.INFO
)
return False
return True
@@ -284,14 +305,61 @@ class command:
self.env["commandBuffer"]["lastProgressTime"] = current_time
return
# Pattern 6: Claude/Codex working indicators (bullets + "esc to interrupt")
# Pattern 6: Claude Code working indicators (various symbols + activity text + "esc/ctrl+c to interrupt")
# Matches any: [symbol] [Task description]… (... to interrupt ...)
# Symbols include: * ✢ ✽ ✶ ✻ · • ◦ ○ ● ◆ and similar decorative characters
# Example: ✽ Reviewing script for issues… (ctrl+c to interrupt · 33s · ↑ 1.6k tokens · thought for 4s)
claude_progress_match = re.search(
r'^[\s]*[•◦][^\n]*\(\s*(?:\d+m\s+)?\d+s?\s+•\s+esc to interrupt[^)]*\)',
r'[*✢✽✶✻·•◦○●◆]\s+\w+.*?…\s*\(.*(?:esc|ctrl\+c) to interrupt.*\)',
text,
re.IGNORECASE,
)
if claude_progress_match:
if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0:
self.env["runtime"]["DebugManager"].write_debug_out(
"Playing Claude Code activity beep",
debug.DebugLevel.INFO,
)
self.play_activity_beep()
self.env["commandBuffer"]["lastProgressTime"] = current_time
return
# Pattern 6b: Claude Code tool invocation indicators (● Tool Name(...))
# Example: ● Web Search("query here")
tool_invocation_match = re.search(
r'[●○◉•◦]\s+(?:Web\s*Search|Read|Write|Edit|Bash|Glob|Grep|Task|WebFetch)\s*\(',
text,
re.IGNORECASE,
)
if tool_invocation_match:
if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0:
self.env["runtime"]["DebugManager"].write_debug_out(
"Playing Claude Code tool invocation beep",
debug.DebugLevel.INFO,
)
self.play_activity_beep()
self.env["commandBuffer"]["lastProgressTime"] = current_time
return
# Pattern 6c: Bullet/white bullet activity lines (•/◦ ...)
bullet_activity_match = re.search(
(
r'^\s*[•◦]\s+.*(?:…|\.{3,}|\b(?:thinking|working|processing|'
r'analyzing|searching|reading|writing|planning|running|'
r'executing|updating|building|installing|compiling|downloading|'
r'reviewing|generating|responding|applying|fixing|editing|'
r'creating|preparing|checking|opening|loading|fetching|'
r'retrieving|scanning|indexing|summarizing)\b)'
),
text,
re.IGNORECASE,
)
if bullet_activity_match:
if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0:
self.env["runtime"]["DebugManager"].write_debug_out(
"Playing bullet activity beep",
debug.DebugLevel.INFO,
)
self.play_activity_beep()
self.env["commandBuffer"]["lastProgressTime"] = current_time
return
@@ -331,6 +399,23 @@ class command:
self.env["commandBuffer"]["lastProgressTime"] = current_time
return
# Pattern 10: Task status indicators (● Task Output, ○ Task Running, etc.)
# Matches bullet points with task-related status text
task_status_match = re.search(
r'[●○◉]\s+(?:Task\s+)?(?:Output|Running|Pending|Working|Processing)\s+[a-zA-Z0-9]+',
text,
re.IGNORECASE,
)
if task_status_match:
if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.5:
self.env["runtime"]["DebugManager"].write_debug_out(
"Playing task status activity beep",
debug.DebugLevel.INFO,
)
self.play_activity_beep()
self.env["commandBuffer"]["lastProgressTime"] = current_time
return
def play_progress_tone(self, percentage):
# Map 0-100% to 400-1200Hz frequency range
frequency = 400 + (percentage * 8)
@@ -158,6 +158,16 @@ class command:
def _restore_speech(self):
"""Helper method to restore speech when prompt is detected"""
# If speech is already enabled, just clear flags to avoid unnecessary
# interrupts on prompt return
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"speech", "enabled"
):
self.env["commandBuffer"]["silenceUntilPrompt"] = False
if "enableSpeechOnKeypress" in self.env["commandBuffer"]:
self.env["commandBuffer"]["enableSpeechOnKeypress"] = False
return
# Disable silence mode
self.env["commandBuffer"]["silenceUntilPrompt"] = False
# Also disable the keypress-based speech restoration since we're
@@ -10,7 +10,11 @@ from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
self._update_times = []
self._line_count_times = []
self._batched_text = []
self._last_flush_time = 0
self._in_flood_mode = False
def initialize(self, environment):
self.env = environment
@@ -21,6 +25,73 @@ class command:
def get_description(self):
return _("Announces incoming text changes")
def _reset_flood_state(self):
self._update_times = []
self._line_count_times = []
self._batched_text = []
self._last_flush_time = 0
self._in_flood_mode = False
def _is_rapid_updates(self):
current_time = time.time()
window = self.env["runtime"]["SettingsManager"].get_setting_as_float(
"speech", "rapid_update_window"
)
threshold = self.env["runtime"]["SettingsManager"].get_setting_as_int(
"speech", "rapid_update_threshold"
)
self._update_times = [
ts for ts in self._update_times if current_time - ts < window
]
self._update_times.append(current_time)
return len(self._update_times) >= threshold
def _is_high_volume(self, delta_text):
current_time = time.time()
window = self.env["runtime"]["SettingsManager"].get_setting_as_float(
"speech", "rapid_update_window"
)
threshold = self.env["runtime"]["SettingsManager"].get_setting_as_int(
"speech", "flood_line_threshold"
)
line_count = max(1, delta_text.count("\n") + 1)
self._line_count_times = [
(ts, count)
for ts, count in self._line_count_times
if current_time - ts < window
]
self._line_count_times.append((current_time, line_count))
total_lines = sum(count for _, count in self._line_count_times)
return total_lines >= threshold
def _add_to_batch(self, text):
new_lines = text.splitlines()
if text.endswith("\n"):
new_lines.append("")
self._batched_text.extend(new_lines)
max_lines = self.env["runtime"]["SettingsManager"].get_setting_as_int(
"speech", "max_batch_lines"
)
if len(self._batched_text) > max_lines:
self._batched_text = self._batched_text[-max_lines:]
def _flush_batch(self):
if not self._batched_text:
return
text = "\n".join(self._batched_text)
self._batched_text = []
self._last_flush_time = time.time()
self.env["runtime"]["OutputManager"].present_text(
text, interrupt=False, flush=False
)
def _was_handled_by_tab_completion(self, delta_text):
"""Check if this delta was already handled by tab completion to avoid duplicates"""
if "tabCompletion" not in self.env["commandBuffer"]:
@@ -50,6 +121,9 @@ class command:
return
delta_text = self.env["screen"]["new_delta"]
if self.env["runtime"]["ScreenManager"].is_screen_change():
self._reset_flood_state()
# Skip if tab completion already handled this delta
if self._was_handled_by_tab_completion(delta_text):
@@ -71,6 +145,29 @@ class command:
# <= 2:
if "\n" not in delta_text:
return
rapid = self._is_rapid_updates()
high_volume = self._is_high_volume(delta_text)
if (rapid and high_volume) or self._in_flood_mode:
if not self._in_flood_mode:
self._last_flush_time = time.time()
self._in_flood_mode = True
self._add_to_batch(delta_text)
interval = self.env["runtime"][
"SettingsManager"
].get_setting_as_float("speech", "batch_flush_interval")
if time.time() - self._last_flush_time >= interval:
self._flush_batch()
if not rapid or not high_volume:
if self._batched_text:
self._flush_batch()
self._in_flood_mode = False
return
# print(x_move, y_move, len(self.env['screen']['new_delta']), len(self.env['screen']['newNegativeDelta']))
self.env["runtime"]["OutputManager"].present_text(
delta_text, interrupt=False, flush=False
@@ -1,66 +0,0 @@
#!/usr/bin/env python3
import time
from fenrirscreenreader.core.i18n import _
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return "No Description found"
def run(self):
if not self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"promote", "enabled"
):
return
if (
self.env["runtime"]["SettingsManager"]
.get_setting("promote", "list")
.strip(" \t\n")
== ""
):
return
if int(time.time() - self.env["input"]["lastInputTime"]) < self.env[
"runtime"
]["SettingsManager"].get_setting_as_int(
"promote", "inactive_timeout_sec"
):
return
if (
len(
self.env["runtime"]["SettingsManager"].get_setting(
"promote", "list"
)
)
== 0
):
return
for promote in (
self.env["runtime"]["SettingsManager"]
.get_setting("promote", "list")
.split(",")
):
if promote in self.env["screen"]["new_delta"]:
self.env["runtime"]["OutputManager"].play_sound_icon(
"PromotedText"
)
self.env["input"]["lastInputTime"] = time.time()
return
def set_callback(self, callback):
pass
+1 -1
View File
@@ -21,7 +21,7 @@ class CursorManager:
Return False if numlock is ON (let keys type numbers)
"""
# Return False if numlock is ON
return not self.env["input"]["newNumLock"]
return not self.env["input"]["new_num_lock"]
def shutdown(self):
pass
+2 -21
View File
@@ -17,8 +17,7 @@ from fenrirscreenreader.core.eventData import FenrirEventType
class EventManager:
def __init__(self):
self.running = Value(c_bool, True)
# Bounded queue to prevent memory exhaustion
self._eventQueue = Queue(maxsize=100)
self._eventQueue = Queue()
self.clean_event_queue()
def initialize(self, environment):
@@ -107,23 +106,5 @@ class EventManager:
return False
if event == FenrirEventType.ignore:
return False
# Use bounded queue - if full, this will block briefly or drop older
# events
try:
self._eventQueue.put({"Type": event, "data": data}, timeout=0.1)
except Exception as e:
# Queue full - drop oldest event and add new one for critical
# events
if event in [
FenrirEventType.screen_update,
FenrirEventType.keyboard_input,
]:
try:
self._eventQueue.get_nowait() # Remove oldest
self._eventQueue.put(
{"Type": event, "data": data}, timeout=0.1
)
except BaseException:
pass # If still can't add, drop the event
# For non-critical events, just drop them if queue is full
self._eventQueue.put({"Type": event, "data": data})
return True
+8 -8
View File
@@ -69,9 +69,9 @@ class FenrirManager:
].get_input_event()
if event["data"]:
event["data"]["EventName"] = self.environment["runtime"][
event["data"]["event_name"] = self.environment["runtime"][
"InputManager"
].convert_event_name(event["data"]["EventName"])
].convert_event_name(event["data"]["event_name"])
self.environment["runtime"]["InputManager"].handle_input_event(
event["data"]
)
@@ -121,8 +121,8 @@ class FenrirManager:
self.environment["runtime"]["InputManager"].write_event_buffer()
self.environment["runtime"]["InputManager"].handle_device_grab()
if self.environment["input"]["keyForeward"] > 0:
self.environment["input"]["keyForeward"] -= 1
if self.environment["input"]["key_forward"] > 0:
self.environment["input"]["key_forward"] -= 1
self.environment["runtime"]["CommandManager"].execute_default_trigger(
"onKeyInput"
@@ -265,11 +265,11 @@ class FenrirManager:
)
def detect_shortcut_command(self):
if self.environment["input"]["keyForeward"] > 0:
if self.environment["input"]["key_forward"] > 0:
return
if len(self.environment["input"]["prevInput"]) > len(
self.environment["input"]["currInput"]
if len(self.environment["input"]["prev_input"]) > len(
self.environment["input"]["curr_input"]
):
return
@@ -283,7 +283,7 @@ class FenrirManager:
].no_key_pressed():
if self.singleKeyCommand:
self.singleKeyCommand = (
len(self.environment["input"]["currInput"]) == 1
len(self.environment["input"]["curr_input"]) == 1
)
if not (
+20 -18
View File
@@ -9,28 +9,30 @@ import time
from fenrirscreenreader.core import debug
input_data = {
"currInput": [],
"prevDeepestInput": [],
"eventBuffer": [],
"curr_input": [],
"prev_input": [],
"prev_deepest_input": [],
"event_buffer": [],
"shortcut_repeat": 0,
"fenrirKey": [],
"scriptKey": [],
"keyForeward": 0,
"lastInputTime": time.time(),
"oldNumLock": True,
"newNumLock": True,
"oldScrollLock": True,
"newScrollLock": True,
"oldCapsLock": False,
"newCapsLock": False,
"fenrir_key": [],
"script_key": [],
"key_forward": 0,
"last_input_time": time.time(),
"old_num_lock": True,
"new_num_lock": True,
"old_scroll_lock": True,
"new_scroll_lock": True,
"old_caps_lock": False,
"new_caps_lock": False,
}
input_event = {
"EventName": "",
"EventValue": "",
"EventSec": 0,
"EventUsec": 0,
"EventState": 0,
"event_name": "",
"event_value": "",
"event_sec": 0,
"event_usec": 0,
"event_state": 0,
"event_type": 0,
}
key_names = [
+1 -1
View File
@@ -30,7 +30,7 @@ class InputDriver:
def clear_event_buffer(self):
if not self._initialized:
return
del self.env["input"]["eventBuffer"][:]
del self.env["input"]["event_buffer"][:]
def update_input_devices(self, new_devices=None, init=False):
if not self._initialized:
+54 -54
View File
@@ -43,18 +43,18 @@ class InputManager:
self.update_input_devices()
# init LEDs with current state
self.env["input"]["newNumLock"] = self.env["runtime"][
self.env["input"]["new_num_lock"] = self.env["runtime"][
"InputDriver"
].get_led_state()
self.env["input"]["oldNumLock"] = self.env["input"]["newNumLock"]
self.env["input"]["newCapsLock"] = self.env["runtime"][
self.env["input"]["old_num_lock"] = self.env["input"]["new_num_lock"]
self.env["input"]["new_caps_lock"] = self.env["runtime"][
"InputDriver"
].get_led_state(1)
self.env["input"]["oldCapsLock"] = self.env["input"]["newCapsLock"]
self.env["input"]["newScrollLock"] = self.env["runtime"][
self.env["input"]["old_caps_lock"] = self.env["input"]["new_caps_lock"]
self.env["input"]["new_scroll_lock"] = self.env["runtime"][
"InputDriver"
].get_led_state(2)
self.env["input"]["oldScrollLock"] = self.env["input"]["newScrollLock"]
self.env["input"]["old_scroll_lock"] = self.env["input"]["new_scroll_lock"]
self.lastDeepestInput = []
self.lastEvent = None
self.env["input"]["shortcut_repeat"] = 1
@@ -84,7 +84,7 @@ class InputManager:
self.set_execute_device_grab()
if not self.executeDeviceGrab:
return
if self.env["input"]["eventBuffer"] != []:
if self.env["input"]["event_buffer"] != []:
return
if not self.no_key_pressed():
return
@@ -176,36 +176,36 @@ class InputManager:
return
self.lastEvent = event_data
# a hang apears.. try to fix
if self.env["input"]["eventBuffer"] == []:
if self.env["input"]["currInput"] != []:
self.env["input"]["currInput"] = []
if self.env["input"]["event_buffer"] == []:
if self.env["input"]["curr_input"] != []:
self.env["input"]["curr_input"] = []
self.env["input"]["shortcut_repeat"] = 1
self.env["input"]["prevInput"] = self.env["input"]["currInput"].copy()
if event_data["EventState"] == 0:
if event_data["EventName"] in self.env["input"]["currInput"]:
self.env["input"]["currInput"].remove(event_data["EventName"])
if len(self.env["input"]["currInput"]) > 1:
self.env["input"]["currInput"] = sorted(
self.env["input"]["currInput"]
self.env["input"]["prev_input"] = self.env["input"]["curr_input"].copy()
if event_data["event_state"] == 0:
if event_data["event_name"] in self.env["input"]["curr_input"]:
self.env["input"]["curr_input"].remove(event_data["event_name"])
if len(self.env["input"]["curr_input"]) > 1:
self.env["input"]["curr_input"] = sorted(
self.env["input"]["curr_input"]
)
elif len(self.env["input"]["currInput"]) == 0:
elif len(self.env["input"]["curr_input"]) == 0:
self.env["input"]["shortcut_repeat"] = 1
self.lastInputTime = time.time()
elif event_data["EventState"] == 1:
if not event_data["EventName"] in self.env["input"]["currInput"]:
self.env["input"]["currInput"].append(event_data["EventName"])
if len(self.env["input"]["currInput"]) > 1:
self.env["input"]["currInput"] = sorted(
self.env["input"]["currInput"]
elif event_data["event_state"] == 1:
if not event_data["event_name"] in self.env["input"]["curr_input"]:
self.env["input"]["curr_input"].append(event_data["event_name"])
if len(self.env["input"]["curr_input"]) > 1:
self.env["input"]["curr_input"] = sorted(
self.env["input"]["curr_input"]
)
if len(self.lastDeepestInput) < len(
self.env["input"]["currInput"]
self.env["input"]["curr_input"]
):
self.set_last_deepest_input(
self.env["input"]["currInput"].copy()
self.env["input"]["curr_input"].copy()
)
elif self.lastDeepestInput == self.env["input"]["currInput"]:
elif self.lastDeepestInput == self.env["input"]["curr_input"]:
if time.time() - self.lastInputTime <= self.env["runtime"][
"SettingsManager"
].get_setting_as_float("keyboard", "double_tap_timeout"):
@@ -214,37 +214,37 @@ class InputManager:
self.env["input"]["shortcut_repeat"] = 1
self.handle_led_states(event_data)
self.lastInputTime = time.time()
elif event_data["EventState"] == 2:
elif event_data["event_state"] == 2:
self.lastInputTime = time.time()
self.env["input"]["oldNumLock"] = self.env["input"]["newNumLock"]
self.env["input"]["newNumLock"] = self.env["runtime"][
self.env["input"]["old_num_lock"] = self.env["input"]["new_num_lock"]
self.env["input"]["new_num_lock"] = self.env["runtime"][
"InputDriver"
].get_led_state()
self.env["input"]["oldCapsLock"] = self.env["input"]["newCapsLock"]
self.env["input"]["newCapsLock"] = self.env["runtime"][
self.env["input"]["old_caps_lock"] = self.env["input"]["new_caps_lock"]
self.env["input"]["new_caps_lock"] = self.env["runtime"][
"InputDriver"
].get_led_state(1)
self.env["input"]["oldScrollLock"] = self.env["input"]["newScrollLock"]
self.env["input"]["newScrollLock"] = self.env["runtime"][
self.env["input"]["old_scroll_lock"] = self.env["input"]["new_scroll_lock"]
self.env["input"]["new_scroll_lock"] = self.env["runtime"][
"InputDriver"
].get_led_state(2)
self.env["runtime"]["DebugManager"].write_debug_out(
"currInput " + str(self.env["input"]["currInput"]),
"curr_input " + str(self.env["input"]["curr_input"]),
debug.DebugLevel.INFO,
)
if self.no_key_pressed():
self.env["input"]["prevInput"] = []
self.env["input"]["prev_input"] = []
def handle_led_states(self, m_event):
if self.curr_key_is_modifier():
return
try:
if m_event["EventName"] == "KEY_NUMLOCK":
if m_event["event_name"] == "KEY_NUMLOCK":
self.env["runtime"]["InputDriver"].toggle_led_state()
elif m_event["EventName"] == "KEY_CAPSLOCK":
elif m_event["event_name"] == "KEY_CAPSLOCK":
self.env["runtime"]["InputDriver"].toggle_led_state(1)
elif m_event["EventName"] == "KEY_SCROLLLOCK":
elif m_event["event_name"] == "KEY_SCROLLLOCK":
self.env["runtime"]["InputDriver"].toggle_led_state(2)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
@@ -368,11 +368,11 @@ class InputManager:
)
def no_key_pressed(self):
return self.env["input"]["currInput"] == []
return self.env["input"]["curr_input"] == []
def is_key_press(self):
return (self.env["input"]["prevInput"] == []) and (
self.env["input"]["currInput"] != []
return (self.env["input"]["prev_input"] == []) and (
self.env["input"]["curr_input"] != []
)
def get_prev_deepest_shortcut(self):
@@ -384,7 +384,7 @@ class InputManager:
def get_prev_shortcut(self):
shortcut = []
shortcut.append(self.env["input"]["shortcut_repeat"])
shortcut.append(self.env["input"]["prevInput"])
shortcut.append(self.env["input"]["prev_input"])
return str(shortcut)
def get_curr_shortcut(self, inputSequence=None):
@@ -430,16 +430,16 @@ class InputManager:
if not self.env["runtime"][
"CursorManager"
].should_process_numpad_commands():
for key in self.env["input"]["currInput"]:
for key in self.env["input"]["curr_input"]:
if key in numpad_keys:
# Return an empty/invalid shortcut that won't match any
# command
return "[]"
shortcut.append(self.env["input"]["currInput"])
shortcut.append(self.env["input"]["curr_input"])
if len(self.env["input"]["prevInput"]) < len(
self.env["input"]["currInput"]
if len(self.env["input"]["prev_input"]) < len(
self.env["input"]["curr_input"]
):
if self.env["input"][
"shortcut_repeat"
@@ -447,7 +447,7 @@ class InputManager:
shortcut = []
self.env["input"]["shortcut_repeat"] = 1
shortcut.append(self.env["input"]["shortcut_repeat"])
shortcut.append(self.env["input"]["currInput"])
shortcut.append(self.env["input"]["curr_input"])
self.env["runtime"]["DebugManager"].write_debug_out(
"curr_shortcut " + str(shortcut), debug.DebugLevel.INFO
)
@@ -456,15 +456,15 @@ class InputManager:
def curr_key_is_modifier(self):
if len(self.get_last_deepest_input()) != 1:
return False
return (self.env["input"]["currInput"][0] == "KEY_FENRIR") or (
self.env["input"]["currInput"][0] == "KEY_SCRIPT"
return (self.env["input"]["curr_input"][0] == "KEY_FENRIR") or (
self.env["input"]["curr_input"][0] == "KEY_SCRIPT"
)
def is_fenrir_key(self, event_name):
return event_name in self.env["input"]["fenrirKey"]
return event_name in self.env["input"]["fenrir_key"]
def is_script_key(self, event_name):
return event_name in self.env["input"]["scriptKey"]
return event_name in self.env["input"]["script_key"]
def get_command_for_shortcut(self, shortcut):
if not self.shortcut_exists(shortcut):
@@ -477,8 +477,8 @@ class InputManager:
if not event_data:
return
key_name = ""
if event_data["EventState"] == 1:
key_name = event_data["EventName"].lower()
if event_data["event_state"] == 1:
key_name = event_data["event_name"].lower()
if key_name.startswith("key_"):
key_name = key_name[4:]
self.env["runtime"]["OutputManager"].present_text(
+34 -5
View File
@@ -65,15 +65,44 @@ class OutputManager:
return
if (len(text) > 1) and (text.strip(string.whitespace) == ""):
return
to_announce_capital = announce_capital and text[0].isupper()
if to_announce_capital:
if self.play_sound_icon("capital", False):
to_announce_capital = False
is_capital = self._should_announce_capital(text, announce_capital)
use_pitch_for_capital = False
if is_capital:
indicator = self.env["runtime"]["SettingsManager"].get_setting(
"speech", "capital_indicator"
).lower()
if indicator == "none":
pass # No indication
elif indicator == "beep":
# Play beep with interrupt=True to fix stacking
self.play_sound_icon("capital", True)
elif indicator == "pitch":
use_pitch_for_capital = True
elif indicator == "both":
self.play_sound_icon("capital", True)
use_pitch_for_capital = True
else:
# Default to pitch for unknown values
use_pitch_for_capital = True
self.last_echo = text
self.speak_text(
text, interrupt, ignore_punctuation, to_announce_capital, flush
text, interrupt, ignore_punctuation, use_pitch_for_capital, flush
)
def _should_announce_capital(self, text, announce_capital):
if not announce_capital or not text:
return False
if len(text) == 1:
return text.isupper()
if any(char.isspace() for char in text):
return False
if not any(char.isalpha() for char in text):
return False
return text.isupper()
def get_last_echo(self):
return self.last_echo
+6 -5
View File
@@ -23,12 +23,18 @@ settings_data = {
"rate": 0.75,
"pitch": 0.5,
"capital_pitch": 0.8,
"capital_indicator": "pitch",
"volume": 1.0,
"module": "",
"voice": "en-us",
"language": "",
"auto_read_incoming": True,
"read_numbers_as_digits": False,
"rapid_update_threshold": 5,
"rapid_update_window": 0.3,
"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"',
"fenrir_min_volume": 0,
"fenrir_max_volume": 200,
@@ -97,11 +103,6 @@ settings_data = {
"vmenu_path": "",
"quick_menu": "speech#rate;speech#pitch;speech#volume",
},
"promote": {
"enabled": True,
"inactive_timeout_sec": 120,
"list": "",
},
"time": {
"enabled": False,
"present_time": True,
@@ -282,15 +282,15 @@ class SettingsManager:
keys = keys.upper()
key_list = keys.split(",")
for key in key_list:
if key not in self.env["input"]["fenrirKey"]:
self.env["input"]["fenrirKey"].append(key)
if key not in self.env["input"]["fenrir_key"]:
self.env["input"]["fenrir_key"].append(key)
def set_script_keys(self, keys):
keys = keys.upper()
key_list = keys.split(",")
for key in key_list:
if key not in self.env["input"]["scriptKey"]:
self.env["input"]["scriptKey"].append(key)
if key not in self.env["input"]["script_key"]:
self.env["input"]["script_key"].append(key)
def reset_setting_arg_dict(self):
self.settingArgDict = {}
+1 -1
View File
@@ -4,5 +4,5 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
version = "2025.12.14"
version = "2026.01.28"
code_name = "master"
@@ -41,7 +41,7 @@ class driver(inputDriver):
def clear_event_buffer(self):
if not self._initialized:
return
del self.env["input"]["eventBuffer"][:]
del self.env["input"]["event_buffer"][:]
print("Input Debug Driver: clear_event_buffer")
def update_input_devices(self, new_devices=None, init=False):
@@ -259,7 +259,7 @@ class driver(inputDriver):
"input_watchdog: EVENT:" + str(event),
debug.DebugLevel.INFO,
)
self.env["input"]["eventBuffer"].append(
self.env["input"]["event_buffer"].append(
[device, udevice, event]
)
if event.type == evdev.events.EV_KEY:
@@ -271,11 +271,11 @@ class driver(inputDriver):
event = device.read_one()
continue
if not isinstance(
curr_map_event["EventName"], str
curr_map_event["event_name"], str
):
event = device.read_one()
continue
if curr_map_event["EventState"] in [0, 1, 2]:
if curr_map_event["event_state"] in [0, 1, 2]:
event_queue.put(
{
"Type": FenrirEventType.keyboard_input,
@@ -301,7 +301,7 @@ class driver(inputDriver):
def write_event_buffer(self):
if not self._initialized:
return
for iDevice, uDevice, event in self.env["input"]["eventBuffer"]:
for iDevice, uDevice, event in self.env["input"]["event_buffer"]:
try:
if uDevice:
if self.gDevices[iDevice.fd]:
@@ -554,18 +554,18 @@ class driver(inputDriver):
m_event = inputData.input_event
try:
# mute is a list = ['KEY_MIN_INTERESTING', 'KEY_MUTE']
m_event["EventName"] = evdev.ecodes.keys[event.code]
if isinstance(m_event["EventName"], list):
if len(m_event["EventName"]) > 0:
m_event["EventName"] = m_event["EventName"][0]
if isinstance(m_event["EventName"], list):
if len(m_event["EventName"]) > 0:
m_event["EventName"] = m_event["EventName"][0]
m_event["EventValue"] = event.code
m_event["EventSec"] = event.sec
m_event["EventUsec"] = event.usec
m_event["EventState"] = event.value
m_event["EventType"] = event.type
m_event["event_name"] = evdev.ecodes.keys[event.code]
if isinstance(m_event["event_name"], list):
if len(m_event["event_name"]) > 0:
m_event["event_name"] = m_event["event_name"][0]
if isinstance(m_event["event_name"], list):
if len(m_event["event_name"]) > 0:
m_event["event_name"] = m_event["event_name"][0]
m_event["event_value"] = event.code
m_event["event_sec"] = event.sec
m_event["event_usec"] = event.usec
m_event["event_state"] = event.value
m_event["event_type"] = event.type
return m_event
except Exception as e:
return None