28 Commits

Author SHA1 Message Date
Storm Dragon f462ca7990 Merge branch 'testing' minor settings file update. 2025-12-03 16:25:47 -05:00
Storm Dragon f0bbcb8a38 Updated settings file to document that capslock as fenrir key and echo mode 2 are incompatible. 2025-12-03 16:25:22 -05:00
Storm Dragon aed627ec2a Discovered through much pain that echo mode 2 and capslock as fenrir key are incompatible. Documented in settings file. 2025-12-03 16:18:02 -05:00
Storm Dragon e62b887e9c Some socket improvements for remote manager I thought should make it into this release. 2025-12-03 12:20:14 -05:00
Storm Dragon bf0d134187 Tests added, see the documentation in the tests directory for details. Improved the socket code. 2025-12-03 02:51:49 -05:00
Storm Dragon c66a9ba9c2 Problems with voice selection fixed.: 2025-12-02 18:38:06 -05:00
Storm Dragon 2092a3e257 Fixed voice selection. 2025-12-02 18:36:46 -05:00
Storm Dragon d46d8de3ee Updated sound driver to gstreamer by default. 2025-12-02 16:25:25 -05:00
Storm Dragon 75a8447759 One more feature addition before hopefully releasing the new version. 2025-12-02 16:13:15 -05:00
Storm Dragon 1650eec768 Add ability to switch speech-dispatcher module and voice to the speeach keys. 2025-12-02 16:11:47 -05:00
Storm Dragon 5bb786ef4c Bug fix in vmenu for keyboard layouts. 2025-11-27 22:44:51 -05:00
Storm Dragon 7f7faa17d3 keyboard layout fixed in vmenu. 2025-11-27 22:42:36 -05:00
Storm Dragon 2766f70c5d Some cleanup and minor fixes in docs. 2025-11-24 09:07:45 -05:00
Storm Dragon 8d781643bc Remove group and user comments from the Arch service file, not being used, so just extra stuff. 2025-11-24 08:49:31 -05:00
Storm Dragon c184cf023a Code cleanups, fixes to systemd files, url corrections. 2025-11-24 08:44:49 -05:00
Storm Dragon 841c221c7b Preparing for new tagged release. 2025-11-23 18:51:02 -05:00
Storm Dragon 87553bdc38 more fixes for the pickle error. 2025-11-23 18:37:21 -05:00
Storm Dragon 77a3aae5a4 Attempt to fix cannot pickle 'TextIOWrapper' instances error for some distros. 2025-11-23 18:29:38 -05:00
Storm Dragon aabc202d83 Latest version of configure_pipewire.sh tested and appears to work. 2025-10-17 22:19:58 -04:00
Storm Dragon 2f3a114790 Pipewire configuration tool updated. In a bout of pure insanity I tested this on my production system and it worked without a hitch, so should be good to go. 2025-10-17 22:19:30 -04:00
Storm Dragon c797974560 Pre tag release. If no problems reported this will become the new stable release. 2025-10-17 21:27:36 -04:00
Storm Dragon af4740d5ad Various minor fixes in preparation for new release. 2025-10-17 21:26:13 -04:00
Storm Dragon 5ef5faaebe Progressbar tweaks. 2025-10-17 21:03:12 -04:00
Storm Dragon 7041d2567a Progress bar updates. 2025-09-26 18:07:35 -04:00
Storm Dragon 2c38bcf5f4 Another attempt at fixing external numpad detection. I *think* some of them send numlock state with every event. If that's the case, this should fix it. 2025-09-13 14:11:27 -04:00
Storm Dragon 96cdda99c4 Separate menu entries for some settings, improves usability. Fixed device detection for devices that do not contain what they do in their name, e.g. numpads that are not listed as numpads. This hopefully fixes a bug with some external numpads. 2025-09-12 12:09:00 -04:00
Storm Dragon 0658d37ae8 I don't wanna say this too loud, but I think tab completion is much more reliable now. No more not reading when you press tab and something appears. 2025-08-31 20:45:32 -04:00
Storm Dragon a6bb3e1301 A bit of code cleanup. Nothing should be changed at all functionally. 2025-08-31 20:30:06 -04:00
162 changed files with 3627 additions and 955 deletions
-3
View File
@@ -1,3 +0,0 @@
V2.0
Cleanup folders and config files.
+1 -3
View File
@@ -4,12 +4,10 @@ Wants=systemd-udev-settle.service
After=systemd-udev-settle.service getty.target
[Service]
Type=forking
PIDFile=/var/run/fenrir.pid
PIDFile=/run/fenrir.pid
ExecStart=/usr/bin/fenrir
ExecReload=/usr/bin/kill -HUP $MAINPID
Restart=always
#Group=fenrirscreenreader
#User=fenrirscreenreader
[Install]
WantedBy=getty.target
+1 -1
View File
@@ -4,7 +4,7 @@ Wants=systemd-udev-settle.service
After=systemd-udev-settle.service sound.target
[Service]
Type=forking
PIDFile=/var/run/fenrir.pid
PIDFile=/run/fenrir.pid
ExecStart=/usr/local/bin/fenrir
ExecReload=/usr/bin/kill -HUP $MAINPID
Restart=always
+2 -2
View File
@@ -1,5 +1,5 @@
Please report Bugs and feature requests to:
https://github.com/chrys87/fenrir/issues
Please report bugs and feature requests to:
https://git.stormux.org/storm/fenrir/issues
For bugs, please provide a debug file that shows the issue.
How to create a debug file:
+11 -4
View File
@@ -3,9 +3,9 @@
enabled=True
# Select the driver used to play sounds, choices are genericDriver and gstreamerDriver.
# Sox is the default.
#driver=gstreamerDriver
driver=genericDriver
# 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
@@ -110,7 +110,7 @@ keyboardLayout=desktop
# echo chars while typing.
# 0 = None
# 1 = always
# 2 = only while capslock
# 2 = only while capslock (not compatible with capslock as fenrir key)
charEchoMode=1
# echo deleted chars
charDeleteEcho=True
@@ -217,6 +217,13 @@ list=
[menu]
vmenuPath=
# quickMenu: Semicolon-separated list of settings for quick adjustment
# Format: section#setting;section#setting;...
# Supported settings:
# - speech#rate, speech#pitch, speech#volume (0.0-1.0)
# - speech#module, speech#voice (speechdDriver only, auto-added)
# Note: speech#module and speech#voice are automatically added when
# speechdDriver is active. Do not add them manually.
quickMenu=speech#rate;speech#pitch;speech#volume
[prompt]
+6 -6
View File
@@ -7,14 +7,14 @@ configurable and easy to customize and extend.
=== Credit and intended audience
This document is just a customization for Slint of the genuine
https://github.com/chrys87/fenrir/blob/master/docu/user.txt[Fenrir User
https://git.stormux.org/storm/fenrir/src/branch/master/docs/user.txt[Fenrir User
Manual] motly written by Chrys, main developer of Fenrir.
It has been adapted to its intended audience: end users of Fenrir on
Slint where it is already installed, thus concentrates on its setting
and usage. You will find more information about its features,
installation and how customize and troubleshoot it and contribute to its
development on https://github.com/chrys87/fenrir[the Fenrir Git
development on https://git.stormux.org/storm/fenrir[the Fenrir Git
repository].
=== Getting started with Fenrir
@@ -2193,9 +2193,9 @@ settings.conf). Commands are python files with a special scheme. You can
assign them to a shortcut using the filename without an extension or
place them in a hook trigger like OnInput or OnScreenChange. For further
information see developer guide. Good Examples:
https://github.com/chrys87/fenrir/blob/master/src/fenrir/commands/commands/date.py["date.py"]
https://git.stormux.org/storm/fenrir/src/branch/master/src/fenrirscreenreader/commands/commands/date.py["date.py"]
(announce the Date),
https://github.com/chrys87/fenrir/blob/master/src/fenrir/commands/commands/shut_up.py["shut_up.py"]
https://git.stormux.org/storm/fenrir/src/branch/master/src/fenrirscreenreader/commands/commands/shut_up.py["shut_up.py"]
(interrupt output) the basic scheme for a command is as follows:
....
@@ -2218,7 +2218,7 @@ class command():
pass
....
* https://github.com/chrys87/fenrir/blob/master/src/fenrir/commands/command_template.py[Template
* https://git.stormux.org/storm/fenrir/src/branch/master/src/fenrirscreenreader/commands/command_template.py[Template
lives here]
* The class needs to have the name "command".
* "initialize" is running once whilst loading the command.
@@ -2276,7 +2276,7 @@ root.
=== Bugreports and feature requests
Please report Bugs and feature requests to:
https://github.com/chrys87/fenrir/issues
https://git.stormux.org/storm/fenrir/issues
for bugs please provide a link:#Howto create a debug file[debug] file
that shows the issue.
+6 -6
View File
@@ -160,7 +160,7 @@ For Arch there are PKGBUILDs in the AUR:
- Download the latest stable version from the [[https://linux-a11y.org/index.php?page=fenrir-screenreader|Fenrir-Project]] site.
- Unpack the archive
- Check the needed Dependencys by running [[https://github.com/chrys87/fenrir/blob/master/check-dependencies.py|check-dependencys.py]] script
- Check the needed Dependencys by running [[https://git.stormux.org/storm/fenrir/src/branch/master/check-dependencies.py|check-dependencys.py]] script
- install the missing dependencies an standard installation requires the following:
* python3 >= 3.3 (and all the following is needed for python3 )
* python3-speechd (screen)
@@ -171,7 +171,7 @@ For Arch there are PKGBUILDs in the AUR:
* python3-pyenchant (spellchecker)
* your language for aspell (aspell-<lang>) (spellchecker)
* sox (sound)
* For an individual installation see [[#Support and Requirements|Support and Requirements]] or consult the [[https://github.com/chrys87/fenrir/blob/master/README.md|Readme]])
* For an individual installation see [[#Support and Requirements|Support and Requirements]] or consult the [[https://git.stormux.org/storm/fenrir/src/branch/master/README.md|Readme]])
- run "install.sh" as root
this installs Fenrir as the following
@@ -185,7 +185,7 @@ to remove Fenrir just run uninstall.sh as root
if you want to get the latest code you can use git to get a development snapshot:
git clone https://github.com/chrys87/fenrir.git
git clone https://git.stormux.org/storm/fenrir.git
===== Auto Start =====
@@ -1270,7 +1270,7 @@ File: ''/usr/share/fenrirscreenreader/scripts/helloWorld__-__key_h.sh'':
===== Commands =====
You can place your own commands in "/usr/share/fenrirscreenreader/commands" (path is configurable in settings.conf).
Commands are python files with a special scheme. You can assign them to a shortcut using the filename without an extension or place them in a hook trigger like OnInput or OnScreenChange. For further information see developer guide.
Good Examples: [[https://github.com/chrys87/fenrir/blob/master/src/fenrir/commands/commands/date.py|"date.py"]] (announce the Date), [[https://github.com/chrys87/fenrir/blob/master/src/fenrir/commands/commands/shut_up.py|"shut_up.py"]] (interrupt output)
Good Examples: [[https://git.stormux.org/storm/fenrir/src/branch/master/src/fenrirscreenreader/commands/commands/date.py|"date.py"]] (announce the Date), [[https://git.stormux.org/storm/fenrir/src/branch/master/src/fenrirscreenreader/commands/commands/shut_up.py|"shut_up.py"]] (interrupt output)
the basic scheme for a command is as follows:
from core import debug
@@ -1289,7 +1289,7 @@ the basic scheme for a command is as follows:
def setCallback(self, callback):
pass
* [[https://github.com/chrys87/fenrir/blob/master/src/fenrir/commands/command_template.py|Template lives here]]
* [[https://git.stormux.org/storm/fenrir/src/branch/master/src/fenrirscreenreader/commands/command_template.py|Template lives here]]
* The class needs to have the name "command".
* "initialize" is running once whilst loading the command.
* "shutdown" is running on unload like the command (quit fenrir)
@@ -1319,7 +1319,7 @@ the basic scheme for a command is as follows:
- You can test if speech-dispatcher works by invoking it as root\\ ''sudo spd-say "hello world"''
===== Bugreports and feature requests =====
Please report Bugs and feature requests to:
[[https://github.com/chrys87/fenrir/issues|https://github.com/chrys87/fenrir/issues]]
[[https://git.stormux.org/storm/fenrir/issues|https://git.stormux.org/storm/fenrir/issues]]
for bugs please provide a [[#Howto create a debug file|debug]] file that shows the issue.
==== How-to create a debug file ====
+1 -1
View File
@@ -56,7 +56,7 @@ To test Fenrir:
sudo fenrir
To have Fenrir start on system boot using systemd:
download service file: https://raw.githubusercontent.com/chrys87/fenrir/master/autostart/systemd/Arch/fenrir.service
download service file: https://git.stormux.org/storm/fenrir/raw/branch/master/autostart/systemd/Arch/fenrir.service
move the service file to: /etc/systemd/system/fenrir.service
sudo systemctl enable fenrir
+69
View File
@@ -0,0 +1,69 @@
[pytest]
# Pytest configuration for Fenrir screen reader
# Test discovery patterns
python_files = test_*.py *_test.py
python_classes = Test*
python_functions = test_*
# Test paths
testpaths = tests
# Minimum Python version
minversion = 3.7
# Output options
addopts =
# Verbose output with test names
-v
# Show extra test summary info
-ra
# Enable strict markers (only registered markers allowed)
--strict-markers
# Show local variables in tracebacks
--showlocals
# Warnings configuration
-W ignore::DeprecationWarning
# Optional plugins (uncomment if installed):
# --timeout=30 # Requires pytest-timeout
# --cov-report=term-missing # Requires pytest-cov
# -x # Stop on first failure
# Register custom markers
markers =
unit: Unit tests (fast, no mocking)
integration: Integration tests (require mocking)
driver: Driver tests (require root access)
slow: Tests that take more than 1 second
remote: Tests for remote control functionality
settings: Tests for settings and configuration
commands: Tests for command system
vmenu: Tests for VMenu system
# Coverage configuration
[coverage:run]
source = src/fenrirscreenreader
omit =
*/tests/*
*/vmenu-profiles/*
*/__pycache__/*
*/site-packages/*
[coverage:report]
# Fail if coverage falls below this percentage
# fail_under = 70
exclude_lines =
# Standard pragma
pragma: no cover
# Don't complain about missing debug code
def __repr__
# Don't complain if tests don't hit defensive assertion code
raise AssertionError
raise NotImplementedError
# Don't complain about abstract methods
@abstractmethod
# Don't complain about initialization
if __name__ == .__main__.:
[coverage:html]
directory = htmlcov
@@ -67,4 +67,4 @@ class command:
def set_callback(self, callback):
pass
pass
@@ -62,4 +62,4 @@ class command:
def set_callback(self, callback):
pass
pass
@@ -4,7 +4,7 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import time
from fenrirscreenreader.core.i18n import _
@@ -14,55 +14,150 @@ class command:
def initialize(self, environment):
self.env = environment
# Initialize tab completion state tracking
if "tabCompletion" not in self.env["commandBuffer"]:
self.env["commandBuffer"]["tabCompletion"] = {
"lastTabTime": 0,
"pendingCompletion": None,
"retryCount": 0
}
def shutdown(self):
pass
def get_description(self):
return "No Description found"
return _("Announces tab completions when detected")
def _is_recent_tab_input(self):
"""Check if TAB was pressed recently (within 200ms window)"""
current_time = time.time()
tab_detected = False
# Check KEY mode
if self.env["runtime"]["InputManager"].get_shortcut_type() in ["KEY"]:
if (self.env["runtime"]["InputManager"].get_last_deepest_input()
in [["KEY_TAB"]]):
tab_detected = True
self.env["commandBuffer"]["tabCompletion"]["lastTabTime"] = current_time
# Check BYTE mode
elif self.env["runtime"]["InputManager"].get_shortcut_type() in ["BYTE"]:
for currByte in self.env["runtime"]["ByteManager"].get_last_byte_key():
if currByte == 9: # Tab character
tab_detected = True
self.env["commandBuffer"]["tabCompletion"]["lastTabTime"] = current_time
# Check if tab was pressed recently (200ms window)
if not tab_detected:
time_since_tab = current_time - self.env["commandBuffer"]["tabCompletion"]["lastTabTime"]
if time_since_tab <= 0.2: # 200ms window
tab_detected = True
return tab_detected
def _is_flexible_completion_match(self, x_move, delta_text):
"""Use flexible matching instead of strict equality"""
if not delta_text:
return False
delta_len = len(delta_text)
# Exact match (preserve original behavior)
if x_move == delta_len:
return True
# Flexible range: allow ±2 characters difference
# Handles spacing adjustments and unicode width variations
if abs(x_move - delta_len) <= 2 and delta_len > 0:
return True
# For longer completions, allow proportional variance
if delta_len > 10 and abs(x_move - delta_len) <= (delta_len * 0.2):
return True
return False
def _detect_completion_patterns(self, delta_text):
"""Detect common tab completion patterns for improved accuracy"""
if not delta_text:
return False
delta_stripped = delta_text.strip()
# File extension completion
if '.' in delta_stripped and delta_stripped.count('.') <= 2:
return True
# Path completion (contains / or \)
if '/' in delta_stripped or '\\' in delta_stripped:
return True
# Command parameter completion (starts with -)
if delta_stripped.startswith('-') and len(delta_stripped) > 1:
return True
# Word boundary completion (alphanumeric content)
if delta_stripped.isalnum() and len(delta_stripped) >= 2:
return True
return False
def run(self):
# try to detect the tab completion by cursor change
"""Enhanced tab completion detection with improved reliability"""
# Basic cursor movement check (preserve original logic)
x_move = (
self.env["screen"]["new_cursor"]["x"]
- self.env["screen"]["old_cursor"]["x"]
)
if x_move <= 0:
return
if self.env["runtime"]["InputManager"].get_shortcut_type() in ["KEY"]:
if not (
self.env["runtime"]["InputManager"].get_last_deepest_input()
in [["KEY_TAB"]]
):
if x_move < 5:
return
elif self.env["runtime"]["InputManager"].get_shortcut_type() in [
"BYTE"
]:
found = False
for currByte in self.env["runtime"][
"ByteManager"
].get_last_byte_key():
if currByte == 9:
found = True
if not found:
if x_move < 5:
return
# is there any change?
# Enhanced tab input detection with persistence
tab_detected = self._is_recent_tab_input()
# Fallback for non-tab movements (preserve original thresholds)
if not tab_detected:
if x_move < 5:
return
# Screen delta availability check
if not self.env["runtime"]["ScreenManager"].is_delta():
# If tab was detected but no delta yet, store for potential retry
if tab_detected and self.env["commandBuffer"]["tabCompletion"]["retryCount"] < 2:
self.env["commandBuffer"]["tabCompletion"]["pendingCompletion"] = {
"x_move": x_move,
"timestamp": time.time()
}
self.env["commandBuffer"]["tabCompletion"]["retryCount"] += 1
return
if not x_move == len(self.env["screen"]["new_delta"]):
return
# filter unneded space on word begin
curr_delta = self.env["screen"]["new_delta"]
if (
len(curr_delta.strip()) != len(curr_delta)
and curr_delta.strip() != ""
):
delta_text = self.env["screen"]["new_delta"]
# Enhanced correlation checking with flexible matching
if not self._is_flexible_completion_match(x_move, delta_text):
# Additional pattern-based validation for edge cases
if not (tab_detected and self._detect_completion_patterns(delta_text)):
return
# Reset retry counter on successful detection
self.env["commandBuffer"]["tabCompletion"]["retryCount"] = 0
self.env["commandBuffer"]["tabCompletion"]["pendingCompletion"] = None
# Mark that we've handled this delta to prevent duplicate announcements
# This prevents the incoming text handler from also announcing the same content
self.env["commandBuffer"]["tabCompletion"]["lastProcessedDelta"] = delta_text
self.env["commandBuffer"]["tabCompletion"]["lastProcessedTime"] = time.time()
# Text filtering and announcement (preserve original behavior)
curr_delta = delta_text
if (len(curr_delta.strip()) != len(curr_delta) and curr_delta.strip() != ""):
curr_delta = curr_delta.strip()
self.env["runtime"]["OutputManager"].present_text(
curr_delta, interrupt=True, announce_capital=True, flush=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
)
def set_callback(self, callback):
pass
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import time
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 _("Handles delayed retry for tab completion detection")
def run(self):
"""Check for and process pending tab completions with slight delay"""
# Only process if we have tab completion state
if "tabCompletion" not in self.env["commandBuffer"]:
return
tab_state = self.env["commandBuffer"]["tabCompletion"]
pending = tab_state.get("pendingCompletion")
if not pending:
return
current_time = time.time()
# Process pending completion after 50ms delay
if current_time - pending["timestamp"] < 0.05:
return
# Check if screen delta is now available
if not self.env["runtime"]["ScreenManager"].is_delta():
# Give up after 200ms total
if current_time - pending["timestamp"] > 0.2:
tab_state["pendingCompletion"] = None
tab_state["retryCount"] = 0
return
# Process the delayed completion
delta_text = self.env["screen"]["new_delta"]
x_move = pending["x_move"]
# Use the same flexible matching logic as main tab completion
match_found = self._is_flexible_completion_match(x_move, delta_text)
if not match_found:
# Try pattern-based detection as final fallback
match_found = self._detect_completion_patterns(delta_text)
if match_found and delta_text:
# Mark that we've handled this delta to prevent duplicate announcements
tab_state["lastProcessedDelta"] = delta_text
tab_state["lastProcessedTime"] = current_time
# Filter and announce the completion
curr_delta = delta_text
if (len(curr_delta.strip()) != len(curr_delta) and
curr_delta.strip() != ""):
curr_delta = curr_delta.strip()
if curr_delta:
self.env["runtime"]["OutputManager"].present_text(
curr_delta, interrupt=True, announce_capital=True, flush=False
)
# Clear pending completion
tab_state["pendingCompletion"] = None
tab_state["retryCount"] = 0
def _is_flexible_completion_match(self, x_move, delta_text):
"""Use flexible matching (duplicated from main command for heartbeat use)"""
if not delta_text:
return False
delta_len = len(delta_text)
# Exact match
if x_move == delta_len:
return True
# Flexible range: allow ±2 characters difference
if abs(x_move - delta_len) <= 2 and delta_len > 0:
return True
# For longer completions, allow proportional variance
if delta_len > 10 and abs(x_move - delta_len) <= (delta_len * 0.2):
return True
return False
def _detect_completion_patterns(self, delta_text):
"""Detect common tab completion patterns (duplicated from main command)"""
if not delta_text:
return False
delta_stripped = delta_text.strip()
# File extension completion
if '.' in delta_stripped and delta_stripped.count('.') <= 2:
return True
# Path completion
if '/' in delta_stripped or '\\' in delta_stripped:
return True
# Command parameter completion
if delta_stripped.startswith('-') and len(delta_stripped) > 1:
return True
# Word boundary completion
if delta_stripped.isalnum() and len(delta_stripped) >= 2:
return True
return False
def set_callback(self, callback):
pass
@@ -24,11 +24,22 @@ class command:
def run(self):
if self.env["input"]["oldNumLock"] == self.env["input"]["newNumLock"]:
return
# Only announce numlock changes if an actual numlock key was pressed
# This prevents spurious announcements from external numpad automatic state changes
# AND the LED state actually changed (some numpads send spurious NUMLOCK events)
current_input = self.env["input"]["currInput"]
if current_input and "KEY_NUMLOCK" in current_input:
# Check if this is a genuine numlock key press by verifying:
# 1. KEY_NUMLOCK is in the current input sequence
# 2. The LED state has actually changed
# 3. This isn't just a side effect from a KP_ key (which some buggy numpads do)
is_genuine_numlock = (
current_input and
"KEY_NUMLOCK" in current_input and
not any(key.startswith("KEY_KP") for key in current_input if isinstance(key, str))
)
if is_genuine_numlock:
if self.env["input"]["newNumLock"]:
self.env["runtime"]["OutputManager"].present_text(
_("Numlock on"), interrupt=True
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.core import debug
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _(
"TUI focus mode handler - suppresses screen update spam "
"for interactive TUI applications"
)
def run(self):
# Check if TUI mode is enabled
if not self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"focus", "tui"
):
return
# TUI mode is active - set suppression flag for incoming handler
# This prevents the 70000-incoming.py command from announcing
# screen updates
self.env["commandBuffer"]["tuiSuppressIncoming"] = True
self.env["runtime"]["DebugManager"].write_debug_out(
"tui_focus_handler: TUI mode active, suppressing incoming text",
debug.DebugLevel.INFO
)
def set_callback(self, callback):
pass
@@ -66,12 +66,18 @@ class command:
# Check if delta is too large (screen change) vs small incremental
# updates
delta_length = len(self.env["screen"]["new_delta"])
delta_text = self.env["screen"]["new_delta"]
delta_length = len(delta_text)
if (
delta_length > 200
): # Allow longer progress lines like Claude Code's status
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:
return False
# Check if current line looks like a prompt - progress unlikely during
# prompts
if self.is_current_line_prompt():
@@ -270,7 +276,7 @@ class command:
self.env["commandBuffer"]["lastProgressTime"] = current_time
return
# Pattern 5: Braille progress indicators
# Pattern 5: Braille spinner indicators
braille_match = re.search(r'[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⡿⣟⣯⣷⣾⣽⣻⢿]', text)
if braille_match:
if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0:
@@ -279,26 +285,46 @@ class command:
return
# Pattern 6: Claude Code progress indicators
claude_progress_match = re.search(r'^[·✶✢✻*]\s+[^(]+[…\.]*\s*\(esc to interrupt[^)]*\)\s*$', text)
claude_progress_match = re.search(r'[·✶✢✻*].*?\(esc to interrupt[^)]*\)', text)
if claude_progress_match:
if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0:
self.play_activity_beep()
self.env["commandBuffer"]["lastProgressTime"] = current_time
return
# Pattern 7: Moon phase progress indicators
# Pattern 7: Moon phase spinner indicators
moon_match = re.search(r'[🌑🌒🌓🌔🌕🌖🌗🌘]', text)
if moon_match:
moon_phases = {
'🌑': 0, '🌒': 12.5, '🌓': 25, '🌔': 37.5,
'🌕': 50, '🌖': 62.5, '🌗': 75, '🌘': 87.5
}
moon_char = moon_match.group(0)
if moon_char in moon_phases:
percentage = moon_phases[moon_char]
if percentage != self.env["commandBuffer"]["lastProgressValue"]:
self.play_progress_tone(percentage)
self.env["commandBuffer"]["lastProgressValue"] = percentage
if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0:
self.play_activity_beep()
self.env["commandBuffer"]["lastProgressTime"] = current_time
return
# Pattern 8: Thinking/processing with timing (🔄 Thinking... 23s)
thinking_match = re.search(r'🔄[^\w]*(?:thinking|processing|working|analyzing)[^\d]*(\d+)s?\b', text, re.IGNORECASE)
if thinking_match:
# Extract timing value for activity beep frequency adjustment
seconds = int(thinking_match.group(1))
# Use slightly longer interval for thinking patterns to avoid spam
thinking_interval = 1.5 if seconds < 10 else 2.0
if (
current_time - self.env["commandBuffer"]["lastProgressTime"]
>= thinking_interval
):
self.env["runtime"]["DebugManager"].write_debug_out(
f"Playing thinking activity beep (timing: {seconds}s)",
debug.DebugLevel.INFO,
)
self.play_activity_beep()
self.env["commandBuffer"]["lastProgressTime"] = current_time
return
# Pattern 9: Half-circle/circle progress indicators (◐ ◓ ◒ ◑)
circle_match = re.search(r'[◐◓◒◑]', text)
if circle_match:
if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0:
self.play_activity_beep()
self.env["commandBuffer"]["lastProgressTime"] = current_time
return
def play_progress_tone(self, percentage):
@@ -4,7 +4,7 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import time
from fenrirscreenreader.core.i18n import _
@@ -19,7 +19,26 @@ class command:
pass
def get_description(self):
return "No Description found"
return _("Announces incoming text changes")
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"]:
return False
tab_state = self.env["commandBuffer"]["tabCompletion"]
# Check if this exact delta was processed recently by tab completion
if (tab_state.get("lastProcessedDelta") == delta_text and
tab_state.get("lastProcessedTime")):
# Only suppress if processed within the last 50ms to avoid stale suppression
# Reduced from 100ms to minimize false positives with rapid multi-line updates
time_since_processed = time.time() - tab_state["lastProcessedTime"]
if time_since_processed <= 0.05:
return True
return False
def run(self):
if not self.env["runtime"]["SettingsManager"].get_setting_as_bool(
@@ -30,6 +49,12 @@ class command:
if not self.env["runtime"]["ScreenManager"].is_delta(ignoreSpace=True):
return
delta_text = self.env["screen"]["new_delta"]
# Skip if tab completion already handled this delta
if self._was_handled_by_tab_completion(delta_text):
return
# this must be a keyecho or something
# if len(self.env['screen']['new_delta'].strip(' \n\t')) <= 1:
x_move = abs(
@@ -41,14 +66,14 @@ class command:
- self.env["screen"]["old_cursor"]["y"]
)
if (x_move >= 1) and x_move == len(self.env["screen"]["new_delta"]):
if (x_move >= 1) and x_move == len(delta_text):
# if len(self.env['screen']['new_delta'].strip(' \n\t0123456789'))
# <= 2:
if "\n" not in self.env["screen"]["new_delta"]:
if "\n" not in delta_text:
return
# print(x_move, y_move, len(self.env['screen']['new_delta']), len(self.env['screen']['newNegativeDelta']))
self.env["runtime"]["OutputManager"].present_text(
self.env["screen"]["new_delta"], interrupt=False, flush=False
delta_text, interrupt=False, flush=False
)
def set_callback(self, callback):
@@ -1 +1 @@
# Emoji VMenu category
# Emoji VMenu category
@@ -1 +1 @@
# Flags emoji subcategory
# Flags emoji subcategory
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added Canada flag to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added UK flag to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added USA flag to clipboard",
interrupt=False, flush=False
)
)
@@ -1 +1 @@
# Food emoji subcategory
# Food emoji subcategory
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added red apple to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added avocado to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added beer to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added birthday cake to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added coffee to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added donut to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added hamburger to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added pizza to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added strawberry to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added taco to clipboard",
interrupt=False, flush=False
)
)
@@ -1 +1 @@
# Holidays emoji subcategory
# Holidays emoji subcategory
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added alien monster to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added bat to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added black cat to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added bunny to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added mage to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added Christmas tree to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added coffin to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added Easter egg to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added fireworks to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added ghost to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added gift to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added jack o'lantern to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added mummy to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added Santa to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added shamrock to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added skull to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added snowman to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added spider to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added turkey to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added vampire to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added spider web to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added witch to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added zombie to clipboard",
interrupt=False, flush=False
)
)
@@ -1 +1 @@
# Nature emoji subcategory
# Nature emoji subcategory
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added butterfly to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added cat to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added cherry blossom to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added dog to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added moon to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added rainbow to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added rose to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added sun to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added sunflower to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added tree to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added the mighty wolf Fenrir to clipboard",
interrupt=False, flush=False
)
)
@@ -1 +1 @@
# People emoji subcategory
# People emoji subcategory
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added grinning face to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added angry face to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added beaming face to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added face blowing kiss to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added cool face to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added crying face to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added devil face to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added dizzy face to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added exploding head to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added face with symbols to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added grinning face with sweat to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added heart-eyes face to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added smiling face with hearts to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added angry face with horns to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added laughing face to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added nauseated face to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added pleading face to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added poop to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added rolling on floor laughing to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added sad face to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added face savoring food to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added screaming face to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added shocked face to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added smiling face to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added smirking face to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added thumbs up to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added winking face to clipboard",
interrupt=False, flush=False
)
)
@@ -1 +1 @@
# Symbols emoji subcategory
# Symbols emoji subcategory
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added chains to clipboard",
interrupt=False, flush=False
)
)
@@ -19,4 +19,4 @@ class command():
self.env["runtime"]["OutputManager"].present_text(
"Added checkmark to clipboard",
interrupt=False, flush=False
)
)

Some files were not shown because too many files have changed in this diff Show More