11 Commits

40 changed files with 1009 additions and 100 deletions

173
README.md
View File

@ -12,6 +12,8 @@ This software is licensed under the LGPL v3.
- **Multiple Interface Support**: Works in Linux TTY, and terminal emulators
- **Flexible Driver System**: Modular architecture with multiple drivers for speech, sound, input, and screen
- **Review Mode**: Navigate and review screen content without moving the edit cursor
- **Table Navigation**: Advanced table mode with column headers, cell-by-cell navigation, and boundary feedback
- **Progress Bar Monitoring**: Automatic detection and audio feedback for progress indicators with ascending tones
- **Multiple Clipboard Support**: Manage multiple clipboard entries
- **Configurable Key Bindings**: Desktop and laptop keyboard layouts
- **Sound Icons**: Audio feedback for various events
@ -156,6 +158,7 @@ By default Fenrir uses:
- `Keypad 2` - Read current character
- `Fenrir + T` - Announce time
- `Fenrir + S` - Spell check current word
- `Fenrir + Keypad *` - Toggle table mode / highlight tracking
### Keyboard Layouts
@ -402,6 +405,176 @@ setting <action> [parameters]
- `time#delaySec=seconds` - Announcement interval
- `time#onMinutes=00,30` - Specific minutes to announce
## Table Navigation
Fenrir includes advanced table navigation capabilities for working with tabular data in terminal applications, CSV files, and formatted text output.
### Entering Table Mode
Table mode is activated through the **toggle_highlight_tracking** command, which cycles through three focus modes:
1. **Highlight tracking mode** (default) - Follows text highlighting
2. **Cursor tracking mode** - Follows text cursor movement
3. **Table mode** - Enables table navigation
**Key bindings:**
- **Desktop layout**: `Fenrir + Keypad *` (asterisk)
- **Laptop layout**: `Fenrir + Y`
Press the key combination repeatedly to cycle through modes until you hear "table mode enabled".
### Table Navigation Commands
#### Column Navigation (Desktop Layout)
- **Next column**: `Keypad 6` - Move to next table column
- **Previous column**: `Keypad 4` - Move to previous table column
- **First column**: `Fenrir + Keypad 4` - Jump to first column of current row
- **Last column**: `Fenrir + Keypad 6` - Jump to last column of current row
#### Column Navigation (Laptop Layout)
- **Next column**: `Fenrir + L` - Move to next table column
- **Previous column**: `Fenrir + J` - Move to previous table column
- **First column**: `Fenrir + Shift + J` - Jump to first column of current row
- **Last column**: `Fenrir + Shift + L` - Jump to last column of current row
#### Cell Character Navigation
- **First character in cell**: `Fenrir + Keypad 1` (desktop) or `Fenrir + Ctrl + J` (laptop)
- **Last character in cell**: `Fenrir + Keypad 3` (desktop) or `Fenrir + Ctrl + L` (laptop)
### Setting Column Headers
For better navigation experience, you can set column headers:
1. **Navigate to header row**: Use normal navigation to reach the row containing column headers
2. **Set headers**: Press `Fenrir + X` to mark the current line as the header row
3. **Navigation feedback**: Column headers will be announced along with cell content
### Table Detection
Fenrir automatically detects table structures using multiple strategies:
- **Delimited text**: CSV, pipe-separated (`|`), semicolon-separated (`;`), tab-separated
- **Aligned columns**: Space-aligned columns (2+ spaces between columns)
- **Flexible parsing**: Handles various table formats commonly found in terminal applications
### Table Mode Features
- **Cell-by-cell navigation**: Navigate through table cells with precise positioning
- **Column header support**: Set and announce column headers for better context
- **Boundary feedback**: Audio cues when reaching start/end of rows
- **Empty cell handling**: Blank cells are announced as "blank"
- **Independent tracking**: Table position is maintained independently of cursor movement
### Speech Output in Table Mode
When navigating in table mode, Fenrir announces:
- **Cell content** followed by **column header/name**
- **Boundary notifications**: "end of line", "start of line"
- **Position indicators**: "first character in cell [column name]"
### Example Usage
```bash
# Working with CSV data
cat data.csv
Name,Age,City
Alice,30,New York
Bob,25,Los Angeles
# 1. Press Fenrir + Keypad * until "table mode enabled"
# 2. Navigate to "Name,Age,City" line
# 3. Press Fenrir + X to set headers
# 4. Use Keypad 4/6 to navigate between columns
# 5. Each cell will be announced with its column header
```
## Progress Bar Monitoring
Fenrir provides intelligent progress bar detection and audio feedback for various progress indicators commonly found in terminal applications.
### Enabling Progress Monitoring
**Command**: `progress_bar_monitor` (no default key binding - assign manually)
To enable progress monitoring:
1. Add a key binding in your keyboard layout file
2. Or use the remote control system: `echo "command progress_bar_monitor" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock`
### Progress Detection Patterns
Fenrir automatically detects various progress indicator formats:
#### 1. Percentage Progress
```
Download: 45%
Processing: 67.5%
Installing: 100%
```
#### 2. Fraction Progress
```
Files: 15/100
Progress: 3 of 10
Step 7/15
```
#### 3. Progress Bars
```
[#### ] 40%
[====> ] 50%
[**********] 100%
```
#### 4. Activity Indicators
```
Loading...
Processing...
Working...
Installing...
Downloading...
Compiling...
Building...
```
### Audio Feedback
#### Progress Tones
- **Ascending tones**: 400Hz to 1200Hz frequency range
- **Percentage mapping**: 0% = 400Hz, 100% = 1200Hz
- **Smooth progression**: Frequency increases proportionally with progress
#### Activity Indicators
- **Steady beep**: 800Hz tone every 2 seconds for ongoing activity
- **Non-intrusive**: Beeps don't interrupt speech or other audio
### Progress Monitoring Features
- **Automatic detection**: No manual configuration required
- **Multiple format support**: Handles various progress indicator styles
- **Prompt awareness**: Automatically pauses when command prompts are detected
- **Non-blocking**: Progress tones don't interrupt speech or other functionality
- **Configurable**: Can be enabled/disabled as needed
### Usage Examples
```bash
# Enable progress monitoring
echo "command progress_bar_monitor" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
# Common scenarios where progress monitoring is useful:
wget https://example.com/large-file.zip # Download progress
tar -xvf archive.tar.gz # Extraction progress
make -j4 # Compilation progress
pacman -S package # Package installation
rsync -av source/ destination/ # File synchronization
```
### Customization
Progress monitoring can be configured through settings:
- **Default enabled**: Set `progressMonitoring=True` in sound section
- **Sound integration**: Works with all sound drivers (sox, gstreamer)
- **Remote control**: Enable/disable through remote commands
### Scripting Examples
#### Bash Script for Speech Notifications

View File

@ -50,7 +50,7 @@ def check_dependency(dep: Dependency) -> bool:
dependencyList = [
# Core dependencies
Dependency('FenrirCore', 'core', 'core',
pythonImports=['daemonize', 'enchant']),
pythonImports=['daemonize', 'enchant', 'pyperclip', 'setproctitle']),
# Screen drivers
Dependency('DummyScreen', 'screen', 'dummyDriver'),
@ -58,7 +58,7 @@ dependencyList = [
pythonImports=['dbus'],
devicePaths=['/dev/vcsa']),
Dependency('PTY', 'screen', 'ptyDriver',
pythonImports=['pyte']),
pythonImports=['pyte', 'xdg']),
# Input drivers
Dependency('DummyInput', 'input', 'dummyDriver'),
@ -82,7 +82,11 @@ dependencyList = [
Dependency('Speechd', 'speech', 'speechdDriver',
pythonImports=['speechd']),
Dependency('GenericSpeech', 'speech', 'genericDriver',
checkCommands=['espeak-ng'])
checkCommands=['espeak-ng']),
# Additional dependencies
Dependency('Pexpect', 'core', 'pexpectDriver',
pythonImports=['pexpect'])
]
defaultModules = {
@ -90,7 +94,8 @@ defaultModules = {
'VCSA',
'Evdev',
'GenericSpeech',
'GenericSound'
'GenericSound',
'Pexpect'
}
def check_all_dependencies():

View File

@ -1,4 +1,46 @@
# Fenrir Keyboard Configuration
This directory contains keyboard layout files for Fenrir screen reader.
## Available Layouts
- **desktop.conf** - Desktop layout using numeric keypad (recommended)
- **laptop.conf** - Laptop layout for keyboards without numeric keypad
- **nvda-desktop.conf** - NVDA-compatible desktop layout
- **nvda-laptop.conf** - NVDA-compatible laptop layout
- **pty.conf** - PTY emulation layout for terminal use
- **pty2.conf** - Alternative PTY emulation layout
## Key Features
### Table Navigation
- **Toggle table mode**: `Fenrir + Keypad *` (desktop) or `Fenrir + Y` (laptop)
- **Column navigation**: `Keypad 4/6` (desktop) or `Fenrir + J/L` (laptop)
- **Row boundaries**: `Fenrir + Keypad 4/6` (desktop) or `Fenrir + Shift + J/L` (laptop)
- **Set headers**: `Fenrir + X` in table mode
### Progress Bar Monitoring
- **Monitor progress**: `progress_bar_monitor` command (assign key binding manually)
- **Auto-detection**: Percentage, fractions, progress bars, activity indicators
- **Audio feedback**: Ascending tones (400Hz-1200Hz) for progress
### Review Mode
- **Basic navigation**: `Keypad 7/8/9` (lines), `Keypad 4/5/6` (words), `Keypad 1/2/3` (characters)
- **Exit review**: `Fenrir + Keypad .`
- **Screen reading**: `Fenrir + Keypad 5` (current screen)
## Configuration
To change keyboard layout, edit `/etc/fenrir/settings/settings.conf`:
```ini
[keyboard]
keyboardLayout=desktop # or laptop, nvda-desktop, nvda-laptop, pty, pty2
```
## Available Key Constants
Keymap for Fenrir
KEY_RESERVED
KEY_ESC

View File

@ -135,6 +135,9 @@ debugFile=
punctuationProfile=default
punctuationLevel=some
respectPunctuationPause=True
# Replace undefined punctuation with spaces instead of removing them
# This improves readability of text with punctuation like [X]mute, IP addresses, etc.
replaceUndefinedPunctuationWithSpace=True
newLinePause=True
numberOfClipboards=50
# used path for "export_clipboard_to_file"

View File

@ -50,6 +50,12 @@ Navigate the screen without moving the text cursor. Essential for examining cont
- `Keypad 1/3` - Previous/next character
- `Fenrir + Keypad dot` - Exit review mode
### Table Navigation
- `Fenrir + Keypad *` - Toggle table mode / highlight tracking
- `Keypad 4/6` - Previous/next column (in table mode)
- `Fenrir + Keypad 4/6` - First/last column (in table mode)
- `Fenrir + X` - Set column headers (in table mode)
### Information
- `Fenrir + T` - Announce time
- `Fenrir + T T` - Announce date
@ -240,6 +246,47 @@ send_fenrir_command("command say Process complete")
## Advanced Features
### Table Navigation Mode
Fenrir includes advanced table navigation capabilities for working with tabular data in terminal applications, CSV files, and formatted text output.
#### Entering Table Mode
1. Press `Fenrir + Keypad *` (desktop) or `Fenrir + Y` (laptop)
2. Cycle through: Highlight tracking → Cursor tracking → Table mode
3. Listen for "table mode enabled" announcement
#### Table Navigation Commands
- **Column navigation**: `Keypad 4/6` - Move between columns
- **Row boundaries**: `Fenrir + Keypad 4/6` - Jump to first/last column
- **Cell characters**: `Fenrir + Keypad 1/3` - First/last character in cell
- **Set headers**: `Fenrir + X` - Mark current line as column headers
#### Table Features
- **Automatic detection**: Supports CSV, pipe-separated, space-aligned columns
- **Column headers**: Set and announce headers for better context
- **Boundary feedback**: Audio cues when reaching row boundaries
- **Cell-by-cell navigation**: Precise positioning within tables
### Progress Bar Monitoring
Fenrir automatically detects and provides audio feedback for progress indicators.
#### Progress Detection
- **Percentage**: 45%, 67.5%, 100%
- **Fractions**: 15/100, 3 of 10, Step 7/15
- **Progress bars**: [#### ], [====> ]
- **Activity indicators**: Loading..., Processing...
#### Audio Feedback
- **Progress tones**: Ascending 400Hz-1200Hz frequency range
- **Activity beeps**: 800Hz tone every 2 seconds
- **Non-intrusive**: Doesn't interrupt speech or other audio
#### Usage
- **Enable**: Use `progress_bar_monitor` command (assign key binding)
- **Automatic**: Works with downloads, compilations, installations
- **Remote control**: Enable via socket commands
### Spell Checking
- `Fenrir + S` - Spell check current word
- `Fenrir + S S` - Add word to dictionary

View File

@ -20,7 +20,8 @@ class command:
def get_description(self):
return _(
"read line to cursor pos, use review cursor if you are in review mode, otherwhise use text cursor"
"read line to cursor pos, use review cursor if you are in review mode, "
"otherwhise use text cursor"
)
def run(self):

View File

@ -20,7 +20,8 @@ class command:
def get_description(self):
return _(
"read to end of line, use review cursor if you are in review mode, otherwhise use text cursor"
"read to end of line, use review cursor if you are in review mode, "
"otherwhise use text cursor"
)
def run(self):

View File

@ -82,7 +82,8 @@ class command:
else:
self.env["runtime"]["OutputManager"].present_text(
_(
"failed to export to X clipboard. No available display found."
"failed to export to X clipboard. No available display "
"found."
),
interrupt=True,
)

View File

@ -20,7 +20,8 @@ class command:
def get_description(self):
return _(
"Presents the currently selected text that will be copied to the clipboard"
"Presents the currently selected text that will be copied to the "
"clipboard"
)
def run(self):

View File

@ -131,7 +131,10 @@ class command:
# Pattern 4: Generic activity indicators (Loading..., Working..., etc.)
activity_pattern = re.search(
r"(loading|processing|working|installing|downloading|compiling|building).*\.{2,}",
(
r"(loading|processing|working|installing|downloading|"
r"compiling|building).*\.{2,}"
),
text,
re.IGNORECASE,
)

View File

@ -31,14 +31,21 @@ class command:
table_info = self.env["runtime"]["TableManager"].get_current_table_cell_info()
if table_info:
cursor_pos = self.env["screen"]["newCursorReview"]
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"])
line_text = self.env["runtime"]["ScreenManager"].get_line_text(
cursor_pos["y"]
)
if line_text:
column_start = self.env["runtime"]["TableManager"].get_column_start_position(line_text, table_info["column_index"])
column_start = self.env["runtime"]["TableManager"].get_column_start_position(
line_text, table_info["column_index"]
)
cell_content = table_info["cell_content"]
cell_end = column_start + len(cell_content)
# If cursor is outside the current cell, move to cell start
if cursor_pos["x"] < column_start or cursor_pos["x"] >= cell_end:
if (
cursor_pos["x"] < column_start or
cursor_pos["x"] >= cell_end
):
self.env["screen"]["newCursorReview"]["x"] = column_start
(

View File

@ -47,10 +47,14 @@ class command:
)
if is_table_mode:
# Get current cell info using internal column tracking
table_info = self.env["runtime"]["TableManager"].get_current_table_cell_info()
table_info = (
self.env["runtime"]["TableManager"].get_current_table_cell_info()
)
if table_info:
# Announce with table context - cell content first, then header
output_text = f"{table_info['cell_content']} {table_info['column_header']}"
output_text = (
f"{table_info['cell_content']} {table_info['column_header']}"
)
self.env["runtime"]["OutputManager"].present_text(
output_text, interrupt=True, flush=False
)

View File

@ -24,6 +24,27 @@ class command:
)
def run(self):
# Check if we're in table mode first
is_table_mode = self.env["runtime"]["TableManager"].is_table_mode()
if is_table_mode:
table_info = self.env["runtime"]["TableManager"].move_to_first_cell()
if table_info:
output_text = (
f"{table_info['cell_content']} {table_info['column_header']}"
)
self.env["runtime"]["OutputManager"].present_text(
output_text, interrupt=True, flush=False
)
self.env["runtime"]["OutputManager"].present_text(
_("first cell"), interrupt=False
)
else:
self.env["runtime"]["OutputManager"].present_text(
_("no table data"), interrupt=True, flush=False
)
return
# Regular line begin navigation (when not in table mode)
cursor_pos = self.env["runtime"][
"CursorManager"
].get_review_or_text_cursor()

View File

@ -24,6 +24,27 @@ class command:
)
def run(self):
# Check if we're in table mode first
is_table_mode = self.env["runtime"]["TableManager"].is_table_mode()
if is_table_mode:
table_info = self.env["runtime"]["TableManager"].move_to_last_cell()
if table_info:
output_text = (
f"{table_info['cell_content']} {table_info['column_header']}"
)
self.env["runtime"]["OutputManager"].present_text(
output_text, interrupt=True, flush=False
)
self.env["runtime"]["OutputManager"].present_text(
_("last cell"), interrupt=False
)
else:
self.env["runtime"]["OutputManager"].present_text(
_("no table data"), interrupt=True, flush=False
)
return
# Regular line end navigation (when not in table mode)
cursor_pos = self.env["runtime"][
"CursorManager"
].get_review_or_text_cursor()

View File

@ -23,6 +23,29 @@ class command:
return _("Move Review to the first character on the line")
def run(self):
# Check if we're in table mode first
is_table_mode = self.env["runtime"]["TableManager"].is_table_mode()
if is_table_mode:
table_info = self.env["runtime"]["TableManager"].move_to_first_char_in_cell()
if table_info:
char_utils.present_char_for_review(
self.env,
table_info['character'],
interrupt=True,
announce_capital=True,
flush=False,
)
self.env["runtime"]["OutputManager"].present_text(
_("first character in cell {0}").format(table_info['column_header']),
interrupt=False,
)
else:
self.env["runtime"]["OutputManager"].present_text(
_("no table data"), interrupt=True, flush=False
)
return
# Regular line first character navigation (when not in table mode)
cursor_pos = self.env["runtime"][
"CursorManager"
].get_review_or_text_cursor()

View File

@ -22,6 +22,29 @@ class command:
return _("Move Review to the last character on the line")
def run(self):
# Check if we're in table mode first
is_table_mode = self.env["runtime"]["TableManager"].is_table_mode()
if is_table_mode:
table_info = self.env["runtime"]["TableManager"].move_to_last_char_in_cell()
if table_info:
char_utils.present_char_for_review(
self.env,
table_info['character'],
interrupt=True,
announce_capital=True,
flush=False,
)
self.env["runtime"]["OutputManager"].present_text(
_("last character in cell {0}").format(table_info['column_header']),
interrupt=False,
)
else:
self.env["runtime"]["OutputManager"].present_text(
_("no table data"), interrupt=True, flush=False
)
return
# Regular line last character navigation (when not in table mode)
cursor_pos = self.env["runtime"][
"CursorManager"
].get_review_or_text_cursor()

View File

@ -31,9 +31,13 @@ class command:
table_info = self.env["runtime"]["TableManager"].get_current_table_cell_info()
if table_info:
cursor_pos = self.env["screen"]["newCursorReview"]
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"])
line_text = self.env["runtime"]["ScreenManager"].get_line_text(
cursor_pos["y"]
)
if line_text:
column_start = self.env["runtime"]["TableManager"].get_column_start_position(line_text, table_info["column_index"])
column_start = self.env["runtime"]["TableManager"].get_column_start_position(
line_text, table_info["column_index"]
)
cell_content = table_info["cell_content"]
cell_end = column_start + len(cell_content)
@ -48,7 +52,9 @@ class command:
flush=False,
)
self.env["runtime"]["OutputManager"].present_text(
_("end of cell"), interrupt=False, sound_icon="EndOfLine"
_("end of cell"),
interrupt=False,
sound_icon="EndOfLine"
)
return
@ -56,7 +62,9 @@ class command:
relative_pos = cursor_pos["x"] - column_start
if relative_pos < len(cell_content) - 1:
new_relative_pos = relative_pos + 1
self.env["screen"]["newCursorReview"]["x"] = column_start + new_relative_pos
self.env["screen"]["newCursorReview"]["x"] = (
column_start + new_relative_pos
)
# Get character at new position
if new_relative_pos < len(cell_content):

View File

@ -30,7 +30,9 @@ class command:
debug.DebugLevel.INFO
)
if is_table_mode:
table_info = self.env["runtime"]["TableManager"].move_to_next_column()
table_info = (
self.env["runtime"]["TableManager"].move_to_next_column()
)
if table_info and table_info.get("at_end"):
# Stay on current cell and play end of line sound
current_info = table_info["current_info"]
@ -41,11 +43,15 @@ class command:
)
# Play end of line sound
self.env["runtime"]["OutputManager"].present_text(
_("end of line"), interrupt=False, sound_icon="EndOfLine"
_("end of line"),
interrupt=False,
sound_icon="EndOfLine"
)
elif table_info:
# Normal column navigation - announce cell content with column info
output_text = f"{table_info['cell_content']} {table_info['column_header']}"
output_text = (
f"{table_info['cell_content']} {table_info['column_header']}"
)
self.env["runtime"]["OutputManager"].present_text(
output_text, interrupt=True, flush=False
)

View File

@ -35,22 +35,31 @@ class command:
table_info = self.env["runtime"]["TableManager"].get_current_table_cell_info()
if table_info:
cursor_pos = self.env["screen"]["newCursorReview"]
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"])
line_text = self.env["runtime"]["ScreenManager"].get_line_text(
cursor_pos["y"]
)
if line_text:
column_start = self.env["runtime"]["TableManager"].get_column_start_position(line_text, table_info["column_index"])
column_start = self.env["runtime"]["TableManager"].get_column_start_position(
line_text, table_info["column_index"]
)
# Check if we're already at the start of the cell
if cursor_pos["x"] <= column_start:
# At cell boundary - announce start and don't move
char_utils.present_char_for_review(
self.env,
table_info["cell_content"][0] if table_info["cell_content"] else "",
(
table_info["cell_content"][0]
if table_info["cell_content"] else ""
),
interrupt=True,
announce_capital=True,
flush=False,
)
self.env["runtime"]["OutputManager"].present_text(
_("start of cell"), interrupt=False, sound_icon="StartOfLine"
_("start of cell"),
interrupt=False,
sound_icon="StartOfLine"
)
return
@ -59,7 +68,9 @@ class command:
relative_pos = cursor_pos["x"] - column_start
if relative_pos > 0:
new_relative_pos = relative_pos - 1
self.env["screen"]["newCursorReview"]["x"] = column_start + new_relative_pos
self.env["screen"]["newCursorReview"]["x"] = (
column_start + new_relative_pos
)
# Get character at new position
if new_relative_pos < len(cell_content):

View File

@ -30,22 +30,30 @@ class command:
debug.DebugLevel.INFO
)
if is_table_mode:
table_info = self.env["runtime"]["TableManager"].move_to_prev_column()
table_info = (
self.env["runtime"]["TableManager"].move_to_prev_column()
)
if table_info and table_info.get("at_start"):
# Stay on current cell at beginning of line
current_info = table_info["current_info"]
if current_info:
output_text = f"{current_info['cell_content']} {current_info['column_header']}"
output_text = (
f"{current_info['cell_content']} {current_info['column_header']}"
)
self.env["runtime"]["OutputManager"].present_text(
output_text, interrupt=True, flush=False
)
# Play start of line sound
self.env["runtime"]["OutputManager"].present_text(
_("start of line"), interrupt=False, sound_icon="StartOfLine"
_("start of line"),
interrupt=False,
sound_icon="StartOfLine"
)
elif table_info:
# Normal column navigation - announce cell content with column info
output_text = f"{table_info['cell_content']} {table_info['column_header']}"
output_text = (
f"{table_info['cell_content']} {table_info['column_header']}"
)
self.env["runtime"]["OutputManager"].present_text(
output_text, interrupt=True, flush=False
)

View File

@ -19,7 +19,8 @@ class command:
def get_description(self):
return _(
"Enables or disables automatic reading of time after specified intervals"
"Enables or disables automatic reading of time after specified "
"intervals"
)
def run(self):

View File

@ -31,7 +31,8 @@ class command:
table_mode = self.env["runtime"]["TableManager"].is_table_mode()
self.env["runtime"]["DebugManager"].write_debug_out(
f"toggle_highlight_tracking: highlight={highlight_mode}, cursor={cursor_mode}, table={table_mode}",
f"toggle_highlight_tracking: highlight={highlight_mode}, "
f"cursor={cursor_mode}, table={table_mode}",
debug.DebugLevel.INFO
)

View File

@ -29,7 +29,9 @@ class command:
if self.env["runtime"]["HelpManager"].is_tutorial_mode():
self.env["runtime"]["OutputManager"].present_text(
_(
"Entering tutorial mode. In this mode commands are described but not executed. You can move through the list of commands with the up and down arrow keys. To Exit tutorial mode press Fenrir+f1."
"Entering tutorial mode. In this mode commands are described but not "
"executed. You can move through the list of commands with the up and "
"down arrow keys. To Exit tutorial mode press Fenrir+f1."
),
interrupt=True,
)

View File

@ -30,7 +30,8 @@ class command:
return
if self.env["runtime"]["AttributeManager"].is_attribute_change():
return
# hack for pdmenu and maybe other dialog apps that place the cursor at last cell/row
# hack for pdmenu and maybe other dialog apps that place the cursor at
# last cell/row
# this is not to be identified as history
if (
self.env["screen"]["new_cursor"]["x"]

View File

@ -99,6 +99,13 @@ class command:
"Progress detector checking: '" + text + "'", debug.DebugLevel.INFO
)
# Filter out URLs to prevent false positives
if self.contains_url(text):
self.env["runtime"]["DebugManager"].write_debug_out(
"Skipping progress detection - text contains URL", debug.DebugLevel.INFO
)
return
# Note: Auto-disable on 100% completion removed to respect user
# settings
@ -147,8 +154,16 @@ class command:
curl_match = re.search(
r"(\d+\s+\d+\s+\d+\s+\d+.*?(?:k|M|G)?.*?--:--:--|Speed)", text
)
# Pattern 1e: General transfer progress (size, rate, time patterns)
transfer_match = re.search(
r"\d+\s+\d+[kMGT]?\s+\d+\s+\d+[kMGT]?.*?\d+\.\d+[kMGT].*?\d+:\d+:\d+", text
)
# Pattern 1f: Pacman-style transfer progress (flexible size/speed/time)
pacman_match = re.search(
r"\d+(?:\.\d+)?\s+[kKmMgGtT]iB\s+\d+(?:\.\d+)?\s+[kKmMgGtT]iB/s\s+\d+:\d+", text
)
if time_match or token_match or dd_match or curl_match:
if time_match or token_match or dd_match or curl_match or transfer_match or pacman_match:
# For non-percentage progress, use a single activity beep every 2
# seconds
if (
@ -183,7 +198,7 @@ class command:
# Pattern 3: Progress bars ([#### ], [====> ], etc.)
# Improved pattern to avoid matching IRC channels like [#channel]
bar_match = re.search(r"\[([#=\-\*]+)([\s\.]*)\]", text)
bar_match = re.search(r"\[([#=\*]+)([\s\.\-]*)\]", text)
if bar_match:
filled = len(bar_match.group(1))
unfilled = len(bar_match.group(2))
@ -350,5 +365,22 @@ class command:
# If anything fails, assume it's not a prompt to be safe
return False
def contains_url(self, text):
"""Check if text contains URLs that might cause false progress detection"""
import re
# Common URL patterns that might contain progress-like patterns
url_patterns = [
r"https?://[^\s]+", # http:// or https:// URLs
r"ftp://[^\s]+", # ftp:// URLs
r"www\.[^\s]+", # www. domains
r"[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}[/\w.-]*", # domain.com/path patterns
]
for pattern in url_patterns:
if re.search(pattern, text, re.IGNORECASE):
return True
return False
def set_callback(self, callback):
pass

View File

@ -20,22 +20,15 @@ class command:
pass
def get_description(self):
return _("presents the date")
return _("Test mc search functionality")
def run(self):
date_format = self.env["runtime"]["SettingsManager"].get_setting(
"general", "date_format"
)
# get the time formatted
date_string = datetime.datetime.strftime(
datetime.datetime.now(), date_format
)
# present the time via speak and braile, there is no soundicon,
# interrupt the current speech
# Test command for mc search operations
test_message = _("MC search test: This demonstrates search functionality")
# present the test message
self.env["runtime"]["OutputManager"].present_text(
date_string, sound_icon="", interrupt=True
test_message, sound_icon="", interrupt=True
)
def set_callback(self, callback):

View File

@ -20,22 +20,15 @@ class command:
pass
def get_description(self):
return _("presents the date")
return _("Test mutt search functionality")
def run(self):
date_format = self.env["runtime"]["SettingsManager"].get_setting(
"general", "date_format"
)
# get the time formatted
date_string = datetime.datetime.strftime(
datetime.datetime.now(), date_format
)
# present the time via speak and braile, there is no soundicon,
# interrupt the current speech
# Test command for mutt search operations
test_message = _("Mutt search test: This demonstrates search functionality")
# present the test message
self.env["runtime"]["OutputManager"].present_text(
date_string, sound_icon="", interrupt=True
test_message, sound_icon="", interrupt=True
)
def set_callback(self, callback):

View File

@ -20,22 +20,15 @@ class command:
pass
def get_description(self):
return _("presents the date")
return _("Test nano search functionality")
def run(self):
date_format = self.env["runtime"]["SettingsManager"].get_setting(
"general", "date_format"
)
# get the time formatted
date_string = datetime.datetime.strftime(
datetime.datetime.now(), date_format
)
# present the time via speak and braile, there is no soundicon,
# interrupt the current speech
# Test command for nano search operations
test_message = _("Nano search test: This demonstrates search functionality")
# present the test message
self.env["runtime"]["OutputManager"].present_text(
date_string, sound_icon="", interrupt=True
test_message, sound_icon="", interrupt=True
)
def set_callback(self, callback):

View File

@ -20,22 +20,15 @@ class command:
pass
def get_description(self):
return _("presents the date")
return _("Test vim search functionality")
def run(self):
date_format = self.env["runtime"]["SettingsManager"].get_setting(
"general", "date_format"
)
# get the time formatted
date_string = datetime.datetime.strftime(
datetime.datetime.now(), date_format
)
# present the time via speak and braile, there is no soundicon,
# interrupt the current speech
# Test command for vim search operations
test_message = _("Vim search test: This demonstrates search functionality")
# present the test message
self.env["runtime"]["OutputManager"].present_text(
date_string, sound_icon="", interrupt=True
test_message, sound_icon="", interrupt=True
)
def set_callback(self, callback):

View File

@ -322,6 +322,9 @@ class AttributeManager:
This is crucial for screen readers to announce when text becomes highlighted,
selected, or changes visual emphasis (bold, reverse video, color changes, etc.)
Enhanced version includes bracket pattern detection for better context in applications
like ninjam that use patterns like [X]mute or [ ]mute.
Returns:
tuple: (highlighted_text, cursor_position)
- highlighted_text: string of characters that gained highlighting
@ -352,6 +355,9 @@ class AttributeManager:
if len(text_lines) != len(self.currAttributes):
return result, curr_cursor
# Track highlighted positions for context analysis
highlighted_positions = []
# Compare attributes line by line, character by character
for line in range(len(self.prevAttributes)):
if self.prevAttributes[line] != self.currAttributes[line]:
@ -373,13 +379,316 @@ class AttributeManager:
# for navigation
if not curr_cursor:
curr_cursor = {"x": column, "y": line}
# Store position for context analysis
highlighted_positions.append((line, column))
# Accumulate highlighted characters
result += text_lines[line][column]
# Add space between lines of highlighted text for speech
# clarity
result += " "
# Enhanced bracket pattern detection for better context
if highlighted_positions:
try:
enhanced_result = self._detect_bracket_context(text_lines, highlighted_positions, result)
if enhanced_result and enhanced_result != result:
# Debug logging for bracket detection
try:
self.env["runtime"]["DebugManager"].write_debug_out(
f"AttributeManager bracket detection: Original='{result}' Enhanced='{enhanced_result}'",
debug.DebugLevel.INFO
)
except Exception:
pass # Don't let debug logging break functionality
result = enhanced_result
except Exception as e:
# If bracket detection fails, fall back to original result
try:
self.env["runtime"]["DebugManager"].write_debug_out(
f"AttributeManager bracket detection error: {e}",
debug.DebugLevel.ERROR
)
except Exception:
pass # Don't let debug logging break functionality
return result, curr_cursor
def _detect_bracket_context(self, text_lines, highlighted_positions, original_result):
"""
Analyzes highlighted positions to detect bracket patterns and provide better context.
This method specifically looks for patterns like [X]mute, [ ]mute, [X]xmit, etc.
that are common in applications like ninjam where the bracket content indicates
state but the meaningful context is the surrounding text.
Args:
text_lines: List of text lines from the screen
highlighted_positions: List of (line, column) tuples of highlighted characters
original_result: The original highlighted text result
Returns:
str: Enhanced result with context, or None if no bracket pattern detected
"""
if not highlighted_positions:
return None
# Group positions by line for easier analysis
positions_by_line = {}
for line, column in highlighted_positions:
if line not in positions_by_line:
positions_by_line[line] = []
positions_by_line[line].append(column)
enhanced_results = []
for line_num, columns in positions_by_line.items():
if line_num >= len(text_lines):
continue
line_text = text_lines[line_num]
columns.sort() # Process columns in order
# Look for bracket patterns in this line
bracket_context = self._analyze_bracket_pattern(line_text, columns)
if bracket_context:
enhanced_results.append(bracket_context)
# If we found enhanced context, return it; otherwise return None to use original
if enhanced_results:
# Remove duplicates while preserving order
unique_results = []
seen = set()
for result in enhanced_results:
if result not in seen:
unique_results.append(result)
seen.add(result)
return " ".join(unique_results)
return None
def _analyze_bracket_pattern(self, line_text, highlighted_columns):
"""
Analyzes a single line for bracket patterns around highlighted positions.
Looks for patterns like:
- master: [X]mute -> "master mute on"
- metronome: [ ]mute -> "metronome mute off"
- [Left] [X]xmit -> "Left xmit on"
Args:
line_text: The text of the line
highlighted_columns: List of column positions that are highlighted
Returns:
str: Context-aware description, or None if no pattern found
"""
if not line_text or not highlighted_columns:
return None
# Look for bracket patterns around highlighted positions
# Process columns in order and only return the first meaningful match
for col in highlighted_columns:
bracket_info = self._find_bracket_at_position(line_text, col)
if bracket_info:
bracket_start, bracket_end, bracket_content = bracket_info
# Get context before and after the bracket
context_before = self._get_context_before(line_text, bracket_start)
context_after = self._get_context_after(line_text, bracket_end)
# Build meaningful description
description = self._build_bracket_description(
context_before, bracket_content, context_after
)
# Only return if we have meaningful context (not just state)
if description and (context_before or context_after):
return description
return None
def _find_bracket_at_position(self, line_text, position):
"""
Determines if a position is within a bracket pattern like [X] or [ ].
Args:
line_text: The text line
position: Column position to check
Returns:
tuple: (bracket_start, bracket_end, bracket_content) or None
"""
if position >= len(line_text):
return None
# Look for opening bracket before or at position
bracket_start = None
for i in range(max(0, position - 2), min(len(line_text), position + 3)):
if line_text[i] == '[':
bracket_start = i
break
if bracket_start is None:
return None
# Look for closing bracket after bracket_start
bracket_end = None
for i in range(bracket_start + 1, min(len(line_text), bracket_start + 5)):
if line_text[i] == ']':
bracket_end = i
break
if bracket_end is None:
return None
# Check if our position is within the bracket
if bracket_start <= position <= bracket_end:
bracket_content = line_text[bracket_start + 1:bracket_end]
# Filter out brackets that are likely not controls
# Skip brackets that contain complex content like "[-10.5dB center]"
if len(bracket_content) > 3 and ('dB' in bracket_content or 'Hz' in bracket_content):
return None
return bracket_start, bracket_end, bracket_content
return None
def _get_context_before(self, line_text, bracket_start):
"""
Gets meaningful context words before a bracket.
Args:
line_text: The text line
bracket_start: Position of opening bracket
Returns:
str: Context words before bracket, or empty string
"""
if bracket_start <= 0:
return ""
# Get text before bracket
before_text = line_text[:bracket_start].rstrip()
# Special handling for common ninjam patterns
# Look for patterns like "master: " or "metronome: " or bracketed labels
# Check if we have a colon-separated label immediately before
if ':' in before_text:
# Get the last colon-separated part
parts = before_text.split('|') # Split by pipe first
last_part = parts[-1].strip()
if ':' in last_part:
# Extract the label before the colon
label_parts = last_part.split(':')
if len(label_parts) >= 2:
return label_parts[-2].strip()
# Look for bracketed content that might be a label
# Pattern: [Something] [X]target -> "Something"
bracket_matches = []
i = 0
while i < len(before_text):
if before_text[i] == '[':
start = i
i += 1
while i < len(before_text) and before_text[i] != ']':
i += 1
if i < len(before_text): # Found closing bracket
content = before_text[start+1:i]
if content.strip():
bracket_matches.append(content.strip())
i += 1
# If we found bracketed content, use the last one
if bracket_matches:
return bracket_matches[-1]
# Fall back to removing bracketed content and getting words
cleaned_text = ""
bracket_level = 0
for char in before_text:
if char == '[':
bracket_level += 1
elif char == ']':
bracket_level -= 1
elif bracket_level == 0:
cleaned_text += char
# Clean up separators and get meaningful words
words = []
for word in cleaned_text.replace(':', '').replace('|', '').strip().split():
if word and not word.startswith('[') and not word.endswith(']'):
words.append(word)
# Return last word as context
if words:
return words[-1]
return ""
def _get_context_after(self, line_text, bracket_end):
"""
Gets meaningful context words after a bracket.
Args:
line_text: The text line
bracket_end: Position of closing bracket
Returns:
str: Context words after bracket, or empty string
"""
if bracket_end >= len(line_text) - 1:
return ""
# Get text after bracket and find first meaningful word
after_text = line_text[bracket_end + 1:].lstrip()
# Get first word, removing common separators
words = after_text.replace(':', '').replace('|', '').strip().split()
if words:
return words[0]
return ""
def _build_bracket_description(self, context_before, bracket_content, context_after):
"""
Builds a human-readable description from bracket context.
Args:
context_before: Words before the bracket
bracket_content: Content inside brackets (X, space, etc.)
context_after: Words after the bracket
Returns:
str: Human-readable description
"""
# Clean up bracket content
bracket_content = bracket_content.strip()
# Determine state based on bracket content
if bracket_content == 'X':
state = "on"
elif bracket_content == '' or bracket_content == ' ':
state = "off"
else:
state = bracket_content # For other patterns
# Build description prioritizing context_before, then context_after
# Add spaces between components for better speech clarity
components = []
if context_before:
components.append(context_before)
if context_after:
components.append(context_after)
components.append(state)
# Join with spaces for better speech flow
return " ".join(components)
def is_useful_for_tracking(
self,
line,

View File

@ -64,7 +64,7 @@ class ProcessManager:
args=(event_queue, function, pargs, run_once),
)
self._Processes.append(t)
else: # thread not implemented yet
else: # use thread instead of process
t = Thread(
target=self.custom_event_worker_thread,
args=(event_queue, function, pargs, run_once),

View File

@ -49,11 +49,38 @@ class PunctuationManager:
def remove_unused(self, text, curr_level=""):
# dont translate dot and comma because they create a pause
curr_all_punct_none = self.allPunctNone.copy()
# Check if we should replace undefined punctuation with spaces
replace_with_space = self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"general", "replaceUndefinedPunctuationWithSpace"
)
# If the setting is disabled, use the old behavior (remove completely)
if not replace_with_space:
# Create a map that removes undefined punctuation instead of replacing with spaces
curr_all_punct_none = dict.fromkeys(
map(ord, string.punctuation + "§ "), None
)
# Restore the pause-important characters
for char in [
ord("'"),
ord("."),
ord(","),
ord(";"),
ord(":"),
ord("?"),
ord("!"),
ord("-"),
]:
curr_all_punct_none[char] = chr(char)
# Remove characters that are defined in the current punctuation level
for char in curr_level:
try:
del curr_all_punct_none[ord(char)]
except Exception as e:
pass
return text.translate(curr_all_punct_none)
def use_custom_dict(self, text, customDict, seperator=""):

View File

@ -50,6 +50,7 @@ settings_data = {
"punctuationProfile": "default",
"punctuationLevel": "some",
"respectPunctuationPause": True,
"replaceUndefinedPunctuationWithSpace": True,
"newLinePause": True,
"numberOfClipboards": 10,
"emoticons": True,

View File

@ -494,11 +494,14 @@ class SettingsManager:
self.set_setting("general", "debug_level", 3)
self.set_setting("general", "debug_mode", "PRINT")
if cliArgs.emulated_pty:
self.set_setting("screen", "driver", "ptyDriver")
self.set_setting("keyboard", "driver", "ptyDriver")
# TODO needs cleanup use dict
# self.set_option_arg_dict('keyboard', 'keyboardLayout', 'pty')
self.set_setting("keyboard", "keyboardLayout", "pty")
# Set PTY driver settings
pty_settings = {
"screen": {"driver": "ptyDriver"},
"keyboard": {"driver": "ptyDriver", "keyboardLayout": "pty"}
}
for section, settings in pty_settings.items():
for key, value in settings.items():
self.set_setting(section, key, value)
if cliArgs.emulated_evdev:
self.set_setting("screen", "driver", "ptyDriver")
self.set_setting("keyboard", "driver", "evdevDriver")

View File

@ -382,6 +382,151 @@ class TableManager:
# Check if cursor is within the column bounds
return column_start <= cursor_x < column_end
def move_to_first_cell(self):
"""Move to first cell in current row"""
if not self.env["runtime"]["CursorManager"].is_review_mode():
return None
cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor()
if not cursor_pos:
return None
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"])
if not line_text:
return None
columns = self.parse_line_into_columns(line_text)
if not columns:
return None
# Set current column to first column
self.currentColumn = 0
# Return info for the first column
return self.get_table_cell_info_by_indices(cursor_pos["y"], 0)
def move_to_last_cell(self):
"""Move to last cell in current row"""
if not self.env["runtime"]["CursorManager"].is_review_mode():
return None
cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor()
if not cursor_pos:
return None
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"])
if not line_text:
return None
columns = self.parse_line_into_columns(line_text)
if not columns:
return None
# Set current column to last column
self.currentColumn = len(columns) - 1
# Return info for the last column
return self.get_table_cell_info_by_indices(cursor_pos["y"], self.currentColumn)
def move_to_first_char_in_cell(self):
"""Move to first character in current cell"""
if not self.env["runtime"]["CursorManager"].is_review_mode():
return None
cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor()
if not cursor_pos:
return None
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"])
if not line_text:
return None
columns = self.parse_line_into_columns(line_text)
if not columns or self.currentColumn < 0 or self.currentColumn >= len(columns):
return None
# Get column start position
column_start = self.get_column_start_position(line_text, self.currentColumn)
# Find first non-space character in the column
column_text = columns[self.currentColumn]
first_char_offset = len(column_text) - len(column_text.lstrip())
# Set cursor position to first character in cell
new_x = column_start + first_char_offset
self.env["runtime"]["CursorManager"].set_review_cursor_position(new_x, cursor_pos["y"])
# Get the character at the new position
from fenrirscreenreader.utils import char_utils
(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
curr_char,
) = char_utils.get_current_char(
new_x,
cursor_pos["y"],
self.env["screen"]["new_content_text"],
)
return {
'cell_content': column_text.strip(),
'column_header': self.get_column_header(self.currentColumn),
'character': curr_char,
'position': 'first'
}
def move_to_last_char_in_cell(self):
"""Move to last character in current cell"""
if not self.env["runtime"]["CursorManager"].is_review_mode():
return None
cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor()
if not cursor_pos:
return None
line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"])
if not line_text:
return None
columns = self.parse_line_into_columns(line_text)
if not columns or self.currentColumn < 0 or self.currentColumn >= len(columns):
return None
# Get column start position
column_start = self.get_column_start_position(line_text, self.currentColumn)
column_text = columns[self.currentColumn]
# Find last non-space character in the column
trimmed_text = column_text.rstrip()
if not trimmed_text:
# If empty cell, go to column start
new_x = column_start
else:
# Find the position of the last character
new_x = column_start + len(trimmed_text) - 1
# Set cursor position to last character in cell
self.env["runtime"]["CursorManager"].set_review_cursor_position(new_x, cursor_pos["y"])
# Get the character at the new position
from fenrirscreenreader.utils import char_utils
(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
curr_char,
) = char_utils.get_current_char(
new_x,
cursor_pos["y"],
self.env["screen"]["new_content_text"],
)
return {
'cell_content': column_text.strip(),
'column_header': self.get_column_header(self.currentColumn),
'character': curr_char,
'position': 'last'
}
def reset_table_mode(self):
self.set_head_line()

View File

@ -4,5 +4,5 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
version = "2025.07.08"
version = "2025.07.16"
code_name = "master"

View File

@ -73,7 +73,8 @@ class driver(remoteDriver):
rawdata = client_sock.recv(8129)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"unixDriver watch_dog: Error receiving data from client: "
"unixDriver watch_dog: Error receiving data from "
"client: "
+ str(e),
debug.DebugLevel.ERROR,
)

View File

@ -66,7 +66,8 @@ class Terminal:
# Terminal class doesn't have access to env, use fallback
# logging
print(
f"ptyDriver Terminal update_attributes: Error accessing attributes: {e}"
f"ptyDriver Terminal update_attributes: Error accessing "
f"attributes: {e}"
)
self.attributes.append([])

View File

@ -55,7 +55,9 @@ class driver(sound_driver):
if self.soundFileCommand == "":
self.soundFileCommand = "play -q -v fenrirVolume fenrirSoundFile"
if self.frequenceCommand == "":
self.frequenceCommand = "play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence"
self.frequenceCommand = (
"play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence"
)
self._initialized = True
def play_frequence(

View File

@ -12,9 +12,11 @@ from fenrirscreenreader.core.speechDriver import speech_driver
class driver(speech_driver):
"""Speech-dispatcher driver for Fenrir screen reader.
This driver provides text-to-speech functionality through speech-dispatcher,
This driver provides text-to-speech functionality through
speech-dispatcher,
which acts as a common interface to various TTS engines. It supports voice
selection, speech parameters (rate, pitch, volume), and multiple TTS modules.
selection, speech parameters (rate, pitch, volume), and multiple TTS
modules.
Features:
- Dynamic voice switching and parameter adjustment