Compare commits
14 Commits
9e8d0b3869
...
2026.01.28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e10c1c43b | ||
|
|
1e67876883 | ||
|
|
14cf6b6088 | ||
|
|
900a027643 | ||
|
|
0e50175463 | ||
|
|
7283f04778 | ||
|
|
8d3495f74f | ||
|
|
a6cd47dafc | ||
|
|
0bb2e52deb | ||
|
|
b8eb815a86 | ||
|
|
beca468338 | ||
|
|
a26fe26c8c | ||
|
|
508fd11610 | ||
|
|
afe0e71a1d |
17
README.md
17
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -75,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
|
||||
@@ -137,9 +153,10 @@ 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
|
||||
|
||||
57
src/fenrirscreenreader/commands/commands/cycle_key_echo.py
Normal file
57
src/fenrirscreenreader/commands/commands/cycle_key_echo.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
@@ -305,12 +305,12 @@ class command:
|
||||
self.env["commandBuffer"]["lastProgressTime"] = current_time
|
||||
return
|
||||
|
||||
# Pattern 6: Claude Code working indicators (various symbols + activity text + "esc to interrupt")
|
||||
# Matches any: [symbol] [Task description]… (... 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… (esc to interrupt · ctrl+t to show todos · 1m 25s · ↑ 887 tokens)
|
||||
# Example: ✽ Reviewing script for issues… (ctrl+c to interrupt · 33s · ↑ 1.6k tokens · thought for 4s)
|
||||
claude_progress_match = re.search(
|
||||
r'[*✢✽✶✻·•◦○●◆]\s+\w+.*?…\s*\(.*esc to interrupt.*\)',
|
||||
r'[*✢✽✶✻·•◦○●◆]\s+\w+.*?…\s*\(.*(?:esc|ctrl\+c) to interrupt.*\)',
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -65,7 +65,7 @@ class OutputManager:
|
||||
return
|
||||
if (len(text) > 1) and (text.strip(string.whitespace) == ""):
|
||||
return
|
||||
is_capital = announce_capital and text[0].isupper()
|
||||
is_capital = self._should_announce_capital(text, announce_capital)
|
||||
use_pitch_for_capital = False
|
||||
|
||||
if is_capital:
|
||||
@@ -92,6 +92,17 @@ class OutputManager:
|
||||
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
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ settings_data = {
|
||||
"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,
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
version = "2025.12.30"
|
||||
version = "2026.01.28"
|
||||
code_name = "master"
|
||||
|
||||
Reference in New Issue
Block a user