5 Commits

Author SHA1 Message Date
Storm Dragon fed8f24126 Add X11 terminal input mode 2026-05-07 01:45:00 -04:00
Storm Dragon 40120f825c 2026.04.04 merged 2026-04-04 10:08:46 -04:00
Storm Dragon 17dea6b026 Fix IRC incoming announcements and add focus.tui default 2026-03-25 03:24:29 -04:00
Storm Dragon 57c09e0db9 Guard evdev uinput setup 2026-03-04 04:01:19 -05:00
Storm Dragon 4050c32a16 Add speech-first diff review mode with navigation and tests 2026-02-15 15:55:46 -05:00
41 changed files with 2778 additions and 7 deletions
+18
View File
@@ -713,6 +713,8 @@ fenrir [OPTIONS]
- `-p, --print` - Print debug messages to screen
- `-e, --emulated-pty` - Use PTY emulation with escape sequences for input (enables desktop/X/Wayland usage)
- `-E, --emulated-evdev` - Use PTY emulation with evdev for input (single instance)
- `-x, --x11` - Use PTY emulation with X11 keyboard input scoped to the terminal window
- `--x11-window-id WINDOWID` - X11 window id to use for `--x11` terminal mode
- `-F, --force-all-screens` - Force Fenrir to respond on all screens, ignoring ignore_screen setting
- `-i, -I, --ignore-screen SCREEN` - Ignore specific screen(s). Can be used multiple times. Combines with existing ignore settings.
@@ -724,6 +726,12 @@ sudo fenrir -f -d
# Use PTY emulation for desktop use
sudo fenrir -e
# Use PTY emulation with X11 terminal-scoped keybindings
fenrir -x
# Use X11 mode with an explicit terminal window id
fenrir -x --x11-window-id 0x123456
# Override settings via command line
sudo fenrir -o "speech#rate=0.8;sound#volume=0.5"
@@ -735,6 +743,16 @@ sudo fenrir --ignore-screen 1
sudo fenrir -i 1 -i 2 # Ignore screens 1 and 2
```
### X11 Terminal Mode
`fenrir -x` runs Fenrir inside a GUI terminal without root. It uses the PTY screen driver for terminal contents and the X11 input driver for Fenrir keybindings. Unlike evdev mode, it does not listen to the whole desktop; key grabs are scoped to the target terminal window so desktop screen readers can keep their own global bindings outside that terminal.
By default, Fenrir targets the X11 window from `WINDOWID`, falling back to the active X11 window. If a terminal does not set `WINDOWID`, pass the window explicitly with `--x11-window-id`.
X11 terminal mode uses the normal keyboard layout files, including desktop and laptop bindings. It supports Fenrir keys such as numpad Insert/`KEY_KP0`, CapsLock/`KEY_CAPSLOCK`, Insert/`KEY_INSERT`, and Super/Windows/`KEY_META`. Script keys such as Compose use `KEY_COMPOSE`.
This mode requires `python-xlib`.
## Localization
Translation files are located in the `locale/` directory. To install translations:
+1
View File
@@ -83,6 +83,7 @@ KEY_FENRIR,KEY_CTRL,KEY_P=toggle_punctuation_level
KEY_FENRIR,KEY_RIGHTBRACE=toggle_auto_spell_check
KEY_FENRIR,KEY_BACKSLASH=toggle_output
KEY_FENRIR,KEY_CTRL,KEY_E=toggle_emoticons
KEY_FENRIR,KEY_CTRL,KEY_D=toggle_diff_mode
KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_E=cycle_key_echo
key_FENRIR,KEY_KPENTER=toggle_auto_read
KEY_FENRIR,KEY_CTRL,KEY_T=toggle_auto_time
+1
View File
@@ -81,6 +81,7 @@ KEY_FENRIR,KEY_SHIFT,KEY_CTRL,KEY_P=toggle_punctuation_level
KEY_FENRIR,KEY_RIGHTBRACE=toggle_auto_spell_check
KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_ENTER=toggle_output
KEY_FENRIR,KEY_SHIFT,KEY_E=toggle_emoticons
KEY_FENRIR,KEY_CTRL,KEY_D=toggle_diff_mode
KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_E=cycle_key_echo
KEY_FENRIR,KEY_CTRL,KEY_T=toggle_auto_time
KEY_FENRIR,KEY_Y=toggle_highlight_tracking
+13
View File
@@ -131,6 +131,8 @@ driver=evdevDriver
device=ALL
# gives Fenrir exclusive access to the keyboard and lets it control keystrokes.
grab_devices=True
# Optional X11 target window id for x11Driver. Empty uses WINDOWID or active window.
x11_window_id=
# Ignore shortcut bindings and pass all keys through without processing Fenrir commands.
# When True, Fenrir will only monitor screen content without intercepting keyboard input.
# the current shortcut layout located in /etc/fenrirscreenreader/keyboard
@@ -226,12 +228,23 @@ has_attributes=True
# Shell to use for PTY emulation mode (empty = use system default shell)
# Examples: /bin/bash, /bin/zsh, /usr/bin/fish
shell=
# Diff review presentation mode:
# speech = spoken feedback only
# sound = sound cues only (falls back to speech when cue is unavailable)
# both = speech and sound cues
diff_presentation=both
# Diff review verbosity:
# compact = concise role/location feedback
# verbose = include diff line content during navigation
diff_verbosity=compact
[focus]
# Follow and announce text cursor position changes
cursor=True
# Follow and announce highlighted/selected text changes (useful in menus)
highlight=False
# Suppress generic incoming announcements for full-screen TUIs that redraw often
tui=False
[remote]
enable=True
+48
View File
@@ -0,0 +1,48 @@
# Diff Review Mode
Diff review mode provides read-only navigation for unified and classic diff files with speech-first output.
## Quick Workflow
1. Copy the full absolute path of a diff file to the Fenrir clipboard.
2. Press `Fenrir + Ctrl + D` to toggle diff mode on.
3. Review the diff using the keys below.
4. Press `Esc` to leave diff mode.
If the clipboard does not contain a full absolute file path, Fenrir announces an error and does not enter diff mode.
## Keys (Active Only In Diff Mode)
- `h` - Next hunk
- `Shift + h` - Previous hunk
- `f` - Next file section
- `Shift + f` - Previous file section
- `a` - Next added line
- `Shift + a` - Previous added line
- `d` - Next removed line
- `Shift + d` - Previous removed line
- `Up` - Previous line
- `Down` - Next line
- `Left` - Previous character
- `Right` - Next character
- `Ctrl + Left` - Previous word
- `Ctrl + Right` - Next word
- `s` - Diff summary
- `F1` - Speak key help
- `Esc` - Exit diff mode
## Speech Behavior
- Added and removed content is spoken as `Added:` and `Removed:` lines.
- Marker-only lines are normalized for speech:
- `+++` is spoken as `added`
- `---` is spoken as `removed`
- Classic diff markers are spoken in plain language:
- `17c17` -> `line 17 changed`
- `17d16` -> `line 17 deleted`
- `16a17` -> `line 17 added`
## Notes
- Diff mode is read-only and does not modify the diff file.
- Normal Fenrir key bindings are restored when diff mode exits.
+24 -1
View File
@@ -343,9 +343,31 @@ fenrir [OPTIONS]
- `-p, --print` - Print debug to screen
- `-e, --emulated-pty` - PTY emulation for desktop use
- `-E, --emulated-evdev` - PTY + evdev emulation
- `-x, --x11` - PTY + X11 keyboard input scoped to the terminal window
- `--x11-window-id WINDOWID` - X11 window id to use for `--x11` terminal mode
- `-F, --force-all-screens` - Ignore ignoreScreen setting
- `-i, -I, --ignore-screen SCREEN` - Ignore specific screen(s), can be used multiple times
### X11 Terminal Mode
Use `fenrir -x` to run Fenrir in an X11 terminal as a normal user:
```bash
fenrir -x
```
This mode uses PTY screen monitoring and X11 keyboard input. It is intended for GUI terminals such as xterm and VTE-based terminals, while keeping Fenrir key handling scoped to that terminal window. Desktop screen readers keep their global bindings outside the focused terminal.
Fenrir normally detects the target terminal from `WINDOWID` or the active X11 window. If needed, pass a window id explicitly:
```bash
fenrir -x --x11-window-id 0x123456
```
X11 terminal mode uses the same keyboard layout files as TTY Fenrir. Supported Fenrir keys include numpad Insert/`KEY_KP0`, CapsLock/`KEY_CAPSLOCK`, Insert/`KEY_INSERT`, and Super/Windows/`KEY_META`. Compose script keys are exposed as `KEY_COMPOSE`.
This mode requires `python-xlib`.
## Troubleshooting
### No Speech
@@ -379,5 +401,6 @@ sudo fenrir -f -d
## See Also
- [README.md](../README.md) - Installation and basic setup
- [diff_review_mode.md](./diff_review_mode.md) - Diff review workflow and key bindings
- [settings.conf](../config/settings/settings.conf) - Configuration reference
- `man fenrir` - Manual page
- `man fenrir` - Manual page
+1
View File
@@ -101,6 +101,7 @@ setup(
"dbus-python>=1.2.8",
"pyperclip",
"pyudev>=0.21.0",
"python-xlib>=0.33",
"setuptools",
"setproctitle",
"pexpect",
+23 -3
View File
@@ -70,6 +70,17 @@ def create_argument_parser():
action='store_true',
help='Use PTY emulation with evdev for input (single instance)'
)
argumentParser.add_argument(
'-x', '--x11',
action='store_true',
help='Use PTY emulation with X11 keyboard input scoped to the terminal window'
)
argumentParser.add_argument(
'--x11-window-id',
metavar='WINDOWID',
default='',
help='X11 window id to use for --x11 terminal mode'
)
argumentParser.add_argument(
'-F',
'--force-all-screens',
@@ -91,8 +102,13 @@ def validate_arguments(cliArgs):
if option and ('#' not in option or '=' not in option):
return False, f"Invalid option format: {option}\nExpected format: SECTION#SETTING=VALUE"
if cliArgs.emulated_pty and cliArgs.emulated_evdev:
return False, "Cannot use both --emulated-pty and --emulated-evdev simultaneously"
emulated_modes = [
cliArgs.emulated_pty,
cliArgs.emulated_evdev,
cliArgs.x11,
]
if sum(bool(mode) for mode in emulated_modes) > 1:
return False, "Cannot combine --emulated-pty, --emulated-evdev, and --x11"
return True, None
@@ -124,6 +140,10 @@ def run_fenrir():
pass
def should_run_foreground(cliArgs):
return cliArgs.foreground or cliArgs.emulated_pty or cliArgs.x11
def main():
global cliArgs
argumentParser = create_argument_parser()
@@ -135,7 +155,7 @@ def main():
argumentParser.error(errorMsg)
sys.exit(1)
if cliArgs.foreground or cliArgs.emulated_pty:
if should_run_foreground(cliArgs):
# Run directly in foreground
run_fenrir()
else:
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("speak diff review key help")
def run(self):
self.env["runtime"]["DiffReviewManager"].present_key_help()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to next added diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].next_added()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to next character in diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].next_char()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to next diff file section")
def run(self):
self.env["runtime"]["DiffReviewManager"].next_file_section()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to next diff hunk")
def run(self):
self.env["runtime"]["DiffReviewManager"].next_hunk()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to next diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].next_line()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to next removed diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].next_removed()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to next word in diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].next_word()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to previous added diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].prev_added()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to previous character in diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].prev_char()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to previous diff file section")
def run(self):
self.env["runtime"]["DiffReviewManager"].prev_file_section()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to previous diff hunk")
def run(self):
self.env["runtime"]["DiffReviewManager"].prev_hunk()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to previous diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].prev_line()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to previous removed diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].prev_removed()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to previous word in diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].prev_word()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("speak diff summary")
def run(self):
self.env["runtime"]["DiffReviewManager"].present_summary()
def set_callback(self, callback):
pass
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("exit diff review mode")
def run(self):
if not self.env["runtime"]["DiffReviewManager"].is_active():
return
self.env["runtime"]["DiffReviewManager"].disable_mode()
self.env["runtime"]["DiffReviewManager"].present_mode_message(
_("Diff review mode disabled."),
)
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("toggle diff review mode")
def run(self):
self.env["runtime"]["DiffReviewManager"].toggle_mode()
def set_callback(self, callback):
pass
@@ -4,6 +4,7 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import difflib
import time
from fenrirscreenreader.core.i18n import _
@@ -111,6 +112,71 @@ class command:
return False
def _normalize_line(self, line):
return " ".join(line.split())
def _is_subsequence(self, subset_lines, source_lines):
subset_index = 0
for source_line in source_lines:
if source_line == subset_lines[subset_index]:
subset_index += 1
if subset_index == len(subset_lines):
return True
return False
def _prefer_inserted_lower_screen_text(self, delta_text):
delta_lines = [
line for line in delta_text.splitlines() if line.strip() != ""
]
if len(delta_lines) < 2:
return delta_text
old_lines = self.env["screen"]["old_content_text"].split("\n")
new_lines = self.env["screen"]["new_content_text"].split("\n")
if len(old_lines) != len(new_lines) or len(new_lines) < 4:
return delta_text
normalized_old_lines = [
self._normalize_line(line) for line in old_lines
]
normalized_new_lines = [
self._normalize_line(line) for line in new_lines
]
matcher = difflib.SequenceMatcher(
None, normalized_old_lines, normalized_new_lines, autojunk=False
)
top_lines_changed = False
inserted_lines = []
lower_screen_start = max(2, len(new_lines) // 2)
for tag, old_start, old_end, new_start, new_end in matcher.get_opcodes():
if tag in {"replace", "delete"} and old_start < 2:
top_lines_changed = True
if tag != "insert" or new_start < lower_screen_start:
continue
inserted_lines.extend(
line for line in new_lines[new_start:new_end] if line.strip() != ""
)
if not top_lines_changed or not inserted_lines:
return delta_text
normalized_delta_lines = [
self._normalize_line(line) for line in delta_lines
]
normalized_inserted_lines = [
self._normalize_line(line) for line in inserted_lines
]
if not self._is_subsequence(
normalized_inserted_lines, normalized_delta_lines
):
return delta_text
if normalized_delta_lines == normalized_inserted_lines:
return delta_text
return "\n".join(inserted_lines)
def run(self):
if not self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"speech", "auto_read_incoming"
@@ -169,6 +235,7 @@ class command:
return
# print(x_move, y_move, len(self.env['screen']['new_delta']), len(self.env['screen']['newNegativeDelta']))
delta_text = self._prefer_inserted_lower_screen_text(delta_text)
self.env["runtime"]["OutputManager"].present_text(
delta_text, interrupt=False, flush=False
)
@@ -0,0 +1,917 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import os
import re
from fenrirscreenreader.core import debug
from fenrirscreenreader.core.i18n import _
class DiffReviewManager:
def __init__(self):
self.env = None
self.active = False
self.loaded_path = ""
self.lines = []
self.line_meta = []
self.file_sections = []
self.hunk_sections = []
self.file_starts = []
self.hunk_starts = []
self.added_lines = []
self.removed_lines = []
self.current_line = -1
self.bindings_backup = None
self.raw_bindings_backup = None
self.total_added = 0
self.total_removed = 0
self.current_char = 0
def initialize(self, environment):
self.env = environment
def shutdown(self):
if self.active:
self._restore_bindings()
self.active = False
def is_active(self):
return self.active
def has_loaded_diff(self):
return len(self.lines) > 0
def get_loaded_path(self):
return self.loaded_path
def toggle_mode(self):
if self.active:
self.disable_mode()
self.present_mode_message(
_("Diff review mode disabled."),
)
return False
clipboard_path = self._get_clipboard_path_or_none()
if (
clipboard_path
and (
(not self.has_loaded_diff())
or (self.loaded_path != clipboard_path)
)
):
if not self.load_file(clipboard_path):
return False
elif not self.has_loaded_diff():
self.present_error(
_("Clipboard does not contain full path to diff file.")
)
return False
enabled = self.enable_mode()
if enabled:
self.present_mode_message(
_("Diff review mode enabled."),
)
return enabled
def enable_mode(self):
if self.active:
return True
if not self.has_loaded_diff():
return False
self._install_diff_bindings()
self.active = True
return True
def disable_mode(self):
if not self.active:
return
self._restore_bindings()
self.active = False
def load_from_clipboard(self):
clipboard_path = self._get_clipboard_path_or_none()
if not clipboard_path:
self.present_error(
_("Clipboard does not contain full path to diff file.")
)
return False
return self.load_file(clipboard_path)
def reload_file(self):
if not self.loaded_path:
self.present_error(_("No diff file loaded."))
return False
return self.load_file(self.loaded_path)
def load_file(self, file_path):
normalized_path = os.path.abspath(os.path.expanduser(file_path))
if not os.path.exists(normalized_path):
self.present_error(
_("Diff file not found: ") + normalized_path
)
return False
if os.path.isdir(normalized_path):
self.present_error(
_("Diff path is a directory: ") + normalized_path
)
return False
if not os.access(normalized_path, os.R_OK):
self.present_error(
_("Cannot read diff file: ") + normalized_path
)
return False
try:
with open(normalized_path, "r", encoding="utf-8", errors="replace") as diff_file:
file_lines = diff_file.read().splitlines()
except Exception as load_error:
self._write_debug(
"DiffReviewManager load_file: " + str(load_error),
debug.DebugLevel.ERROR,
)
self.present_error(
_("Failed to load diff file: ") + normalized_path
)
return False
self.present_mode_message(
_("Loading ") + normalized_path + ".",
)
self._parse_lines(file_lines, normalized_path)
self.present_mode_message(
_("Diff loaded. ")
+ str(len(self.file_sections))
+ _(" files changed. ")
+ str(self.total_added)
+ _(" additions. ")
+ str(self.total_removed)
+ _(" removals."),
)
return True
def _parse_lines(self, file_lines, loaded_path):
self.lines = file_lines
self.loaded_path = loaded_path
self.line_meta = []
self.file_sections = []
self.hunk_sections = []
self.file_starts = []
self.hunk_starts = []
self.added_lines = []
self.removed_lines = []
self.current_line = -1
self.total_added = 0
self.total_removed = 0
self.current_char = 0
has_git_header = any(
line.startswith("diff --git ") for line in self.lines
)
current_file_index = -1
current_hunk_index = -1
for line_index, line in enumerate(self.lines):
role = self._classify_line(line)
if self._starts_new_file_section(
line=line,
has_git_header=has_git_header,
):
current_file_index = self._add_file_section(line_index)
current_hunk_index = -1
if (
current_file_index == -1
and role
in {
"hunk_header",
"added_line",
"removed_line",
"context_line",
"note_line",
}
):
current_file_index = self._add_file_section(line_index)
if role == "hunk_header":
if current_file_index == -1:
current_file_index = self._add_file_section(line_index)
current_hunk_index = self._add_hunk_section(
line_index=line_index,
file_index=current_file_index,
)
if role in {"added_line", "removed_line"} and current_hunk_index == -1:
if current_file_index == -1:
current_file_index = self._add_file_section(line_index)
current_hunk_index = self._add_hunk_section(
line_index=line_index,
file_index=current_file_index,
)
if role == "added_line":
self.added_lines.append(line_index)
self.total_added += 1
if current_file_index != -1:
self.file_sections[current_file_index]["added_count"] += 1
if current_hunk_index != -1:
self.hunk_sections[current_hunk_index]["added_count"] += 1
elif role == "removed_line":
self.removed_lines.append(line_index)
self.total_removed += 1
if current_file_index != -1:
self.file_sections[current_file_index]["removed_count"] += 1
if current_hunk_index != -1:
self.hunk_sections[current_hunk_index]["removed_count"] += 1
self.line_meta.append(
{
"role": role,
"file_index": current_file_index,
"hunk_index": current_hunk_index,
}
)
for file_index, file_section in enumerate(self.file_sections):
file_hunks = file_section["hunk_indices"]
for local_position, hunk_index in enumerate(file_hunks, start=1):
self.hunk_sections[hunk_index]["position_in_file"] = local_position
self.hunk_sections[hunk_index]["hunks_in_file"] = len(file_hunks)
def _classify_line(self, line):
if line.startswith("\\ No newline at end of file"):
return "note_line"
if self._is_file_header(line):
return "file_header"
if line.startswith("@@"):
return "hunk_header"
if line.startswith("+") and not line.startswith("+++ "):
return "added_line"
if line.startswith("-") and not line.startswith("--- "):
return "removed_line"
return "context_line"
def _get_clipboard_path_or_none(self):
if self.env["runtime"]["MemoryManager"].is_index_list_empty(
"clipboardHistory"
):
return None
clipboard_value = self.env["runtime"]["MemoryManager"].get_index_list_element(
"clipboardHistory"
)
if not isinstance(clipboard_value, str):
return None
clipboard_path = clipboard_value.strip().strip("\"'")
clipboard_path = os.path.expanduser(clipboard_path)
if not os.path.isabs(clipboard_path):
return None
return os.path.abspath(clipboard_path)
def _is_file_header(self, line):
file_header_prefixes = (
"diff --git ",
"--- ",
"+++ ",
"index ",
"old mode ",
"new mode ",
"deleted file mode ",
"new file mode ",
"rename from ",
"rename to ",
"similarity index ",
"dissimilarity index ",
"Binary files ",
)
return line.startswith(file_header_prefixes)
def _starts_new_file_section(self, line, has_git_header):
if has_git_header:
return line.startswith("diff --git ")
return line.startswith("--- ")
def _add_file_section(self, line_index):
file_section = {
"start_line": line_index,
"added_count": 0,
"removed_count": 0,
"hunk_indices": [],
}
self.file_sections.append(file_section)
self.file_starts.append(line_index)
return len(self.file_sections) - 1
def _add_hunk_section(self, line_index, file_index):
hunk_section = {
"start_line": line_index,
"file_index": file_index,
"added_count": 0,
"removed_count": 0,
"position_in_file": 0,
"hunks_in_file": 0,
}
self.hunk_sections.append(hunk_section)
self.hunk_starts.append(line_index)
hunk_index = len(self.hunk_sections) - 1
if file_index != -1:
self.file_sections[file_index]["hunk_indices"].append(hunk_index)
return hunk_index
def next_file_section(self):
self._jump_to_anchor(
anchors=self.file_starts,
previous=False,
empty_message=_("No file sections in loaded diff."),
edge_message=_("No further file sections."),
announce_role="file",
)
def prev_file_section(self):
self._jump_to_anchor(
anchors=self.file_starts,
previous=True,
empty_message=_("No file sections in loaded diff."),
edge_message=_("No previous file sections."),
announce_role="file",
)
def next_hunk(self):
self._jump_to_anchor(
anchors=self.hunk_starts,
previous=False,
empty_message=_("No hunks in loaded diff."),
edge_message=_("No further hunks."),
announce_role="hunk",
)
def prev_hunk(self):
self._jump_to_anchor(
anchors=self.hunk_starts,
previous=True,
empty_message=_("No hunks in loaded diff."),
edge_message=_("No previous hunks."),
announce_role="hunk",
)
def next_added(self):
self._jump_to_anchor(
anchors=self.added_lines,
previous=False,
empty_message=_("No added lines in loaded diff."),
edge_message=_("No further added lines."),
announce_role="added",
)
def prev_added(self):
self._jump_to_anchor(
anchors=self.added_lines,
previous=True,
empty_message=_("No added lines in loaded diff."),
edge_message=_("No previous added lines."),
announce_role="added",
)
def next_removed(self):
self._jump_to_anchor(
anchors=self.removed_lines,
previous=False,
empty_message=_("No removed lines in loaded diff."),
edge_message=_("No further removed lines."),
announce_role="removed",
)
def prev_removed(self):
self._jump_to_anchor(
anchors=self.removed_lines,
previous=True,
empty_message=_("No removed lines in loaded diff."),
edge_message=_("No previous removed lines."),
announce_role="removed",
)
def present_summary(self):
if not self.has_loaded_diff():
self.present_mode_message(_("No diff file loaded."))
return
summary = (
_("Diff summary. Files changed: ")
+ str(len(self.file_sections))
+ _(". Additions: ")
+ str(self.total_added)
+ _(". Removals: ")
+ str(self.total_removed)
+ "."
)
if self.current_line >= 0 and self.current_line < len(self.line_meta):
location = self._build_location_text(self.current_line)
if location:
summary += " " + location
self.present_mode_message(summary)
def present_key_help(self):
help_text = _(
"Diff keys: h next hunk, shift h previous hunk, a next addition, "
"shift a previous addition, d next removal, shift d previous removal, "
"f next file section, shift f previous file section, up previous line, "
"down next line, left previous character, right next character, "
"control left previous word, control right next word, s summary, "
"escape exit diff mode."
)
self.present_mode_message(help_text)
def next_line(self):
if not self.has_loaded_diff():
self.present_mode_message(_("No diff file loaded."))
return
if self.current_line < 0:
self.current_line = 0
self.current_char = 0
self._announce_current_line()
return
if self.current_line >= len(self.lines) - 1:
self.present_mode_message(_("End of diff."))
return
self.current_line += 1
self.current_char = 0
self._announce_current_line()
def prev_line(self):
if not self.has_loaded_diff():
self.present_mode_message(_("No diff file loaded."))
return
if self.current_line < 0:
self.current_line = 0
self.current_char = 0
self._announce_current_line()
return
if self.current_line <= 0:
self.present_mode_message(_("Start of diff."))
return
self.current_line -= 1
self.current_char = 0
self._announce_current_line()
def next_char(self):
if not self._ensure_current_line():
return
line_text = self.lines[self.current_line]
if len(line_text) == 0:
self.present_mode_message(_("blank"))
return
if self.current_char >= len(line_text) - 1:
self.present_mode_message(_("End of line."))
return
self.current_char += 1
self._announce_current_char()
def prev_char(self):
if not self._ensure_current_line():
return
line_text = self.lines[self.current_line]
if len(line_text) == 0:
self.present_mode_message(_("blank"))
return
if self.current_char <= 0:
self.present_mode_message(_("Start of line."))
return
self.current_char -= 1
self._announce_current_char()
def next_word(self):
if not self._ensure_current_line():
return
line_text = self.lines[self.current_line]
word_spans = self._get_word_spans(line_text)
if len(word_spans) == 0:
self.present_mode_message(_("No words on this line."))
return
next_word_start = None
for start, end in word_spans:
if start > self.current_char:
next_word_start = start
break
if start <= self.current_char < end:
continue
if next_word_start is None:
self.present_mode_message(_("No next word on this line."))
return
self.current_char = next_word_start
self._announce_word_at_current_char()
def prev_word(self):
if not self._ensure_current_line():
return
line_text = self.lines[self.current_line]
word_spans = self._get_word_spans(line_text)
if len(word_spans) == 0:
self.present_mode_message(_("No words on this line."))
return
prev_word_start = None
for start, end in word_spans:
if end - 1 < self.current_char:
prev_word_start = start
elif start <= self.current_char < end:
break
else:
break
if prev_word_start is None:
self.present_mode_message(_("No previous word on this line."))
return
self.current_char = prev_word_start
self._announce_word_at_current_char()
def _jump_to_anchor(
self,
anchors,
previous,
empty_message,
edge_message,
announce_role,
):
if not self.has_loaded_diff():
self.present_mode_message(_("No diff file loaded."))
return
if len(anchors) == 0:
self.present_mode_message(empty_message)
return
new_line = self._find_next_anchor(anchors, previous)
if new_line is None:
self.present_mode_message(edge_message)
return
self.current_line = new_line
self.current_char = 0
self._announce_line(announce_role, new_line)
def _find_next_anchor(self, anchors, previous):
if self.current_line < 0:
if previous:
return anchors[-1]
return anchors[0]
if previous:
for anchor_line in reversed(anchors):
if anchor_line < self.current_line:
return anchor_line
return None
for anchor_line in anchors:
if anchor_line > self.current_line:
return anchor_line
return None
def _announce_line(self, announce_role, line_index):
line_text = self.lines[line_index] if line_index < len(self.lines) else ""
line_meta = self.line_meta[line_index]
role = line_meta["role"]
verbosity = self._get_diff_verbosity()
if announce_role == "file":
self._announce_file_section(line_index, line_meta)
return
if announce_role == "hunk":
self._announce_hunk_section(line_index, line_meta, line_text)
return
if role == "added_line":
base_message = _("Added line.")
elif role == "removed_line":
base_message = _("Removed line.")
else:
base_message = _("Diff line.")
message = base_message + " " + self._build_location_text(line_index)
if verbosity == "verbose":
content = self._line_content_for_speech(line_text, role)
message = message + " " + content
self.present_mode_message(message)
def _announce_current_line(self):
if self.current_line < 0 or self.current_line >= len(self.lines):
self.present_mode_message(_("No diff file loaded."))
return
line_text = self.lines[self.current_line]
role = self.line_meta[self.current_line]["role"]
if role == "context_line":
formatted_classic_diff = self._format_classic_diff_command(line_text)
if formatted_classic_diff:
self.present_mode_message(formatted_classic_diff)
return
role_prefix = {
"added_line": _("Added"),
"removed_line": _("Removed"),
"hunk_header": _("Hunk header"),
"file_header": _("File header"),
"note_line": _("Note"),
"context_line": "",
}.get(role, _("Line"))
if line_text.strip() == "":
if role_prefix:
self.present_mode_message(role_prefix + ": " + _("blank"))
else:
self.present_mode_message(_("blank"))
return
if role_prefix:
line_text = self._normalize_diff_line_content(line_text, role)
if line_text.strip() == "":
self.present_mode_message(role_prefix + ": " + _("blank"))
return
if role in {"added_line", "removed_line"} and line_text in {
_("added"),
_("removed"),
}:
self.present_mode_message(line_text)
return
self.present_mode_message(role_prefix + ": " + line_text)
else:
self.present_mode_message(line_text)
def _format_classic_diff_command(self, line_text):
match = re.match(r"^(\d+)(?:,(\d+))?([acd])(\d+)(?:,(\d+))?$", line_text.strip())
if not match:
return None
old_start = int(match.group(1))
old_end = int(match.group(2) or match.group(1))
op = match.group(3)
new_start = int(match.group(4))
new_end = int(match.group(5) or match.group(4))
if op == "c":
return self._format_line_span(old_start, old_end) + " " + _("changed")
if op == "d":
return self._format_line_span(old_start, old_end) + " " + _("deleted")
if op == "a":
return self._format_line_span(new_start, new_end) + " " + _("added")
return None
def _format_line_span(self, start_line, end_line):
if start_line == end_line:
return _("line ") + str(start_line)
return (
_("lines ")
+ str(start_line)
+ _(" through ")
+ str(end_line)
)
def _announce_current_char(self):
if self.current_line < 0 or self.current_line >= len(self.lines):
self.present_mode_message(_("No diff file loaded."))
return
line_text = self.lines[self.current_line]
if len(line_text) == 0:
self.present_mode_message(_("blank"))
return
if self.current_char < 0:
self.current_char = 0
if self.current_char >= len(line_text):
self.current_char = len(line_text) - 1
char_value = line_text[self.current_char]
if char_value == " ":
self.present_mode_message(_("space"))
return
self.env["runtime"]["OutputManager"].present_text(
char_value,
interrupt=True,
ignore_punctuation=True,
announce_capital=True,
)
def _announce_word_at_current_char(self):
if self.current_line < 0 or self.current_line >= len(self.lines):
self.present_mode_message(_("No diff file loaded."))
return
line_text = self.lines[self.current_line]
for start, end in self._get_word_spans(line_text):
if start <= self.current_char < end:
self.present_mode_message(line_text[start:end])
return
self.present_mode_message(_("No word at current position."))
def _ensure_current_line(self):
if not self.has_loaded_diff():
self.present_mode_message(_("No diff file loaded."))
return False
if self.current_line < 0:
self.current_line = 0
self.current_char = 0
if self.current_line >= len(self.lines):
self.current_line = len(self.lines) - 1
self.current_char = 0
return True
def _get_word_spans(self, line_text):
return [match.span() for match in re.finditer(r"\S+", line_text)]
def _announce_file_section(self, line_index, line_meta):
file_index = line_meta["file_index"]
if file_index == -1 or file_index >= len(self.file_sections):
self.present_mode_message(_("File section."))
return
file_section = self.file_sections[file_index]
message = (
_("File section ")
+ str(file_index + 1)
+ _(" of ")
+ str(len(self.file_sections))
+ _(". Plus ")
+ str(file_section["added_count"])
+ _(", minus ")
+ str(file_section["removed_count"])
+ "."
)
self.present_mode_message(message)
def _announce_hunk_section(self, line_index, line_meta, line_text):
hunk_index = line_meta["hunk_index"]
if hunk_index == -1 or hunk_index >= len(self.hunk_sections):
self.present_mode_message(_("Hunk section."))
return
hunk_section = self.hunk_sections[hunk_index]
file_index = hunk_section["file_index"]
file_position = file_index + 1 if file_index >= 0 else 0
hunk_position = hunk_section["position_in_file"]
hunk_total = hunk_section["hunks_in_file"]
message = (
_("Hunk ")
+ str(hunk_position)
+ _(" of ")
+ str(hunk_total)
+ _(" in file ")
+ str(file_position)
+ _(" of ")
+ str(len(self.file_sections))
+ _(". Plus ")
+ str(hunk_section["added_count"])
+ _(", minus ")
+ str(hunk_section["removed_count"])
+ "."
)
if self._get_diff_verbosity() == "verbose":
message += " " + self._line_content_for_speech(line_text, "hunk_header")
self.present_mode_message(message)
def _line_content_for_speech(self, line_text, role):
line_text = self._normalize_diff_line_content(line_text, role)
if line_text.strip() == "":
return _("blank")
return line_text
def _normalize_diff_line_content(self, line_text, role):
if role == "added_line":
stripped_line = line_text.lstrip("+")
if stripped_line.strip() == "" and line_text.startswith("+"):
return _("added")
return stripped_line
if role == "removed_line":
stripped_line = line_text.lstrip("-")
if stripped_line.strip() == "" and line_text.startswith("-"):
return _("removed")
return stripped_line
return line_text
def _build_location_text(self, line_index):
if line_index < 0 or line_index >= len(self.line_meta):
return ""
line_meta = self.line_meta[line_index]
file_index = line_meta["file_index"]
hunk_index = line_meta["hunk_index"]
location_parts = []
if file_index != -1 and file_index < len(self.file_sections):
location_parts.append(
_("File ")
+ str(file_index + 1)
+ _(" of ")
+ str(len(self.file_sections))
+ ".")
if hunk_index != -1 and hunk_index < len(self.hunk_sections):
hunk_info = self.hunk_sections[hunk_index]
location_parts.append(
_("Hunk ")
+ str(hunk_info["position_in_file"])
+ _(" of ")
+ str(hunk_info["hunks_in_file"])
+ "."
)
return " ".join(location_parts)
def _get_diff_verbosity(self):
verbosity = self.env["runtime"]["SettingsManager"].get_setting(
"general", "diff_verbosity"
).lower()
if verbosity not in {"compact", "verbose"}:
return "compact"
return verbosity
def present_error(self, message):
self.present_mode_message(message)
def present_mode_message(self, message):
self.env["runtime"]["OutputManager"].present_text(
message,
interrupt=True,
)
def _install_diff_bindings(self):
self.bindings_backup = self.env["bindings"].copy()
self.raw_bindings_backup = self.env["rawBindings"].copy()
diff_bindings = {
str([1, ["KEY_H"]]): "DIFF_NEXT_HUNK",
str([1, sorted(["KEY_SHIFT", "KEY_H"])]): "DIFF_PREV_HUNK",
str([1, ["KEY_A"]]): "DIFF_NEXT_ADDED",
str([1, sorted(["KEY_SHIFT", "KEY_A"])]): "DIFF_PREV_ADDED",
str([1, ["KEY_D"]]): "DIFF_NEXT_REMOVED",
str([1, sorted(["KEY_SHIFT", "KEY_D"])]): "DIFF_PREV_REMOVED",
str([1, ["KEY_F"]]): "DIFF_NEXT_FILE_SECTION",
str([1, sorted(["KEY_SHIFT", "KEY_F"])]): "DIFF_PREV_FILE_SECTION",
str([1, ["KEY_S"]]): "DIFF_SUMMARY",
str([1, ["KEY_F1"]]): "DIFF_KEY_HELP",
str([1, ["KEY_UP"]]): "DIFF_PREV_LINE",
str([1, ["KEY_DOWN"]]): "DIFF_NEXT_LINE",
str([1, ["KEY_LEFT"]]): "DIFF_PREV_CHAR",
str([1, ["KEY_RIGHT"]]): "DIFF_NEXT_CHAR",
str([1, sorted(["KEY_CTRL", "KEY_LEFT"])]): "DIFF_PREV_WORD",
str([1, sorted(["KEY_CTRL", "KEY_RIGHT"])]): "DIFF_NEXT_WORD",
str([1, ["KEY_ESC"]]): "EXIT_DIFF_MODE",
str([1, sorted(["KEY_FENRIR", "KEY_CTRL", "KEY_D"])]): "TOGGLE_DIFF_MODE",
}
diff_raw_bindings = {
shortcut: [1, keys]
for shortcut, keys in [
(str([1, ["KEY_H"]]), ["KEY_H"]),
(str([1, sorted(["KEY_SHIFT", "KEY_H"])]), sorted(["KEY_SHIFT", "KEY_H"])),
(str([1, ["KEY_A"]]), ["KEY_A"]),
(str([1, sorted(["KEY_SHIFT", "KEY_A"])]), sorted(["KEY_SHIFT", "KEY_A"])),
(str([1, ["KEY_D"]]), ["KEY_D"]),
(str([1, sorted(["KEY_SHIFT", "KEY_D"])]), sorted(["KEY_SHIFT", "KEY_D"])),
(str([1, ["KEY_F"]]), ["KEY_F"]),
(str([1, sorted(["KEY_SHIFT", "KEY_F"])]), sorted(["KEY_SHIFT", "KEY_F"])),
(str([1, ["KEY_S"]]), ["KEY_S"]),
(str([1, ["KEY_F1"]]), ["KEY_F1"]),
(str([1, ["KEY_UP"]]), ["KEY_UP"]),
(str([1, ["KEY_DOWN"]]), ["KEY_DOWN"]),
(str([1, ["KEY_LEFT"]]), ["KEY_LEFT"]),
(str([1, ["KEY_RIGHT"]]), ["KEY_RIGHT"]),
(
str([1, sorted(["KEY_CTRL", "KEY_LEFT"])]),
sorted(["KEY_CTRL", "KEY_LEFT"]),
),
(
str([1, sorted(["KEY_CTRL", "KEY_RIGHT"])]),
sorted(["KEY_CTRL", "KEY_RIGHT"]),
),
(str([1, ["KEY_ESC"]]), ["KEY_ESC"]),
(
str([1, sorted(["KEY_FENRIR", "KEY_CTRL", "KEY_D"])]),
sorted(["KEY_FENRIR", "KEY_CTRL", "KEY_D"]),
),
]
}
self.env["bindings"] = diff_bindings
self.env["rawBindings"] = diff_raw_bindings
def _restore_bindings(self):
if self.bindings_backup is not None:
self.env["bindings"] = self.bindings_backup
if self.raw_bindings_backup is not None:
self.env["rawBindings"] = self.raw_bindings_backup
self.bindings_backup = None
self.raw_bindings_backup = None
def _write_debug(self, message, level):
self.env["runtime"]["DebugManager"].write_debug_out(
message, level
)
@@ -96,6 +96,10 @@ class FenrirManager:
self.environment["runtime"][
"InputManager"
].clear_event_buffer()
if self.environment["runtime"]["DiffReviewManager"].is_active():
self.environment["runtime"][
"InputManager"
].clear_event_buffer()
self.detect_shortcut_command()
@@ -301,6 +305,14 @@ class FenrirManager:
if self.environment["runtime"]["InputManager"].is_key_press():
if self.command != "":
self.singleKeyCommand = True
elif (
self.environment["runtime"]["DiffReviewManager"].is_active()
and self.command != ""
):
# Diff mode uses non-Fenrir modified bindings (Shift/Ctrl).
# Promote resolved shortcuts to executable commands so
# combinations like Shift+H and Ctrl+Right are dispatched.
self.singleKeyCommand = True
if not (self.singleKeyCommand or self.modifierInput):
return
@@ -28,6 +28,7 @@ general_data = {
"VmenuManager",
"QuickMenuManager",
"ReadAllManager",
"DiffReviewManager",
"RemoteManager",
"SettingsManager",
"SayAllManager",
@@ -340,6 +340,14 @@ class InputManager:
except Exception as e:
pass
def reset_input_state(self):
self.env["input"]["curr_input"] = []
self.env["input"]["prev_input"] = []
self.env["input"]["event_buffer"] = []
self.env["input"]["shortcut_repeat"] = 1
self.clear_last_deep_input()
self.clear_event_buffer()
def set_last_deepest_input(self, currentDeepestInput):
self.lastDeepestInput = currentDeepestInput
@@ -21,4 +21,5 @@ runtime_data = {
"FenrirManager": None,
"EventManager": None,
"ProcessManager": None,
"DiffReviewManager": None,
}
@@ -75,10 +75,13 @@ settings_data = {
"auto_present_indent_mode": 1,
"has_attributes": True,
"shell": "",
"diff_presentation": "both",
"diff_verbosity": "compact",
},
"focus": {
"cursor": True,
"highlight": False,
"tui": False,
},
"remote": {
"enabled": True,
@@ -116,6 +119,7 @@ settings_data = {
"driver": "evdev",
"device": "all",
"grab_devices": True,
"x11_window_id": "",
"ignore_shortcuts": False,
"keyboard_layout": "desktop",
"char_echo_mode": 2, # while capslock
@@ -16,6 +16,7 @@ from fenrirscreenreader.core import commandManager
from fenrirscreenreader.core import cursorManager
from fenrirscreenreader.core import debug
from fenrirscreenreader.core import debugManager
from fenrirscreenreader.core import diffReviewManager
from fenrirscreenreader.core import environment
from fenrirscreenreader.core import eventManager
from fenrirscreenreader.core import helpManager
@@ -410,6 +411,7 @@ class SettingsManager:
valid_drivers = [
"evdevDriver",
"ptyDriver",
"x11Driver",
"atspiDriver",
"dummyDriver",
]
@@ -506,6 +508,13 @@ class SettingsManager:
if cliArgs.emulated_evdev:
self.set_setting("screen", "driver", "ptyDriver")
self.set_setting("keyboard", "driver", "evdevDriver")
if cliArgs.x11:
self.set_setting("screen", "driver", "ptyDriver")
self.set_setting("keyboard", "driver", "x11Driver")
if cliArgs.x11_window_id:
self.set_setting(
"keyboard", "x11_window_id", cliArgs.x11_window_id
)
self.set_fenrir_keys(self.get_setting("general", "fenrir_keys"))
self.set_script_keys(self.get_setting("general", "script_keys"))
@@ -642,6 +651,11 @@ class SettingsManager:
environment["runtime"]["RemoteManager"] = remoteManager.RemoteManager()
environment["runtime"]["RemoteManager"].initialize(environment)
environment["runtime"][
"DiffReviewManager"
] = diffReviewManager.DiffReviewManager()
environment["runtime"]["DiffReviewManager"].initialize(environment)
if environment["runtime"]["InputManager"].get_shortcut_type() == "KEY":
if not os.path.exists(
self.get_setting("keyboard", "keyboard_layout")
+2 -2
View File
@@ -4,5 +4,5 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
version = "2026.01.28"
code_name = "master"
version = "2026.05.07"
code_name = "testing"
@@ -68,7 +68,9 @@ class driver(inputDriver):
self.gDevices = {}
self.iDeviceNo = 0
self.watch_dog = Value(c_bool, True)
self.UInputinject = UInput()
self.UInputinject = (
UInput() if _evdevAvailable and "UInput" in globals() else None
)
self._deviceLock = threading.Lock()
def initialize(self, environment):
@@ -634,6 +636,8 @@ class driver(inputDriver):
self.uDevices[fd] = None
if self.uDevices[fd] is not None:
return
if "UInput" not in globals():
return
try:
self.uDevices[fd] = UInput.from_device(
self.iDevices[fd], name="fenrir-uinput", phys="fenrir-uinput"
@@ -936,6 +940,8 @@ class driver(inputDriver):
"""
if not self._initialized:
return
if self.UInputinject is None:
return
try:
self.UInputinject.write(e.EV_KEY, e.ecodes[key], state)
self.UInputinject.syn()
@@ -0,0 +1,620 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import os
import sys
import time
from fenrirscreenreader.core import debug
from fenrirscreenreader.core.eventData import FenrirEventType
from fenrirscreenreader.core.inputDriver import InputDriver as inputDriver
try:
from Xlib import X
from Xlib import XK
from Xlib import display
from Xlib.error import BadAccess
from Xlib.error import BadWindow
except Exception as x_error:
X = None
XK = None
display = None
BadAccess = Exception
BadWindow = Exception
_x_error = x_error
else:
_x_error = None
def build_keysym_name_map():
if XK is None:
return {}
names = {}
for attr_name in dir(XK):
if not attr_name.startswith("XK_"):
continue
value = getattr(XK, attr_name)
if not isinstance(value, int):
continue
names.setdefault(value, attr_name[3:])
return names
KEYSYM_NAME_MAP = build_keysym_name_map()
class X11DriverError(RuntimeError):
pass
class driver(inputDriver):
"""X11 terminal-scoped keyboard input driver."""
key_name_overrides = {
"BackSpace": "KEY_BACKSPACE",
"Return": "KEY_ENTER",
"ISO_Enter": "KEY_ENTER",
"Tab": "KEY_TAB",
"Escape": "KEY_ESC",
"space": "KEY_SPACE",
"minus": "KEY_MINUS",
"equal": "KEY_EQUAL",
"bracketleft": "KEY_LEFTBRACE",
"bracketright": "KEY_RIGHTBRACE",
"backslash": "KEY_BACKSLASH",
"semicolon": "KEY_SEMICOLON",
"apostrophe": "KEY_APOSTROPHE",
"grave": "KEY_GRAVE",
"comma": "KEY_COMMA",
"period": "KEY_DOT",
"slash": "KEY_SLASH",
"Shift_L": "KEY_LEFTSHIFT",
"Shift_R": "KEY_RIGHTSHIFT",
"Control_L": "KEY_LEFTCTRL",
"Control_R": "KEY_RIGHTCTRL",
"Alt_L": "KEY_LEFTALT",
"Alt_R": "KEY_RIGHTALT",
"Meta_L": "KEY_LEFTMETA",
"Meta_R": "KEY_RIGHTMETA",
"Super_L": "KEY_LEFTMETA",
"Super_R": "KEY_RIGHTMETA",
"Multi_key": "KEY_COMPOSE",
"Caps_Lock": "KEY_CAPSLOCK",
"Num_Lock": "KEY_NUMLOCK",
"Scroll_Lock": "KEY_SCROLLLOCK",
"Insert": "KEY_INSERT",
"Delete": "KEY_DELETE",
"Home": "KEY_HOME",
"End": "KEY_END",
"Page_Up": "KEY_PAGEUP",
"Page_Down": "KEY_PAGEDOWN",
"Up": "KEY_UP",
"Down": "KEY_DOWN",
"Left": "KEY_LEFT",
"Right": "KEY_RIGHT",
"KP_0": "KEY_KP0",
"KP_Insert": "KEY_KP0",
"KP_1": "KEY_KP1",
"KP_End": "KEY_KP1",
"KP_2": "KEY_KP2",
"KP_Down": "KEY_KP2",
"KP_3": "KEY_KP3",
"KP_Page_Down": "KEY_KP3",
"KP_4": "KEY_KP4",
"KP_Left": "KEY_KP4",
"KP_5": "KEY_KP5",
"KP_Begin": "KEY_KP5",
"KP_6": "KEY_KP6",
"KP_Right": "KEY_KP6",
"KP_7": "KEY_KP7",
"KP_Home": "KEY_KP7",
"KP_8": "KEY_KP8",
"KP_Up": "KEY_KP8",
"KP_9": "KEY_KP9",
"KP_Page_Up": "KEY_KP9",
"KP_Decimal": "KEY_KPDOT",
"KP_Delete": "KEY_KPDOT",
"KP_Add": "KEY_KPPLUS",
"KP_Subtract": "KEY_KPMINUS",
"KP_Multiply": "KEY_KPASTERISK",
"KP_Divide": "KEY_KPSLASH",
"KP_Enter": "KEY_KPENTER",
"KP_Equal": "KEY_KPEQUAL",
}
modifier_masks = {
"KEY_SHIFT": X.ShiftMask if X else 1,
"KEY_LEFTSHIFT": X.ShiftMask if X else 1,
"KEY_RIGHTSHIFT": X.ShiftMask if X else 1,
"KEY_CTRL": X.ControlMask if X else 4,
"KEY_LEFTCTRL": X.ControlMask if X else 4,
"KEY_RIGHTCTRL": X.ControlMask if X else 4,
"KEY_ALT": X.Mod1Mask if X else 8,
"KEY_LEFTALT": X.Mod1Mask if X else 8,
"KEY_RIGHTALT": X.Mod1Mask if X else 8,
"KEY_META": X.Mod4Mask if X else 64,
"KEY_LEFTMETA": X.Mod4Mask if X else 64,
"KEY_RIGHTMETA": X.Mod4Mask if X else 64,
}
modifier_key_names = set(modifier_masks.keys())
def __init__(self):
inputDriver.__init__(self)
self.display = None
self.root = None
self.window = None
self.window_id = None
self.active = True
self.num_lock_mask = 0
self.grabbed = set()
self.grab_signature = None
self.interesting_keys = set()
self.fenrir_keys = set()
self.failed_grabs = 0
def initialize(self, environment):
self.env = environment
self.env["runtime"]["InputManager"].set_shortcut_type("KEY")
if display is None:
self.fail_startup("python-xlib is not available: " + str(_x_error))
self.display = display.Display()
self.root = self.display.screen().root
self.window_id = self.resolve_window_id()
if self.window_id is None:
self.fail_startup(
"No X11 target window found. Use --x11-window-id or launch from an X11 terminal."
)
self.write_debug(
"x11Driver target window "
+ self.format_window_id(self.window_id)
+ ", active window "
+ self.format_window_id(self.get_active_window_id())
+ ", WINDOWID="
+ os.environ.get("WINDOWID", ""),
debug.DebugLevel.INFO,
)
self.window = self.display.create_resource_object("window", self.window_id)
self.window.change_attributes(
event_mask=(
X.KeyPressMask
| X.KeyReleaseMask
| X.FocusChangeMask
| X.StructureNotifyMask
)
)
self.num_lock_mask = self.find_num_lock_mask()
self.refresh_interesting_keys()
self.refresh_grabs(force=True)
self.env["runtime"]["ProcessManager"].add_custom_event_thread(
self.input_watchdog
)
self._initialized = True
def shutdown(self):
self.ungrab_all_devices()
try:
if self.display:
self.display.close()
except Exception:
pass
self._initialized = False
def fail_startup(self, message):
print("Fenrir X11 driver error: " + message, file=sys.stderr)
raise SystemExit(1)
def resolve_window_id(self):
configured_window = self.env["runtime"]["SettingsManager"].get_setting(
"keyboard", "x11_window_id"
)
for candidate in [configured_window, os.environ.get("WINDOWID", "")]:
window_id = self.parse_window_id(candidate)
if window_id is not None:
return window_id
return self.get_active_window_id()
def parse_window_id(self, value):
if value is None:
return None
value = str(value).strip()
if value == "":
return None
try:
return int(value, 0)
except ValueError:
raise X11DriverError("Invalid X11 window id: " + value)
def get_active_window_id(self):
atom = self.display.intern_atom("_NET_ACTIVE_WINDOW")
prop = self.root.get_full_property(atom, X.AnyPropertyType)
if prop is None or len(prop.value) == 0:
return None
return int(prop.value[0])
def format_window_id(self, window_id):
if window_id is None:
return "None"
return hex(int(window_id))
def find_num_lock_mask(self):
num_lock_keysym = XK.string_to_keysym("Num_Lock")
if not num_lock_keysym:
return 0
num_lock_keycode = self.display.keysym_to_keycode(num_lock_keysym)
if not num_lock_keycode:
return 0
modifier_map = self.display.get_modifier_mapping()
masks = [
X.ShiftMask,
X.LockMask,
X.ControlMask,
X.Mod1Mask,
X.Mod2Mask,
X.Mod3Mask,
X.Mod4Mask,
X.Mod5Mask,
]
for index, keycodes in enumerate(modifier_map):
if num_lock_keycode in keycodes:
return masks[index]
return 0
def input_watchdog(self, active, event_queue):
while active.value:
try:
self.refresh_grabs()
if not self.display.pending_events():
time.sleep(0.01)
continue
event = self.display.next_event()
self.handle_x_event(event, event_queue)
except BadWindow:
self.env["runtime"]["DebugManager"].write_debug_out(
"x11Driver target window disappeared",
debug.DebugLevel.ERROR,
)
break
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"x11Driver input watchdog error: " + str(e),
debug.DebugLevel.ERROR,
)
time.sleep(0.1)
def handle_x_event(self, event, event_queue):
event_type = getattr(event, "type", None)
if event_type == X.FocusIn:
self.active = True
self.clear_event_buffer()
return
if event_type == X.FocusOut:
self.active = False
self.reset_input_state()
return
if event_type not in [X.KeyPress, X.KeyRelease]:
return
if not self.active and not self.event_is_in_target_tree(event):
return
input_event = self.map_event(event)
if input_event is None:
return
key_name = input_event["event_name"]
if not self.should_emit_key(key_name):
return
self.write_debug(
"x11Driver key event "
+ key_name
+ " state "
+ str(input_event["event_state"])
+ " raw state "
+ str(getattr(event, "state", 0)),
debug.DebugLevel.INFO,
)
self.env["input"]["event_buffer"].append(input_event.copy())
event_queue.put(
{
"Type": FenrirEventType.keyboard_input,
"data": input_event,
}
)
def event_is_in_target_tree(self, event):
event_window = getattr(event, "event", None)
if event_window is None:
return False
window_id = getattr(event_window, "id", None)
if window_id is None:
return False
return window_id == self.window_id or self.window_has_ancestor(
window_id, self.window_id
)
def window_has_ancestor(self, window_id, ancestor_id):
try:
window = self.display.create_resource_object("window", window_id)
while window.id != self.root.id:
if window.id == ancestor_id:
return True
parent = window.query_tree().parent
if parent is None or parent.id == window.id:
return False
window = parent
except Exception:
return False
return False
def map_event(self, event):
key_name = self.keycode_to_key_name(event.detail, event.state)
if key_name is None:
return None
event_time = time.time()
return {
"event_name": key_name,
"event_value": event.detail,
"event_sec": int(event_time),
"event_usec": int((event_time % 1) * 1000000),
"event_state": 1 if event.type == X.KeyPress else 0,
"event_type": event.type,
}
def keycode_to_key_name(self, keycode, state=0):
key_names = []
for keysym_name in self.keycode_to_keysym_names(keycode):
key_name = self.keysym_name_to_key_name(keysym_name)
if key_name is None or key_name in key_names:
continue
key_names.append(key_name)
for key_name in key_names:
if key_name.startswith("KEY_KP"):
return key_name
if key_names:
return key_names[0]
return None
def keycode_to_keysym_names(self, keycode):
names = []
for column in range(8):
keysym = self.display.keycode_to_keysym(keycode, column)
keysym_name = self.keysym_to_name(keysym)
if keysym_name and keysym_name not in names:
names.append(keysym_name)
return names
def keysym_to_name(self, keysym):
keysym_name = XK.keysym_to_string(keysym)
if keysym_name:
return keysym_name
return KEYSYM_NAME_MAP.get(keysym)
def keysym_name_to_key_name(self, keysym_name):
if not keysym_name:
return None
if keysym_name in self.key_name_overrides:
return self.key_name_overrides[keysym_name]
if len(keysym_name) == 1:
if keysym_name.isalpha():
return "KEY_" + keysym_name.upper()
if keysym_name.isdigit():
return "KEY_" + keysym_name
if keysym_name.startswith("F") and keysym_name[1:].isdigit():
return "KEY_" + keysym_name
return None
def should_emit_key(self, key_name):
if key_name in self.modifier_key_names:
return True
if key_name in self.interesting_keys:
return True
input_manager = self.env["runtime"]["InputManager"]
curr_input = self.env["input"]["curr_input"]
normalized_curr = [input_manager.convert_event_name(key) for key in curr_input]
return "KEY_FENRIR" in normalized_curr or "KEY_SCRIPT" in normalized_curr
def refresh_interesting_keys(self):
input_manager = self.env["runtime"]["InputManager"]
self.fenrir_keys = set(self.env["input"]["fenrir_key"])
interesting = set(self.fenrir_keys)
interesting.update(self.env["input"]["script_key"])
interesting.update(self.modifier_key_names)
for shortcut in self.env.get("rawBindings", {}).values():
for key_name in shortcut[1]:
if key_name == "KEY_FENRIR":
interesting.update(self.fenrir_keys)
elif key_name == "KEY_SCRIPT":
interesting.update(self.env["input"]["script_key"])
else:
interesting.add(input_manager.convert_event_name(key_name))
interesting.add(key_name)
self.interesting_keys = interesting
def refresh_grabs(self, force=False):
signature = (
tuple(sorted(self.env.get("rawBindings", {}).keys())),
tuple(sorted(self.env["input"]["fenrir_key"])),
tuple(sorted(self.env["input"]["script_key"])),
)
if not force and signature == self.grab_signature:
return
self.ungrab_all_devices()
self.refresh_interesting_keys()
passive_grabs = self.build_passive_grabs()
failed_before = self.failed_grabs
for key_name, modifier_mask in passive_grabs:
self.grab_key_name(key_name, modifier_mask)
self.display.flush()
self.grab_signature = signature
self.write_debug(
"x11Driver passive grabs planned "
+ str(len(passive_grabs))
+ ", installed "
+ str(len(self.grabbed))
+ ", failed "
+ str(self.failed_grabs - failed_before),
debug.DebugLevel.INFO,
)
def build_passive_grabs(self):
grabs = set()
for fenrir_key in self.env["input"]["fenrir_key"]:
grabs.add((fenrir_key, 0))
for script_key in self.env["input"]["script_key"]:
grabs.add((script_key, 0))
for shortcut in self.env.get("rawBindings", {}).values():
keys = shortcut[1]
expanded_keys = self.expand_special_keys(keys)
modifier_mask = self.shortcut_modifier_mask(expanded_keys)
non_modifier_keys = [
key for key in expanded_keys
if key not in self.modifier_key_names
]
if not non_modifier_keys:
continue
final_key = non_modifier_keys[-1]
if "KEY_FENRIR" in keys:
for fenrir_key in self.env["input"]["fenrir_key"]:
grabs.add((fenrir_key, modifier_mask))
elif "KEY_SCRIPT" in keys:
for script_key in self.env["input"]["script_key"]:
grabs.add((script_key, modifier_mask))
else:
grabs.add((final_key, modifier_mask))
return grabs
def expand_special_keys(self, keys):
expanded = []
for key_name in keys:
if key_name == "KEY_FENRIR":
expanded.extend(self.env["input"]["fenrir_key"])
elif key_name == "KEY_SCRIPT":
expanded.extend(self.env["input"]["script_key"])
else:
expanded.append(key_name)
return expanded
def shortcut_modifier_mask(self, keys):
modifier_mask = 0
for key_name in keys:
modifier_mask |= self.modifier_masks.get(key_name, 0)
return modifier_mask
def grab_key_name(self, key_name, modifier_mask=0):
keysym_names = self.key_name_to_keysym_names(key_name)
for keysym_name in keysym_names:
keysym = XK.string_to_keysym(keysym_name)
if not keysym:
continue
keycode = self.display.keysym_to_keycode(keysym)
if not keycode:
continue
for effective_mask in self.optional_modifier_masks(modifier_mask):
try:
self.window.grab_key(
keycode,
effective_mask,
False,
X.GrabModeAsync,
X.GrabModeAsync,
)
self.grabbed.add((keycode, effective_mask))
except BadAccess:
self.failed_grabs += 1
self.env["runtime"]["DebugManager"].write_debug_out(
"x11Driver could not grab "
+ key_name
+ " with modifier mask "
+ str(effective_mask),
debug.DebugLevel.WARNING,
)
def optional_modifier_masks(self, modifier_mask):
optional_masks = [0, X.LockMask]
if self.num_lock_mask:
optional_masks += [self.num_lock_mask, self.num_lock_mask | X.LockMask]
return {modifier_mask | optional for optional in optional_masks}
def key_name_to_keysym_names(self, key_name):
reverse_map = {
value: key for key, value in self.key_name_overrides.items()
}
aliases = {
"KEY_META": ["Super_L", "Super_R", "Meta_L", "Meta_R"],
"KEY_LEFTMETA": ["Super_L", "Meta_L"],
"KEY_RIGHTMETA": ["Super_R", "Meta_R"],
"KEY_COMPOSE": ["Multi_key"],
"KEY_KP0": ["KP_0", "KP_Insert"],
"KEY_KP1": ["KP_1", "KP_End"],
"KEY_KP2": ["KP_2", "KP_Down"],
"KEY_KP3": ["KP_3", "KP_Page_Down"],
"KEY_KP4": ["KP_4", "KP_Left"],
"KEY_KP5": ["KP_5", "KP_Begin"],
"KEY_KP6": ["KP_6", "KP_Right"],
"KEY_KP7": ["KP_7", "KP_Home"],
"KEY_KP8": ["KP_8", "KP_Up"],
"KEY_KP9": ["KP_9", "KP_Page_Up"],
"KEY_KPDOT": ["KP_Decimal", "KP_Delete"],
}
if key_name in aliases:
return aliases[key_name]
if key_name in reverse_map:
return [reverse_map[key_name]]
if key_name.startswith("KEY_F") and key_name[5:].isdigit():
return [key_name[4:]]
if key_name.startswith("KEY_") and len(key_name) == 5:
return [key_name[4:].lower()]
if key_name.startswith("KEY_") and key_name[4:].isdigit():
return [key_name[4:]]
return []
def reset_input_state(self):
try:
self.env["runtime"]["InputManager"].reset_input_state()
except Exception:
self.clear_event_buffer()
def write_event_buffer(self):
self.clear_event_buffer()
def clear_event_buffer(self):
if not self._initialized:
return
del self.env["input"]["event_buffer"][:]
def write_debug(self, message, level):
try:
self.env["runtime"]["DebugManager"].write_debug_out(message, level)
except Exception:
pass
def update_input_devices(self, new_devices=None, init=False):
return
def grab_all_devices(self):
return True
def ungrab_all_devices(self):
if not self.display or not self.window:
return True
for keycode, modifier_mask in list(self.grabbed):
try:
self.window.ungrab_key(keycode, modifier_mask)
except Exception:
pass
self.grabbed.clear()
try:
self.display.flush()
except Exception:
pass
return True
def remove_all_devices(self):
self.ungrab_all_devices()
def get_led_state(self, led=0):
return False
def set_led_state(self, led_dict):
return False
+135
View File
@@ -0,0 +1,135 @@
"""
Unit tests for DiffReviewManager parsing and presentation behavior.
"""
from unittest.mock import Mock
import pytest
import fenrirscreenreader.core.diffReviewManager as diff_review_module
from fenrirscreenreader.core.diffReviewManager import DiffReviewManager
@pytest.fixture
def diff_manager(monkeypatch):
"""Create a DiffReviewManager with a minimal test environment."""
monkeypatch.setattr(diff_review_module, "_", lambda text: text)
spoken_messages = []
output_manager = Mock()
def _capture_message(message, **_kwargs):
spoken_messages.append(message)
output_manager.present_text.side_effect = _capture_message
settings_manager = Mock()
settings_manager.get_setting.side_effect = (
lambda section, setting: "compact"
if (section, setting) == ("general", "diff_verbosity")
else "default"
)
env = {
"runtime": {
"DebugManager": Mock(write_debug_out=Mock()),
"OutputManager": output_manager,
"SettingsManager": settings_manager,
"MemoryManager": Mock(
is_index_list_empty=Mock(return_value=True),
get_index_list_element=Mock(return_value=""),
),
},
"bindings": {},
"rawBindings": {},
}
manager = DiffReviewManager()
manager.initialize(env)
return manager, spoken_messages
@pytest.mark.unit
class TestDiffReviewManager:
"""Test diff review parsing and speech presentation edge cases."""
def test_parse_unified_diff_tracks_sections_and_counts(self, diff_manager):
manager, _ = diff_manager
lines = [
"diff --git a/example.txt b/example.txt",
"index 1111111..2222222 100644",
"--- a/example.txt",
"+++ b/example.txt",
"@@ -1,2 +1,3 @@",
" context line",
"-old line",
"+new line one",
"+new line two",
]
manager._parse_lines(lines, "/tmp/example.diff")
assert len(manager.file_sections) == 1
assert len(manager.hunk_sections) == 1
assert manager.total_added == 2
assert manager.total_removed == 1
assert manager.file_sections[0]["added_count"] == 2
assert manager.file_sections[0]["removed_count"] == 1
assert manager.hunk_sections[0]["position_in_file"] == 1
assert manager.hunk_sections[0]["hunks_in_file"] == 1
def test_formats_classic_diff_markers(self, diff_manager):
manager, _ = diff_manager
assert manager._format_classic_diff_command("17c17") == "line 17 changed"
assert manager._format_classic_diff_command("17d16") == "line 17 deleted"
assert manager._format_classic_diff_command("16a17") == "line 17 added"
assert (
manager._format_classic_diff_command("20,22c30,32")
== "lines 20 through 22 changed"
)
assert manager._format_classic_diff_command("not a diff marker") is None
def test_announces_classic_marker_line_as_plain_language(self, diff_manager):
manager, spoken_messages = diff_manager
manager._parse_lines(["17c17"], "/tmp/classic.diff")
manager.current_line = 0
manager._announce_current_line()
assert spoken_messages[-1] == "line 17 changed"
def test_marker_only_added_removed_lines_become_words(self, diff_manager):
manager, spoken_messages = diff_manager
manager._parse_lines(["+++", "---"], "/tmp/markers.diff")
manager.current_line = 0
manager._announce_current_line()
manager.current_line = 1
manager._announce_current_line()
assert spoken_messages[-2] == "added"
assert spoken_messages[-1] == "removed"
def test_large_diff_parsing_stays_consistent(self, diff_manager):
manager, spoken_messages = diff_manager
line_count = 1500
lines = [
"diff --git a/large.txt b/large.txt",
"--- a/large.txt",
"+++ b/large.txt",
"@@ -1,1500 +1,1500 @@",
]
for index in range(1, line_count + 1):
lines.append(f"-old value {index}")
lines.append(f"+new value {index}")
manager._parse_lines(lines, "/tmp/large.diff")
manager.next_added()
assert len(manager.file_sections) == 1
assert len(manager.hunk_sections) == 1
assert manager.total_added == line_count
assert manager.total_removed == line_count
assert manager.current_line == manager.added_lines[0]
assert spoken_messages[-1].startswith("Added line.")
+140
View File
@@ -0,0 +1,140 @@
"""
Unit tests for incoming text announcement behavior.
"""
import importlib.util
from pathlib import Path
from unittest.mock import Mock
import pytest
def _load_incoming_module():
module_path = (
Path(__file__).resolve().parents[2]
/ "src"
/ "fenrirscreenreader"
/ "commands"
/ "onScreenUpdate"
/ "70000-incoming.py"
)
spec = importlib.util.spec_from_file_location(
"fenrir_incoming_command", module_path
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.fixture
def incoming_command():
incoming_module = _load_incoming_module()
output_manager = Mock(present_text=Mock())
settings_manager = Mock()
settings_manager.get_setting_as_bool.side_effect = (
lambda section, setting: True
if (section, setting) == ("speech", "auto_read_incoming")
else False
)
settings_manager.get_setting_as_float.side_effect = (
lambda section, setting: {
("speech", "rapid_update_window"): 0.3,
("speech", "batch_flush_interval"): 0.5,
}[(section, setting)]
)
settings_manager.get_setting_as_int.side_effect = (
lambda section, setting: {
("speech", "rapid_update_threshold"): 5,
("speech", "flood_line_threshold"): 500,
("speech", "max_batch_lines"): 100,
}[(section, setting)]
)
env = {
"runtime": {
"OutputManager": output_manager,
"SettingsManager": settings_manager,
"ScreenManager": Mock(
is_delta=Mock(return_value=True),
is_screen_change=Mock(return_value=False),
),
},
"screen": {
"new_delta": "",
"old_content_text": "",
"new_content_text": "",
"old_cursor": {"x": 0, "y": 0},
"new_cursor": {"x": 0, "y": 0},
"newNegativeDelta": "",
},
"commandBuffer": {},
}
command = incoming_module.command()
command.initialize(env)
return command, env, output_manager
@pytest.mark.unit
class TestIncomingCommand:
"""Test incoming text filtering for chat-like screen updates."""
def test_prefers_inserted_chat_line_over_changed_headers(
self, incoming_command
):
command, env, output_manager = incoming_command
env["screen"]["old_content_text"] = "\n".join(
[
"Status old",
"Users old",
"alice: hi",
"bob: hello",
"> ",
]
)
env["screen"]["new_content_text"] = "\n".join(
[
"Status new",
"Users new",
"bob: hello",
"carol: test",
"> ",
]
)
env["screen"]["new_delta"] = "\n".join(
["Status new", "Users new", "carol: test"]
)
command.run()
output_manager.present_text.assert_called_once_with(
"carol: test", interrupt=False, flush=False
)
def test_keeps_header_update_when_no_lower_screen_insert_exists(
self, incoming_command
):
command, env, output_manager = incoming_command
env["screen"]["old_content_text"] = "\n".join(
[
"Status old",
"Users old",
"alice: hi",
"bob: hello",
]
)
env["screen"]["new_content_text"] = "\n".join(
[
"Status new",
"Users new",
"alice: hi",
"bob: hello",
]
)
env["screen"]["new_delta"] = "\n".join(["Status new", "Users new"])
command.run()
output_manager.present_text.assert_called_once_with(
"Status new\nUsers new", interrupt=False, flush=False
)
+9
View File
@@ -10,6 +10,7 @@ import sys
from pathlib import Path
# Import the settings manager
from fenrirscreenreader.core.settingsData import settings_data
from fenrirscreenreader.core.settingsManager import SettingsManager
@@ -136,6 +137,7 @@ class TestDriverValidation:
# Valid drivers
self.manager._validate_setting_value("keyboard", "driver", "evdevDriver")
self.manager._validate_setting_value("keyboard", "driver", "ptyDriver")
self.manager._validate_setting_value("keyboard", "driver", "x11Driver")
self.manager._validate_setting_value("keyboard", "driver", "atspiDriver")
self.manager._validate_setting_value("keyboard", "driver", "dummyDriver")
@@ -186,3 +188,10 @@ class TestValidationSkipsUnknownSettings:
"""Unknown settings in known sections should not raise errors."""
# Should not raise - only specific critical settings are validated
self.manager._validate_setting_value("speech", "unknown_setting", "value")
@pytest.mark.unit
@pytest.mark.settings
def test_focus_settings_define_tui_toggle():
"""Focus settings should include the TUI toggle used by on-screen handlers."""
assert settings_data["focus"]["tui"] is False
+221
View File
@@ -0,0 +1,221 @@
import importlib.machinery
import importlib.util
from pathlib import Path
from unittest.mock import Mock
import pytest
from fenrirscreenreader.inputDriver.x11Driver import X
from fenrirscreenreader.inputDriver.x11Driver import XK
from fenrirscreenreader.inputDriver.x11Driver import driver as X11Driver
def load_fenrir_entrypoint():
fenrir_path = Path(__file__).parents[2] / "src" / "fenrir"
loader = importlib.machinery.SourceFileLoader(
"fenrir_entrypoint_x11", str(fenrir_path)
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
@pytest.mark.unit
def test_x11_mode_runs_in_foreground():
fenrir = load_fenrir_entrypoint()
args = fenrir.create_argument_parser().parse_args(["-x"])
assert fenrir.should_run_foreground(args) is True
@pytest.mark.unit
def test_x11_mode_rejects_other_emulated_modes():
fenrir = load_fenrir_entrypoint()
args = fenrir.create_argument_parser().parse_args(["-x", "-e"])
is_valid, error = fenrir.validate_arguments(args)
assert is_valid is False
assert "--x11" in error
@pytest.mark.unit
def test_x11_cli_accepts_window_id():
fenrir = load_fenrir_entrypoint()
args = fenrir.create_argument_parser().parse_args(
["-x", "--x11-window-id", "0x123"]
)
assert args.x11_window_id == "0x123"
@pytest.mark.unit
def test_x11_key_name_mapping_for_keypad_and_capslock():
x11 = X11Driver()
assert x11.keysym_name_to_key_name("KP_0") == "KEY_KP0"
assert x11.keysym_name_to_key_name("KP_Insert") == "KEY_KP0"
assert x11.keysym_name_to_key_name("Caps_Lock") == "KEY_CAPSLOCK"
assert x11.keysym_name_to_key_name("Super_L") == "KEY_LEFTMETA"
assert x11.keysym_name_to_key_name("Multi_key") == "KEY_COMPOSE"
assert x11.keysym_name_to_key_name("a") == "KEY_A"
assert x11.keysym_name_to_key_name("F10") == "KEY_F10"
@pytest.mark.unit
def test_x11_keycode_mapping_prefers_keypad_aliases():
x11 = X11Driver()
x11.display = Mock()
keysyms = {
0: XK.string_to_keysym("Up"),
1: XK.string_to_keysym("KP_8"),
2: 0,
3: 0,
4: 0,
5: 0,
6: 0,
7: 0,
}
x11.display.keycode_to_keysym.side_effect = lambda keycode, column: keysyms[column]
assert x11.keycode_to_key_name(80) == "KEY_KP8"
@pytest.mark.unit
def test_x11_keycode_mapping_preserves_plain_insert():
x11 = X11Driver()
x11.display = Mock()
keysyms = {
0: XK.string_to_keysym("Insert"),
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
6: 0,
7: 0,
}
x11.display.keycode_to_keysym.side_effect = lambda keycode, column: keysyms[column]
assert x11.keycode_to_key_name(118) == "KEY_INSERT"
@pytest.mark.unit
def test_x11_keycode_mapping_detects_keypad_insert():
x11 = X11Driver()
x11.display = Mock()
keysyms = {
0: XK.string_to_keysym("Insert"),
1: XK.string_to_keysym("KP_Insert"),
2: 0,
3: 0,
4: 0,
5: 0,
6: 0,
7: 0,
}
x11.display.keycode_to_keysym.side_effect = lambda keycode, column: keysyms[column]
assert x11.keycode_to_key_name(90) == "KEY_KP0"
@pytest.mark.unit
def test_x11_key_name_to_keysym_names_includes_numlock_aliases():
x11 = X11Driver()
assert x11.key_name_to_keysym_names("KEY_KP0") == ["KP_0", "KP_Insert"]
assert "KP_Delete" in x11.key_name_to_keysym_names("KEY_KPDOT")
assert "Caps_Lock" in x11.key_name_to_keysym_names("KEY_CAPSLOCK")
assert "Super_L" in x11.key_name_to_keysym_names("KEY_META")
assert "Meta_R" in x11.key_name_to_keysym_names("KEY_META")
assert x11.key_name_to_keysym_names("KEY_COMPOSE") == ["Multi_key"]
@pytest.mark.unit
def test_x11_build_passive_grabs_for_fenrir_keys_and_shortcuts():
x11 = X11Driver()
input_manager = Mock(convert_event_name=lambda key: key)
x11.env = {
"input": {
"fenrir_key": ["KEY_KP0", "KEY_CAPSLOCK"],
"script_key": [],
},
"rawBindings": {
"fenrir_combo": [1, ["KEY_FENRIR", "KEY_KP8"]],
"bare_keypad": [1, ["KEY_KP5"]],
"ctrl_keypad": [1, ["KEY_CTRL", "KEY_KP2"]],
},
"runtime": {"InputManager": input_manager},
}
grabs = x11.build_passive_grabs()
assert ("KEY_KP0", 0) in grabs
assert ("KEY_CAPSLOCK", 0) in grabs
assert ("KEY_KP5", 0) in grabs
assert ("KEY_KP2", X.ControlMask) in grabs
@pytest.mark.unit
def test_x11_parse_window_id_accepts_decimal_and_hex():
x11 = X11Driver()
assert x11.parse_window_id("123") == 123
assert x11.parse_window_id("0x123") == 0x123
assert x11.parse_window_id("") is None
@pytest.mark.unit
def test_x11_write_event_buffer_clears_buffer():
x11 = X11Driver()
x11._initialized = True
x11.env = {"input": {"event_buffer": ["KEY_KP0"]}}
x11.write_event_buffer()
assert x11.env["input"]["event_buffer"] == []
@pytest.mark.unit
def test_x11_find_num_lock_mask_uses_modifier_mapping():
x11 = X11Driver()
x11.display = Mock()
x11.display.keysym_to_keycode.return_value = 77
x11.display.get_modifier_mapping.return_value = [
[],
[],
[],
[],
[77],
[],
[],
[],
]
assert x11.find_num_lock_mask() == X.Mod2Mask
@pytest.mark.unit
def test_x11_handle_key_event_keeps_event_buffer_for_input_manager():
x11 = X11Driver()
x11.active = True
x11.interesting_keys = {"KEY_KP0"}
x11.env = {
"input": {
"curr_input": [],
"event_buffer": [],
},
"runtime": {
"InputManager": Mock(convert_event_name=lambda key: key),
"DebugManager": Mock(),
},
}
event_queue = Mock()
event = Mock(type=X.KeyPress, detail=90, state=0)
x11.keycode_to_key_name = Mock(return_value="KEY_KP0")
x11.handle_x_event(event, event_queue)
assert x11.env["input"]["event_buffer"][0]["event_name"] == "KEY_KP0"
event_queue.put.assert_called_once()