Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fed8f24126 | |||
| 40120f825c | |||
| 17dea6b026 | |||
| 57c09e0db9 | |||
| 4050c32a16 |
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -101,6 +101,7 @@ setup(
|
||||
"dbus-python>=1.2.8",
|
||||
"pyperclip",
|
||||
"pyudev>=0.21.0",
|
||||
"python-xlib>=0.33",
|
||||
"setuptools",
|
||||
"setproctitle",
|
||||
"pexpect",
|
||||
|
||||
+23
-3
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user