Add speech-first diff review mode with navigation and tests
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -226,6 +226,15 @@ 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
|
||||
|
||||
48
docs/diff_review_mode.md
Normal file
48
docs/diff_review_mode.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Diff Review Mode
|
||||
|
||||
Diff review mode provides read-only navigation for unified and classic diff files with speech-first output.
|
||||
|
||||
## Quick Workflow
|
||||
|
||||
1. Copy the full absolute path of a diff file to the Fenrir clipboard.
|
||||
2. Press `Fenrir + Ctrl + D` to toggle diff mode on.
|
||||
3. Review the diff using the keys below.
|
||||
4. Press `Esc` to leave diff mode.
|
||||
|
||||
If the clipboard does not contain a full absolute file path, Fenrir announces an error and does not enter diff mode.
|
||||
|
||||
## Keys (Active Only In Diff Mode)
|
||||
|
||||
- `h` - Next hunk
|
||||
- `Shift + h` - Previous hunk
|
||||
- `f` - Next file section
|
||||
- `Shift + f` - Previous file section
|
||||
- `a` - Next added line
|
||||
- `Shift + a` - Previous added line
|
||||
- `d` - Next removed line
|
||||
- `Shift + d` - Previous removed line
|
||||
- `Up` - Previous line
|
||||
- `Down` - Next line
|
||||
- `Left` - Previous character
|
||||
- `Right` - Next character
|
||||
- `Ctrl + Left` - Previous word
|
||||
- `Ctrl + Right` - Next word
|
||||
- `s` - Diff summary
|
||||
- `F1` - Speak key help
|
||||
- `Esc` - Exit diff mode
|
||||
|
||||
## Speech Behavior
|
||||
|
||||
- Added and removed content is spoken as `Added:` and `Removed:` lines.
|
||||
- Marker-only lines are normalized for speech:
|
||||
- `+++` is spoken as `added`
|
||||
- `---` is spoken as `removed`
|
||||
- Classic diff markers are spoken in plain language:
|
||||
- `17c17` -> `line 17 changed`
|
||||
- `17d16` -> `line 17 deleted`
|
||||
- `16a17` -> `line 17 added`
|
||||
|
||||
## Notes
|
||||
|
||||
- Diff mode is read-only and does not modify the diff file.
|
||||
- Normal Fenrir key bindings are restored when diff mode exits.
|
||||
@@ -379,5 +379,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
|
||||
|
||||
27
src/fenrirscreenreader/commands/commands/diff_key_help.py
Normal file
27
src/fenrirscreenreader/commands/commands/diff_key_help.py
Normal file
@@ -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
|
||||
27
src/fenrirscreenreader/commands/commands/diff_next_added.py
Normal file
27
src/fenrirscreenreader/commands/commands/diff_next_added.py
Normal file
@@ -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
|
||||
27
src/fenrirscreenreader/commands/commands/diff_next_char.py
Normal file
27
src/fenrirscreenreader/commands/commands/diff_next_char.py
Normal file
@@ -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
|
||||
27
src/fenrirscreenreader/commands/commands/diff_next_hunk.py
Normal file
27
src/fenrirscreenreader/commands/commands/diff_next_hunk.py
Normal file
@@ -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
|
||||
27
src/fenrirscreenreader/commands/commands/diff_next_line.py
Normal file
27
src/fenrirscreenreader/commands/commands/diff_next_line.py
Normal file
@@ -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
|
||||
27
src/fenrirscreenreader/commands/commands/diff_next_word.py
Normal file
27
src/fenrirscreenreader/commands/commands/diff_next_word.py
Normal file
@@ -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
|
||||
27
src/fenrirscreenreader/commands/commands/diff_prev_added.py
Normal file
27
src/fenrirscreenreader/commands/commands/diff_prev_added.py
Normal file
@@ -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
|
||||
27
src/fenrirscreenreader/commands/commands/diff_prev_char.py
Normal file
27
src/fenrirscreenreader/commands/commands/diff_prev_char.py
Normal file
@@ -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
|
||||
27
src/fenrirscreenreader/commands/commands/diff_prev_hunk.py
Normal file
27
src/fenrirscreenreader/commands/commands/diff_prev_hunk.py
Normal file
@@ -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
|
||||
27
src/fenrirscreenreader/commands/commands/diff_prev_line.py
Normal file
27
src/fenrirscreenreader/commands/commands/diff_prev_line.py
Normal file
@@ -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
|
||||
27
src/fenrirscreenreader/commands/commands/diff_prev_word.py
Normal file
27
src/fenrirscreenreader/commands/commands/diff_prev_word.py
Normal file
@@ -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
|
||||
27
src/fenrirscreenreader/commands/commands/diff_summary.py
Normal file
27
src/fenrirscreenreader/commands/commands/diff_summary.py
Normal file
@@ -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
|
||||
32
src/fenrirscreenreader/commands/commands/exit_diff_mode.py
Normal file
32
src/fenrirscreenreader/commands/commands/exit_diff_mode.py
Normal file
@@ -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
|
||||
27
src/fenrirscreenreader/commands/commands/toggle_diff_mode.py
Normal file
27
src/fenrirscreenreader/commands/commands/toggle_diff_mode.py
Normal file
@@ -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
|
||||
917
src/fenrirscreenreader/core/diffReviewManager.py
Normal file
917
src/fenrirscreenreader/core/diffReviewManager.py
Normal file
@@ -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",
|
||||
|
||||
@@ -21,4 +21,5 @@ runtime_data = {
|
||||
"FenrirManager": None,
|
||||
"EventManager": None,
|
||||
"ProcessManager": None,
|
||||
"DiffReviewManager": None,
|
||||
}
|
||||
|
||||
@@ -75,6 +75,8 @@ settings_data = {
|
||||
"auto_present_indent_mode": 1,
|
||||
"has_attributes": True,
|
||||
"shell": "",
|
||||
"diff_presentation": "both",
|
||||
"diff_verbosity": "compact",
|
||||
},
|
||||
"focus": {
|
||||
"cursor": True,
|
||||
|
||||
@@ -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
|
||||
@@ -642,6 +643,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.11"
|
||||
code_name = "master"
|
||||
version = "2026.02.15"
|
||||
code_name = "testing"
|
||||
|
||||
135
tests/unit/test_diff_review_manager.py
Normal file
135
tests/unit/test_diff_review_manager.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Unit tests for DiffReviewManager parsing and presentation behavior.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import fenrirscreenreader.core.diffReviewManager as diff_review_module
|
||||
from fenrirscreenreader.core.diffReviewManager import DiffReviewManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def diff_manager(monkeypatch):
|
||||
"""Create a DiffReviewManager with a minimal test environment."""
|
||||
monkeypatch.setattr(diff_review_module, "_", lambda text: text)
|
||||
|
||||
spoken_messages = []
|
||||
output_manager = Mock()
|
||||
|
||||
def _capture_message(message, **_kwargs):
|
||||
spoken_messages.append(message)
|
||||
|
||||
output_manager.present_text.side_effect = _capture_message
|
||||
settings_manager = Mock()
|
||||
settings_manager.get_setting.side_effect = (
|
||||
lambda section, setting: "compact"
|
||||
if (section, setting) == ("general", "diff_verbosity")
|
||||
else "default"
|
||||
)
|
||||
|
||||
env = {
|
||||
"runtime": {
|
||||
"DebugManager": Mock(write_debug_out=Mock()),
|
||||
"OutputManager": output_manager,
|
||||
"SettingsManager": settings_manager,
|
||||
"MemoryManager": Mock(
|
||||
is_index_list_empty=Mock(return_value=True),
|
||||
get_index_list_element=Mock(return_value=""),
|
||||
),
|
||||
},
|
||||
"bindings": {},
|
||||
"rawBindings": {},
|
||||
}
|
||||
|
||||
manager = DiffReviewManager()
|
||||
manager.initialize(env)
|
||||
return manager, spoken_messages
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDiffReviewManager:
|
||||
"""Test diff review parsing and speech presentation edge cases."""
|
||||
|
||||
def test_parse_unified_diff_tracks_sections_and_counts(self, diff_manager):
|
||||
manager, _ = diff_manager
|
||||
lines = [
|
||||
"diff --git a/example.txt b/example.txt",
|
||||
"index 1111111..2222222 100644",
|
||||
"--- a/example.txt",
|
||||
"+++ b/example.txt",
|
||||
"@@ -1,2 +1,3 @@",
|
||||
" context line",
|
||||
"-old line",
|
||||
"+new line one",
|
||||
"+new line two",
|
||||
]
|
||||
|
||||
manager._parse_lines(lines, "/tmp/example.diff")
|
||||
|
||||
assert len(manager.file_sections) == 1
|
||||
assert len(manager.hunk_sections) == 1
|
||||
assert manager.total_added == 2
|
||||
assert manager.total_removed == 1
|
||||
assert manager.file_sections[0]["added_count"] == 2
|
||||
assert manager.file_sections[0]["removed_count"] == 1
|
||||
assert manager.hunk_sections[0]["position_in_file"] == 1
|
||||
assert manager.hunk_sections[0]["hunks_in_file"] == 1
|
||||
|
||||
def test_formats_classic_diff_markers(self, diff_manager):
|
||||
manager, _ = diff_manager
|
||||
|
||||
assert manager._format_classic_diff_command("17c17") == "line 17 changed"
|
||||
assert manager._format_classic_diff_command("17d16") == "line 17 deleted"
|
||||
assert manager._format_classic_diff_command("16a17") == "line 17 added"
|
||||
assert (
|
||||
manager._format_classic_diff_command("20,22c30,32")
|
||||
== "lines 20 through 22 changed"
|
||||
)
|
||||
assert manager._format_classic_diff_command("not a diff marker") is None
|
||||
|
||||
def test_announces_classic_marker_line_as_plain_language(self, diff_manager):
|
||||
manager, spoken_messages = diff_manager
|
||||
|
||||
manager._parse_lines(["17c17"], "/tmp/classic.diff")
|
||||
manager.current_line = 0
|
||||
manager._announce_current_line()
|
||||
|
||||
assert spoken_messages[-1] == "line 17 changed"
|
||||
|
||||
def test_marker_only_added_removed_lines_become_words(self, diff_manager):
|
||||
manager, spoken_messages = diff_manager
|
||||
|
||||
manager._parse_lines(["+++", "---"], "/tmp/markers.diff")
|
||||
manager.current_line = 0
|
||||
manager._announce_current_line()
|
||||
manager.current_line = 1
|
||||
manager._announce_current_line()
|
||||
|
||||
assert spoken_messages[-2] == "added"
|
||||
assert spoken_messages[-1] == "removed"
|
||||
|
||||
def test_large_diff_parsing_stays_consistent(self, diff_manager):
|
||||
manager, spoken_messages = diff_manager
|
||||
line_count = 1500
|
||||
lines = [
|
||||
"diff --git a/large.txt b/large.txt",
|
||||
"--- a/large.txt",
|
||||
"+++ b/large.txt",
|
||||
"@@ -1,1500 +1,1500 @@",
|
||||
]
|
||||
for index in range(1, line_count + 1):
|
||||
lines.append(f"-old value {index}")
|
||||
lines.append(f"+new value {index}")
|
||||
|
||||
manager._parse_lines(lines, "/tmp/large.diff")
|
||||
manager.next_added()
|
||||
|
||||
assert len(manager.file_sections) == 1
|
||||
assert len(manager.hunk_sections) == 1
|
||||
assert manager.total_added == line_count
|
||||
assert manager.total_removed == line_count
|
||||
assert manager.current_line == manager.added_lines[0]
|
||||
assert spoken_messages[-1].startswith("Added line.")
|
||||
|
||||
Reference in New Issue
Block a user