diff --git a/config/keyboard/desktop.conf b/config/keyboard/desktop.conf index 27f0d868..98abf171 100644 --- a/config/keyboard/desktop.conf +++ b/config/keyboard/desktop.conf @@ -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 diff --git a/config/keyboard/laptop.conf b/config/keyboard/laptop.conf index 618278f3..1657f364 100644 --- a/config/keyboard/laptop.conf +++ b/config/keyboard/laptop.conf @@ -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 diff --git a/config/settings/settings.conf b/config/settings/settings.conf index 6ff31dc4..67b0a53e 100644 --- a/config/settings/settings.conf +++ b/config/settings/settings.conf @@ -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 diff --git a/docs/diff_review_mode.md b/docs/diff_review_mode.md new file mode 100644 index 00000000..925a38df --- /dev/null +++ b/docs/diff_review_mode.md @@ -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. diff --git a/docs/user.md b/docs/user.md index f3e59559..5358eb97 100644 --- a/docs/user.md +++ b/docs/user.md @@ -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 \ No newline at end of file +- `man fenrir` - Manual page diff --git a/src/fenrirscreenreader/commands/commands/diff_key_help.py b/src/fenrirscreenreader/commands/commands/diff_key_help.py new file mode 100644 index 00000000..7fdc5cea --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/diff_key_help.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/diff_next_added.py b/src/fenrirscreenreader/commands/commands/diff_next_added.py new file mode 100644 index 00000000..b15b9c5f --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/diff_next_added.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/diff_next_char.py b/src/fenrirscreenreader/commands/commands/diff_next_char.py new file mode 100644 index 00000000..0d109302 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/diff_next_char.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/diff_next_file_section.py b/src/fenrirscreenreader/commands/commands/diff_next_file_section.py new file mode 100644 index 00000000..d459d959 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/diff_next_file_section.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/diff_next_hunk.py b/src/fenrirscreenreader/commands/commands/diff_next_hunk.py new file mode 100644 index 00000000..477f894d --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/diff_next_hunk.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/diff_next_line.py b/src/fenrirscreenreader/commands/commands/diff_next_line.py new file mode 100644 index 00000000..bec32ae9 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/diff_next_line.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/diff_next_removed.py b/src/fenrirscreenreader/commands/commands/diff_next_removed.py new file mode 100644 index 00000000..6778d433 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/diff_next_removed.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/diff_next_word.py b/src/fenrirscreenreader/commands/commands/diff_next_word.py new file mode 100644 index 00000000..0741f2c8 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/diff_next_word.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/diff_prev_added.py b/src/fenrirscreenreader/commands/commands/diff_prev_added.py new file mode 100644 index 00000000..507e2acd --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/diff_prev_added.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/diff_prev_char.py b/src/fenrirscreenreader/commands/commands/diff_prev_char.py new file mode 100644 index 00000000..24d0d256 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/diff_prev_char.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/diff_prev_file_section.py b/src/fenrirscreenreader/commands/commands/diff_prev_file_section.py new file mode 100644 index 00000000..1c9ca8ff --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/diff_prev_file_section.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/diff_prev_hunk.py b/src/fenrirscreenreader/commands/commands/diff_prev_hunk.py new file mode 100644 index 00000000..d25226bc --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/diff_prev_hunk.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/diff_prev_line.py b/src/fenrirscreenreader/commands/commands/diff_prev_line.py new file mode 100644 index 00000000..971736ef --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/diff_prev_line.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/diff_prev_removed.py b/src/fenrirscreenreader/commands/commands/diff_prev_removed.py new file mode 100644 index 00000000..6f3af2e1 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/diff_prev_removed.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/diff_prev_word.py b/src/fenrirscreenreader/commands/commands/diff_prev_word.py new file mode 100644 index 00000000..c0658e6f --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/diff_prev_word.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/diff_summary.py b/src/fenrirscreenreader/commands/commands/diff_summary.py new file mode 100644 index 00000000..83ef984c --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/diff_summary.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/exit_diff_mode.py b/src/fenrirscreenreader/commands/commands/exit_diff_mode.py new file mode 100644 index 00000000..862e02d6 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/exit_diff_mode.py @@ -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 diff --git a/src/fenrirscreenreader/commands/commands/toggle_diff_mode.py b/src/fenrirscreenreader/commands/commands/toggle_diff_mode.py new file mode 100644 index 00000000..558712c5 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/toggle_diff_mode.py @@ -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 diff --git a/src/fenrirscreenreader/core/diffReviewManager.py b/src/fenrirscreenreader/core/diffReviewManager.py new file mode 100644 index 00000000..8f01eb94 --- /dev/null +++ b/src/fenrirscreenreader/core/diffReviewManager.py @@ -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 + ) diff --git a/src/fenrirscreenreader/core/fenrirManager.py b/src/fenrirscreenreader/core/fenrirManager.py index 719ff042..23e15eb1 100644 --- a/src/fenrirscreenreader/core/fenrirManager.py +++ b/src/fenrirscreenreader/core/fenrirManager.py @@ -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 diff --git a/src/fenrirscreenreader/core/generalData.py b/src/fenrirscreenreader/core/generalData.py index 4f621bc0..3c3718b7 100644 --- a/src/fenrirscreenreader/core/generalData.py +++ b/src/fenrirscreenreader/core/generalData.py @@ -28,6 +28,7 @@ general_data = { "VmenuManager", "QuickMenuManager", "ReadAllManager", + "DiffReviewManager", "RemoteManager", "SettingsManager", "SayAllManager", diff --git a/src/fenrirscreenreader/core/runtimeData.py b/src/fenrirscreenreader/core/runtimeData.py index a78ce85e..13937d0d 100644 --- a/src/fenrirscreenreader/core/runtimeData.py +++ b/src/fenrirscreenreader/core/runtimeData.py @@ -21,4 +21,5 @@ runtime_data = { "FenrirManager": None, "EventManager": None, "ProcessManager": None, + "DiffReviewManager": None, } diff --git a/src/fenrirscreenreader/core/settingsData.py b/src/fenrirscreenreader/core/settingsData.py index e87503eb..845f2ef7 100644 --- a/src/fenrirscreenreader/core/settingsData.py +++ b/src/fenrirscreenreader/core/settingsData.py @@ -75,6 +75,8 @@ settings_data = { "auto_present_indent_mode": 1, "has_attributes": True, "shell": "", + "diff_presentation": "both", + "diff_verbosity": "compact", }, "focus": { "cursor": True, diff --git a/src/fenrirscreenreader/core/settingsManager.py b/src/fenrirscreenreader/core/settingsManager.py index 63be2a68..1511164b 100644 --- a/src/fenrirscreenreader/core/settingsManager.py +++ b/src/fenrirscreenreader/core/settingsManager.py @@ -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") diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 83907a1e..7ce9cc40 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -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" diff --git a/tests/unit/test_diff_review_manager.py b/tests/unit/test_diff_review_manager.py new file mode 100644 index 00000000..e11ec346 --- /dev/null +++ b/tests/unit/test_diff_review_manager.py @@ -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.") +