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
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("speak diff review key help")
def run(self):
self.env["runtime"]["DiffReviewManager"].present_key_help()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to next added diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].next_added()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to next character in diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].next_char()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to next diff file section")
def run(self):
self.env["runtime"]["DiffReviewManager"].next_file_section()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to next diff hunk")
def run(self):
self.env["runtime"]["DiffReviewManager"].next_hunk()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to next diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].next_line()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to next removed diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].next_removed()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to next word in diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].next_word()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to previous added diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].prev_added()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to previous character in diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].prev_char()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to previous diff file section")
def run(self):
self.env["runtime"]["DiffReviewManager"].prev_file_section()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to previous diff hunk")
def run(self):
self.env["runtime"]["DiffReviewManager"].prev_hunk()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to previous diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].prev_line()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to previous removed diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].prev_removed()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("move to previous word in diff line")
def run(self):
self.env["runtime"]["DiffReviewManager"].prev_word()
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("speak diff summary")
def run(self):
self.env["runtime"]["DiffReviewManager"].present_summary()
def set_callback(self, callback):
pass
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("exit diff review mode")
def run(self):
if not self.env["runtime"]["DiffReviewManager"].is_active():
return
self.env["runtime"]["DiffReviewManager"].disable_mode()
self.env["runtime"]["DiffReviewManager"].present_mode_message(
_("Diff review mode disabled."),
)
def set_callback(self, callback):
pass
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("toggle diff review mode")
def run(self):
self.env["runtime"]["DiffReviewManager"].toggle_mode()
def set_callback(self, callback):
pass
@@ -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")
+2 -2
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"