5 Commits

134 changed files with 1055 additions and 411 deletions
@@ -4,7 +4,7 @@
# Fenrir TTY screen reader # Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors. # By Chrys, Storm Dragon, and contributors.
import time
from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.core.i18n import _
@@ -14,55 +14,150 @@ class command:
def initialize(self, environment): def initialize(self, environment):
self.env = 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): def shutdown(self):
pass pass
def get_description(self): 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): 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 = ( x_move = (
self.env["screen"]["new_cursor"]["x"] self.env["screen"]["new_cursor"]["x"]
- self.env["screen"]["old_cursor"]["x"] - self.env["screen"]["old_cursor"]["x"]
) )
if x_move <= 0: if x_move <= 0:
return return
if self.env["runtime"]["InputManager"].get_shortcut_type() in ["KEY"]:
if not ( # Enhanced tab input detection with persistence
self.env["runtime"]["InputManager"].get_last_deepest_input() tab_detected = self._is_recent_tab_input()
in [["KEY_TAB"]]
): # Fallback for non-tab movements (preserve original thresholds)
if x_move < 5: if not tab_detected:
return if x_move < 5:
elif self.env["runtime"]["InputManager"].get_shortcut_type() in [ return
"BYTE"
]: # Screen delta availability check
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?
if not self.env["runtime"]["ScreenManager"].is_delta(): 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 return
if not x_move == len(self.env["screen"]["new_delta"]):
return delta_text = self.env["screen"]["new_delta"]
# filter unneded space on word begin
curr_delta = self.env["screen"]["new_delta"] # Enhanced correlation checking with flexible matching
if ( if not self._is_flexible_completion_match(x_move, delta_text):
len(curr_delta.strip()) != len(curr_delta) # Additional pattern-based validation for edge cases
and curr_delta.strip() != "" 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() 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): def set_callback(self, callback):
pass 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
@@ -26,9 +26,20 @@ class command:
return return
# Only announce numlock changes if an actual numlock key was pressed # 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"] 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"]: if self.env["input"]["newNumLock"]:
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
_("Numlock on"), interrupt=True _("Numlock on"), interrupt=True
@@ -279,7 +279,7 @@ class command:
return return
# Pattern 6: Claude Code progress indicators # 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'[·✶✢✻*]\s+[^(]+[…\.]*\s*\(esc to interrupt[^)]*\)', text)
if claude_progress_match: if claude_progress_match:
if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0: if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0:
self.play_activity_beep() self.play_activity_beep()
@@ -301,6 +301,25 @@ class command:
self.env["commandBuffer"]["lastProgressValue"] = percentage self.env["commandBuffer"]["lastProgressValue"] = percentage
return 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
def play_progress_tone(self, percentage): def play_progress_tone(self, percentage):
# Map 0-100% to 400-1200Hz frequency range # Map 0-100% to 400-1200Hz frequency range
frequency = 400 + (percentage * 8) frequency = 400 + (percentage * 8)
@@ -4,7 +4,7 @@
# Fenrir TTY screen reader # Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors. # By Chrys, Storm Dragon, and contributors.
import time
from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.core.i18n import _
@@ -19,7 +19,25 @@ class command:
pass pass
def get_description(self): 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 100ms to avoid stale suppression
time_since_processed = time.time() - tab_state["lastProcessedTime"]
if time_since_processed <= 0.1:
return True
return False
def run(self): def run(self):
if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( if not self.env["runtime"]["SettingsManager"].get_setting_as_bool(
@@ -30,6 +48,12 @@ class command:
if not self.env["runtime"]["ScreenManager"].is_delta(ignoreSpace=True): if not self.env["runtime"]["ScreenManager"].is_delta(ignoreSpace=True):
return 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 # this must be a keyecho or something
# if len(self.env['screen']['new_delta'].strip(' \n\t')) <= 1: # if len(self.env['screen']['new_delta'].strip(' \n\t')) <= 1:
x_move = abs( x_move = abs(
@@ -41,14 +65,14 @@ class command:
- self.env["screen"]["old_cursor"]["y"] - 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')) # if len(self.env['screen']['new_delta'].strip(' \n\t0123456789'))
# <= 2: # <= 2:
if "\n" not in self.env["screen"]["new_delta"]: if "\n" not in delta_text:
return return
# print(x_move, y_move, len(self.env['screen']['new_delta']), len(self.env['screen']['newNegativeDelta'])) # print(x_move, y_move, len(self.env['screen']['new_delta']), len(self.env['screen']['newNegativeDelta']))
self.env["runtime"]["OutputManager"].present_text( 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): def set_callback(self, callback):
@@ -147,3 +147,18 @@ class config_command:
except Exception as e: except Exception as e:
self.present_text(f"Failed to create basic defaults: {str(e)}") self.present_text(f"Failed to create basic defaults: {str(e)}")
return False return False
def get_setting(self, section, setting, default=None):
"""Get setting value from settings manager"""
try:
return self.env["runtime"]["SettingsManager"].get_setting(section, setting)
except Exception:
return default
def set_setting(self, section, setting, value):
"""Set setting value via settings manager"""
try:
self.env["runtime"]["SettingsManager"].set_setting(section, setting, value)
return True
except Exception:
return False
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set punctuation to All (every punctuation mark)"
def run(self):
current_level = self.get_setting("general", "punctuationLevel", "some")
if current_level.lower() == "all":
self.present_text("Punctuation level already set to All")
return
success = self.set_setting("general", "punctuationLevel", "all")
if success:
self.present_text("Punctuation level set to All - every punctuation mark will be spoken")
self.play_sound("Accept")
else:
self.present_text("Failed to change punctuation level")
self.play_sound("Error")
@@ -1,54 +0,0 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set punctuation verbosity level"
def run(self):
current_level = self.get_setting("general", "punctuationLevel", "some")
# Present current level
level_descriptions = {
"none": "None - no punctuation spoken",
"some": "Some - basic punctuation only",
"most": "Most - detailed punctuation",
"all": "All - every punctuation mark",
}
current_description = level_descriptions.get(current_level, "Unknown")
self.present_text(f"Current punctuation level: {current_description}")
# Cycle through the four levels
levels = ["none", "some", "most", "all"]
try:
current_index = levels.index(current_level)
next_index = (current_index + 1) % len(levels)
new_level = levels[next_index]
except ValueError:
new_level = "some" # Default to some
success = self.set_setting("general", "punctuationLevel", new_level)
if success:
new_description = level_descriptions[new_level]
self.present_text(f"Punctuation level set to: {new_description}")
self.play_sound("Accept")
else:
self.present_text("Failed to change punctuation level")
self.play_sound("Error")
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set punctuation to Most (detailed punctuation)"
def run(self):
current_level = self.get_setting("general", "punctuationLevel", "some")
if current_level.lower() == "most":
self.present_text("Punctuation level already set to Most")
return
success = self.set_setting("general", "punctuationLevel", "most")
if success:
self.present_text("Punctuation level set to Most - detailed punctuation will be spoken")
self.play_sound("Accept")
else:
self.present_text("Failed to change punctuation level")
self.play_sound("Error")
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set punctuation to None (no punctuation spoken)"
def run(self):
current_level = self.get_setting("general", "punctuationLevel", "some")
if current_level.lower() == "none":
self.present_text("Punctuation level already set to None")
return
success = self.set_setting("general", "punctuationLevel", "none")
if success:
self.present_text("Punctuation level set to None - no punctuation will be spoken")
self.play_sound("Accept")
else:
self.present_text("Failed to change punctuation level")
self.play_sound("Error")
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set punctuation to Some (basic punctuation only)"
def run(self):
current_level = self.get_setting("general", "punctuationLevel", "some")
if current_level.lower() == "some":
self.present_text("Punctuation level already set to Some")
return
success = self.set_setting("general", "punctuationLevel", "some")
if success:
self.present_text("Punctuation level set to Some - basic punctuation will be spoken")
self.play_sound("Accept")
else:
self.present_text("Failed to change punctuation level")
self.play_sound("Error")
@@ -1,85 +0,0 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Select keyboard layout (desktop or laptop)"
def run(self):
current_layout = self.get_setting(
"keyboard", "keyboardLayout", "desktop"
)
# Present current layout
self.present_text(f"Current keyboard layout: {current_layout}")
# Find available keyboard layouts
keyboard_path = "/etc/fenrirscreenreader/keyboard"
if not os.path.isdir(keyboard_path):
# Development path
keyboard_path = os.path.join(
os.path.dirname(self.settings_file), "..", "keyboard"
)
available_layouts = self.get_available_layouts(keyboard_path)
if len(available_layouts) > 1:
# Cycle through available layouts
try:
current_index = available_layouts.index(current_layout)
next_index = (current_index + 1) % len(available_layouts)
new_layout = available_layouts[next_index]
except ValueError:
# Current layout not found, use first available
new_layout = available_layouts[0]
success = self.set_setting(
"keyboard", "keyboardLayout", new_layout
)
if success:
self.present_text(f"Keyboard layout changed to: {new_layout}")
self.present_text(
"Please restart Fenrir for this change to take effect."
)
self.play_sound("Accept")
else:
self.present_text("Failed to change keyboard layout")
self.play_sound("Error")
else:
self.present_text("Only default keyboard layout is available")
self.play_sound("Cancel")
def get_available_layouts(self, keyboard_path):
"""Find available keyboard layout files"""
layouts = []
if os.path.isdir(keyboard_path):
try:
for file in os.listdir(keyboard_path):
if file.endswith(".conf") and not file.startswith("."):
layout_name = file[:-5] # Remove .conf extension
layouts.append(layout_name)
except Exception:
pass
# Ensure we have at least the default layouts
if not layouts:
layouts = ["desktop", "laptop"]
return sorted(layouts)
@@ -1,55 +0,0 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set character echo mode"
def run(self):
current_mode = self.get_setting("keyboard", "charEchoMode", "1")
# Present current mode
mode_descriptions = {
"0": "None - no character echo",
"1": "Always - echo all typed characters",
"2": "Caps Lock - echo only when caps lock is on",
}
current_description = mode_descriptions.get(current_mode, "Unknown")
self.present_text(
f"Current character echo mode: {current_description}"
)
# Cycle through the three modes
modes = ["0", "1", "2"]
try:
current_index = modes.index(current_mode)
next_index = (current_index + 1) % len(modes)
new_mode = modes[next_index]
except ValueError:
new_mode = "1" # Default to always
success = self.set_setting("keyboard", "charEchoMode", new_mode)
if success:
new_description = mode_descriptions[new_mode]
self.present_text(f"Character echo mode set to: {new_description}")
self.play_sound("Accept")
else:
self.present_text("Failed to change character echo mode")
self.play_sound("Error")
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set character echo to Always (echo all typed characters)"
def run(self):
current_mode = self.get_setting("keyboard", "charEchoMode", "1")
if current_mode == "1":
self.present_text("Character echo already set to Always")
return
success = self.set_setting("keyboard", "charEchoMode", "1")
if success:
self.present_text("Character echo set to Always - all typed characters will be spoken")
self.play_sound("Accept")
else:
self.present_text("Failed to change character echo mode")
self.play_sound("Error")
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set character echo to Caps Lock (echo only when caps lock is on)"
def run(self):
current_mode = self.get_setting("keyboard", "charEchoMode", "1")
if current_mode == "2":
self.present_text("Character echo already set to Caps Lock mode")
return
success = self.set_setting("keyboard", "charEchoMode", "2")
if success:
self.present_text("Character echo set to Caps Lock mode - characters will be spoken only when caps lock is on")
self.play_sound("Accept")
else:
self.present_text("Failed to change character echo mode")
self.play_sound("Error")
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set character echo to None (no character echo)"
def run(self):
current_mode = self.get_setting("keyboard", "charEchoMode", "1")
if current_mode == "0":
self.present_text("Character echo already set to None")
return
success = self.set_setting("keyboard", "charEchoMode", "0")
if success:
self.present_text("Character echo set to None - no typed characters will be spoken")
self.play_sound("Accept")
else:
self.present_text("Failed to change character echo mode")
self.play_sound("Error")
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set keyboard layout to Desktop"
def run(self):
current_layout = self.get_setting("keyboard", "keyboardLayout", "desktop")
if current_layout.lower() == "desktop":
self.present_text("Keyboard layout already set to Desktop")
return
success = self.set_setting("keyboard", "keyboardLayout", "desktop")
if success:
self.present_text("Keyboard layout set to Desktop")
self.present_text("Please restart Fenrir for this change to take effect")
self.play_sound("Accept")
else:
self.present_text("Failed to change keyboard layout")
self.play_sound("Error")
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set keyboard layout to Laptop"
def run(self):
current_layout = self.get_setting("keyboard", "keyboardLayout", "desktop")
if current_layout.lower() == "laptop":
self.present_text("Keyboard layout already set to Laptop")
return
success = self.set_setting("keyboard", "keyboardLayout", "laptop")
if success:
self.present_text("Keyboard layout set to Laptop")
self.present_text("Please restart Fenrir for this change to take effect")
self.play_sound("Accept")
else:
self.present_text("Failed to change keyboard layout")
self.play_sound("Error")
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set keyboard layout to PTY (terminal emulation)"
def run(self):
current_layout = self.get_setting("keyboard", "keyboardLayout", "desktop")
if current_layout.lower() == "pty":
self.present_text("Keyboard layout already set to PTY")
return
success = self.set_setting("keyboard", "keyboardLayout", "pty")
if success:
self.present_text("Keyboard layout set to PTY for terminal emulation")
self.present_text("Please restart Fenrir for this change to take effect")
self.play_sound("Accept")
else:
self.present_text("Failed to change keyboard layout")
self.play_sound("Error")
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set keyboard layout to PTY2 (alternative terminal layout)"
def run(self):
current_layout = self.get_setting("keyboard", "keyboardLayout", "desktop")
if current_layout.lower() == "pty2":
self.present_text("Keyboard layout already set to PTY2")
return
success = self.set_setting("keyboard", "keyboardLayout", "pty2")
if success:
self.present_text("Keyboard layout set to PTY2 alternative terminal layout")
self.present_text("Please restart Fenrir for this change to take effect")
self.play_sound("Accept")
else:
self.present_text("Failed to change keyboard layout")
self.play_sound("Error")
@@ -159,8 +159,7 @@ class command(config_command):
except Exception as e: except Exception as e:
self.present_text( self.present_text(
f"Failed to create default configuration: { f"Failed to create default configuration: {str(e)}",
str(e)}",
interrupt=False, interrupt=False,
flush=False, flush=False,
) )
@@ -45,8 +45,7 @@ class command:
sound_driver.initialize(self.env) sound_driver.initialize(self.env)
except Exception as e: except Exception as e:
print( print(
f"revert_to_saved sound_driver: Error reinitializing sound driver: { f"revert_to_saved sound_driver: Error reinitializing sound driver: {str(e)}"
str(e)}"
) )
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
@@ -1,51 +0,0 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set screen text encoding"
def run(self):
current_encoding = self.get_setting("screen", "encoding", "auto")
# Present current encoding
self.present_text(f"Current screen encoding: {current_encoding}")
# Cycle through available encodings
encodings = ["auto", "utf-8", "cp1252", "iso-8859-1"]
try:
current_index = encodings.index(current_encoding)
next_index = (current_index + 1) % len(encodings)
new_encoding = encodings[next_index]
except ValueError:
new_encoding = "auto" # Default to auto
success = self.set_setting("screen", "encoding", new_encoding)
if success:
self.present_text(f"Screen encoding set to: {new_encoding}")
if new_encoding == "auto":
self.present_text(
"Fenrir will automatically detect text encoding"
)
else:
self.present_text(f"Fenrir will use {new_encoding} encoding")
self.play_sound("Accept")
else:
self.present_text("Failed to change screen encoding")
self.play_sound("Error")
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set screen encoding to Auto (automatic detection)"
def run(self):
current_encoding = self.get_setting("screen", "encoding", "auto")
if current_encoding.lower() == "auto":
self.present_text("Screen encoding already set to Auto")
return
success = self.set_setting("screen", "encoding", "auto")
if success:
self.present_text("Screen encoding set to Auto - Fenrir will automatically detect text encoding")
self.play_sound("Accept")
else:
self.present_text("Failed to change screen encoding")
self.play_sound("Error")
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set screen encoding to CP1252 (Windows Western European)"
def run(self):
current_encoding = self.get_setting("screen", "encoding", "auto")
if current_encoding.lower() == "cp1252":
self.present_text("Screen encoding already set to CP1252")
return
success = self.set_setting("screen", "encoding", "cp1252")
if success:
self.present_text("Screen encoding set to CP1252 - Windows Western European encoding")
self.play_sound("Accept")
else:
self.present_text("Failed to change screen encoding")
self.play_sound("Error")
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set screen encoding to ISO-8859-1 (Latin-1)"
def run(self):
current_encoding = self.get_setting("screen", "encoding", "auto")
if current_encoding.lower() == "iso-8859-1":
self.present_text("Screen encoding already set to ISO-8859-1")
return
success = self.set_setting("screen", "encoding", "iso-8859-1")
if success:
self.present_text("Screen encoding set to ISO-8859-1 - Latin-1 encoding")
self.play_sound("Accept")
else:
self.present_text("Failed to change screen encoding")
self.play_sound("Error")
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import importlib.util
import os
from fenrirscreenreader.core.i18n import _
# Load base configuration class
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
config_command = _module.config_command
class command(config_command):
def __init__(self):
super().__init__()
def get_description(self):
return "Set screen encoding to UTF-8 (Unicode)"
def run(self):
current_encoding = self.get_setting("screen", "encoding", "auto")
if current_encoding.lower() == "utf-8":
self.present_text("Screen encoding already set to UTF-8")
return
success = self.set_setting("screen", "encoding", "utf-8")
if success:
self.present_text("Screen encoding set to UTF-8 - Unicode text encoding")
self.play_sound("Accept")
else:
self.present_text("Failed to change screen encoding")
self.play_sound("Error")
@@ -189,9 +189,7 @@ class DynamicApplyVoiceCommand:
# Debug: verify what was actually set # Debug: verify what was actually set
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
f"Speech driver now has module: { f"Speech driver now has module: {SpeechDriver.module}, voice: {SpeechDriver.voice}",
SpeechDriver.module}, voice: {
SpeechDriver.voice}",
interrupt=True, interrupt=True,
) )
@@ -233,8 +231,7 @@ class DynamicApplyVoiceCommand:
) )
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
f"Failed to apply voice, reverted: { f"Failed to apply voice, reverted: {str(e)}",
str(e)}",
interrupt=False, interrupt=False,
flush=False, flush=False,
) )
+2 -2
View File
@@ -4,5 +4,5 @@
# Fenrir TTY screen reader # Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors. # By Chrys, Storm Dragon, and contributors.
version = "2025.08.31" version = "2025.09.26"
code_name = "master" code_name = "testing"
@@ -438,11 +438,25 @@ class driver(inputDriver):
debug.DebugLevel.INFO, debug.DebugLevel.INFO,
) )
continue continue
if len(cap[event_type.EV_KEY]) < 60: # Check if device has numpad keys - use lower threshold for dedicated numpads
numpad_keys = [
evdev.ecodes.KEY_KP0, evdev.ecodes.KEY_KP1, evdev.ecodes.KEY_KP2,
evdev.ecodes.KEY_KP3, evdev.ecodes.KEY_KP4, evdev.ecodes.KEY_KP5,
evdev.ecodes.KEY_KP6, evdev.ecodes.KEY_KP7, evdev.ecodes.KEY_KP8,
evdev.ecodes.KEY_KP9, evdev.ecodes.KEY_KPPLUS, evdev.ecodes.KEY_KPMINUS,
evdev.ecodes.KEY_KPASTERISK, evdev.ecodes.KEY_KPSLASH, evdev.ecodes.KEY_KPENTER,
evdev.ecodes.KEY_KPDOT
]
has_numpad_keys = any(key in cap[event_type.EV_KEY] for key in numpad_keys)
min_key_threshold = 10 if has_numpad_keys else 60
if len(cap[event_type.EV_KEY]) < min_key_threshold:
threshold_type = "numpad" if has_numpad_keys else "keyboard"
self.env["runtime"][ self.env["runtime"][
"DebugManager" "DebugManager"
].write_debug_out( ].write_debug_out(
"Device Skipped (< 60 keys):" f"Device Skipped (< {min_key_threshold} keys for {threshold_type}):"
+ curr_device.name, + curr_device.name,
debug.DebugLevel.INFO, debug.DebugLevel.INFO,
) )
+12 -5
View File
@@ -43,12 +43,19 @@ if [ -n "$STAGED_PYTHON_FILES" ]; then
for file in $STAGED_PYTHON_FILES; do for file in $STAGED_PYTHON_FILES; do
if [ -f "$file" ]; then if [ -f "$file" ]; then
# Check for unterminated strings (the main issue from the email) # Check for broken f-strings (multiline issues that cause syntax errors)
if grep -n 'f".*{$' "$file" >/dev/null 2>&1; then # Pattern 1: f-string with opening brace at end of line (likely broken across lines)
echo -e "${RED}✗ $file: Potential unterminated f-string${NC}" if grep -n 'f"[^"]*{[^}]*$' "$file" >/dev/null 2>&1; then
echo -e "${RED}✗ $file: Potential broken multiline f-string${NC}"
grep -n 'f"[^"]*{[^}]*$' "$file" | head -3
ISSUES_FOUND=1 ISSUES_FOUND=1
fi fi
# Pattern 2: Lines that end with just an opening brace (common in broken f-strings)
if grep -n '^\s*[^#]*{$' "$file" >/dev/null 2>&1; then
echo -e "${YELLOW}⚠ $file: Lines ending with lone opening brace (check f-strings)${NC}"
fi
# Check for missing imports that are commonly used # Check for missing imports that are commonly used
if grep -q 'debug\.DebugLevel\.' "$file" && ! grep -q 'from.*debug' "$file" && ! grep -q 'import.*debug' "$file"; then if grep -q 'debug\.DebugLevel\.' "$file" && ! grep -q 'from.*debug' "$file" && ! grep -q 'import.*debug' "$file"; then
echo -e "${YELLOW}⚠ $file: Uses debug.DebugLevel but no debug import found${NC}" echo -e "${YELLOW}⚠ $file: Uses debug.DebugLevel but no debug import found${NC}"
@@ -110,7 +117,7 @@ if [ -n "$STAGED_PYTHON_FILES" ]; then
for file in $STAGED_PYTHON_FILES; do for file in $STAGED_PYTHON_FILES; do
if [ -f "$file" ]; then if [ -f "$file" ]; then
# Check for potential passwords, keys, tokens # Check for potential passwords, keys, tokens
if grep -i -E '(password|passwd|pwd|key|token|secret|api_key).*=.*["\'][^"\']{8,}["\']' "$file" >/dev/null 2>&1; then if grep -i -E '(password|passwd|pwd|key|token|secret|api_key).*=.*["'"'"'][^"'"'"']{8,}["'"'"']' "$file" >/dev/null 2>&1; then
echo -e "${RED}✗ $file: Potential hardcoded secret detected${NC}" echo -e "${RED}✗ $file: Potential hardcoded secret detected${NC}"
SECRETS_FOUND=1 SECRETS_FOUND=1
fi fi
@@ -126,7 +133,7 @@ else
fi fi
# Summary # Summary
echo -e "\n${'='*50}" echo -e "\n=================================================="
if [ $VALIDATION_FAILED -eq 0 ]; then if [ $VALIDATION_FAILED -eq 0 ]; then
echo -e "${GREEN}✓ All pre-commit validations passed${NC}" echo -e "${GREEN}✓ All pre-commit validations passed${NC}"
echo -e "${GREEN}Commit allowed to proceed${NC}" echo -e "${GREEN}Commit allowed to proceed${NC}"
+22
View File
@@ -13,6 +13,7 @@ Usage:
import ast import ast
import os import os
import re
import sys import sys
import argparse import argparse
import tempfile import tempfile
@@ -25,12 +26,33 @@ class SyntaxValidator:
self.warnings = [] self.warnings = []
self.fixed = [] self.fixed = []
def check_fstring_issues(self, content):
"""Check for common f-string formatting issues that cause syntax errors."""
issues = []
lines = content.split('\n')
for i, line in enumerate(lines, 1):
# Check for f-strings that appear to be broken across lines
# Pattern: f"some text {
if re.search(r'f"[^"]*{[^}]*$', line.strip()):
issues.append(f"Line {i}: Potential broken multiline f-string")
return issues
def validate_file(self, filepath): def validate_file(self, filepath):
"""Validate syntax of a single Python file.""" """Validate syntax of a single Python file."""
try: try:
with open(filepath, 'r', encoding='utf-8') as f: with open(filepath, 'r', encoding='utf-8') as f:
content = f.read() content = f.read()
# Check for specific f-string issues before AST parsing
fstring_issues = self.check_fstring_issues(content)
if fstring_issues:
# Create a synthetic syntax error for f-string issues
error_msg = f"F-string issues: {'; '.join(fstring_issues)}"
self.errors.append((filepath, SyntaxError(error_msg), content))
return False, content
# Parse with AST (catches syntax errors) # Parse with AST (catches syntax errors)
ast.parse(content, filename=str(filepath)) ast.parse(content, filename=str(filepath))
return True, content return True, content