Add speech-first diff review mode with navigation and tests

This commit is contained in:
Storm Dragon
2026-02-15 15:55:46 -05:00
parent 1e67876883
commit 4050c32a16
31 changed files with 1628 additions and 3 deletions

View File

@@ -83,6 +83,7 @@ KEY_FENRIR,KEY_CTRL,KEY_P=toggle_punctuation_level
KEY_FENRIR,KEY_RIGHTBRACE=toggle_auto_spell_check
KEY_FENRIR,KEY_BACKSLASH=toggle_output
KEY_FENRIR,KEY_CTRL,KEY_E=toggle_emoticons
KEY_FENRIR,KEY_CTRL,KEY_D=toggle_diff_mode
KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_E=cycle_key_echo
key_FENRIR,KEY_KPENTER=toggle_auto_read
KEY_FENRIR,KEY_CTRL,KEY_T=toggle_auto_time

View File

@@ -81,6 +81,7 @@ KEY_FENRIR,KEY_SHIFT,KEY_CTRL,KEY_P=toggle_punctuation_level
KEY_FENRIR,KEY_RIGHTBRACE=toggle_auto_spell_check
KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_ENTER=toggle_output
KEY_FENRIR,KEY_SHIFT,KEY_E=toggle_emoticons
KEY_FENRIR,KEY_CTRL,KEY_D=toggle_diff_mode
KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_E=cycle_key_echo
KEY_FENRIR,KEY_CTRL,KEY_T=toggle_auto_time
KEY_FENRIR,KEY_Y=toggle_highlight_tracking

View File

@@ -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
View File

@@ -0,0 +1,48 @@
# Diff Review Mode
Diff review mode provides read-only navigation for unified and classic diff files with speech-first output.
## Quick Workflow
1. Copy the full absolute path of a diff file to the Fenrir clipboard.
2. Press `Fenrir + Ctrl + D` to toggle diff mode on.
3. Review the diff using the keys below.
4. Press `Esc` to leave diff mode.
If the clipboard does not contain a full absolute file path, Fenrir announces an error and does not enter diff mode.
## Keys (Active Only In Diff Mode)
- `h` - Next hunk
- `Shift + h` - Previous hunk
- `f` - Next file section
- `Shift + f` - Previous file section
- `a` - Next added line
- `Shift + a` - Previous added line
- `d` - Next removed line
- `Shift + d` - Previous removed line
- `Up` - Previous line
- `Down` - Next line
- `Left` - Previous character
- `Right` - Next character
- `Ctrl + Left` - Previous word
- `Ctrl + Right` - Next word
- `s` - Diff summary
- `F1` - Speak key help
- `Esc` - Exit diff mode
## Speech Behavior
- Added and removed content is spoken as `Added:` and `Removed:` lines.
- Marker-only lines are normalized for speech:
- `+++` is spoken as `added`
- `---` is spoken as `removed`
- Classic diff markers are spoken in plain language:
- `17c17` -> `line 17 changed`
- `17d16` -> `line 17 deleted`
- `16a17` -> `line 17 added`
## Notes
- Diff mode is read-only and does not modify the diff file.
- Normal Fenrir key bindings are restored when diff mode exits.

View File

@@ -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

View 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

View 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

View 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

View 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 file section")
def run(self):
self.env["runtime"]["DiffReviewManager"].next_file_section()
def set_callback(self, callback):
pass

View 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

View 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

View 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 removed diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].next_removed()
def set_callback(self, callback):
pass

View 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

View 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

View 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

View 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 file section")
def run(self):
self.env["runtime"]["DiffReviewManager"].prev_file_section()
def set_callback(self, callback):
pass

View 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

View 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

View 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 removed diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].prev_removed()
def set_callback(self, callback):
pass

View 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

View 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

View 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

View 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

View 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
)

View File

@@ -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

View File

@@ -28,6 +28,7 @@ general_data = {
"VmenuManager",
"QuickMenuManager",
"ReadAllManager",
"DiffReviewManager",
"RemoteManager",
"SettingsManager",
"SayAllManager",

View File

@@ -21,4 +21,5 @@ runtime_data = {
"FenrirManager": None,
"EventManager": None,
"ProcessManager": None,
"DiffReviewManager": None,
}

View File

@@ -75,6 +75,8 @@ settings_data = {
"auto_present_indent_mode": 1,
"has_attributes": True,
"shell": "",
"diff_presentation": "both",
"diff_verbosity": "compact",
},
"focus": {
"cursor": True,

View File

@@ -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")

View File

@@ -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"

View File

@@ -0,0 +1,135 @@
"""
Unit tests for DiffReviewManager parsing and presentation behavior.
"""
from unittest.mock import Mock
import pytest
import fenrirscreenreader.core.diffReviewManager as diff_review_module
from fenrirscreenreader.core.diffReviewManager import DiffReviewManager
@pytest.fixture
def diff_manager(monkeypatch):
"""Create a DiffReviewManager with a minimal test environment."""
monkeypatch.setattr(diff_review_module, "_", lambda text: text)
spoken_messages = []
output_manager = Mock()
def _capture_message(message, **_kwargs):
spoken_messages.append(message)
output_manager.present_text.side_effect = _capture_message
settings_manager = Mock()
settings_manager.get_setting.side_effect = (
lambda section, setting: "compact"
if (section, setting) == ("general", "diff_verbosity")
else "default"
)
env = {
"runtime": {
"DebugManager": Mock(write_debug_out=Mock()),
"OutputManager": output_manager,
"SettingsManager": settings_manager,
"MemoryManager": Mock(
is_index_list_empty=Mock(return_value=True),
get_index_list_element=Mock(return_value=""),
),
},
"bindings": {},
"rawBindings": {},
}
manager = DiffReviewManager()
manager.initialize(env)
return manager, spoken_messages
@pytest.mark.unit
class TestDiffReviewManager:
"""Test diff review parsing and speech presentation edge cases."""
def test_parse_unified_diff_tracks_sections_and_counts(self, diff_manager):
manager, _ = diff_manager
lines = [
"diff --git a/example.txt b/example.txt",
"index 1111111..2222222 100644",
"--- a/example.txt",
"+++ b/example.txt",
"@@ -1,2 +1,3 @@",
" context line",
"-old line",
"+new line one",
"+new line two",
]
manager._parse_lines(lines, "/tmp/example.diff")
assert len(manager.file_sections) == 1
assert len(manager.hunk_sections) == 1
assert manager.total_added == 2
assert manager.total_removed == 1
assert manager.file_sections[0]["added_count"] == 2
assert manager.file_sections[0]["removed_count"] == 1
assert manager.hunk_sections[0]["position_in_file"] == 1
assert manager.hunk_sections[0]["hunks_in_file"] == 1
def test_formats_classic_diff_markers(self, diff_manager):
manager, _ = diff_manager
assert manager._format_classic_diff_command("17c17") == "line 17 changed"
assert manager._format_classic_diff_command("17d16") == "line 17 deleted"
assert manager._format_classic_diff_command("16a17") == "line 17 added"
assert (
manager._format_classic_diff_command("20,22c30,32")
== "lines 20 through 22 changed"
)
assert manager._format_classic_diff_command("not a diff marker") is None
def test_announces_classic_marker_line_as_plain_language(self, diff_manager):
manager, spoken_messages = diff_manager
manager._parse_lines(["17c17"], "/tmp/classic.diff")
manager.current_line = 0
manager._announce_current_line()
assert spoken_messages[-1] == "line 17 changed"
def test_marker_only_added_removed_lines_become_words(self, diff_manager):
manager, spoken_messages = diff_manager
manager._parse_lines(["+++", "---"], "/tmp/markers.diff")
manager.current_line = 0
manager._announce_current_line()
manager.current_line = 1
manager._announce_current_line()
assert spoken_messages[-2] == "added"
assert spoken_messages[-1] == "removed"
def test_large_diff_parsing_stays_consistent(self, diff_manager):
manager, spoken_messages = diff_manager
line_count = 1500
lines = [
"diff --git a/large.txt b/large.txt",
"--- a/large.txt",
"+++ b/large.txt",
"@@ -1,1500 +1,1500 @@",
]
for index in range(1, line_count + 1):
lines.append(f"-old value {index}")
lines.append(f"+new value {index}")
manager._parse_lines(lines, "/tmp/large.diff")
manager.next_added()
assert len(manager.file_sections) == 1
assert len(manager.hunk_sections) == 1
assert manager.total_added == line_count
assert manager.total_removed == line_count
assert manager.current_line == manager.added_lines[0]
assert spoken_messages[-1].startswith("Added line.")