From 17dea6b0266ded75575747af5640e67814dfb626 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 25 Mar 2026 03:24:29 -0400 Subject: [PATCH] Fix IRC incoming announcements and add focus.tui default --- config/settings/settings.conf | 2 + .../commands/onScreenUpdate/70000-incoming.py | 67 +++++++++ src/fenrirscreenreader/core/settingsData.py | 1 + src/fenrirscreenreader/fenrirVersion.py | 2 +- tests/unit/test_incoming_command.py | 140 ++++++++++++++++++ tests/unit/test_settings_validation.py | 8 + 6 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_incoming_command.py diff --git a/config/settings/settings.conf b/config/settings/settings.conf index 67b0a53e..3779f968 100644 --- a/config/settings/settings.conf +++ b/config/settings/settings.conf @@ -241,6 +241,8 @@ diff_verbosity=compact cursor=True # Follow and announce highlighted/selected text changes (useful in menus) highlight=False +# Suppress generic incoming announcements for full-screen TUIs that redraw often +tui=False [remote] enable=True diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/70000-incoming.py b/src/fenrirscreenreader/commands/onScreenUpdate/70000-incoming.py index 1e9e0ea3..c2ff62f1 100644 --- a/src/fenrirscreenreader/commands/onScreenUpdate/70000-incoming.py +++ b/src/fenrirscreenreader/commands/onScreenUpdate/70000-incoming.py @@ -4,6 +4,7 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. +import difflib import time from fenrirscreenreader.core.i18n import _ @@ -111,6 +112,71 @@ class command: return False + def _normalize_line(self, line): + return " ".join(line.split()) + + def _is_subsequence(self, subset_lines, source_lines): + subset_index = 0 + for source_line in source_lines: + if source_line == subset_lines[subset_index]: + subset_index += 1 + if subset_index == len(subset_lines): + return True + return False + + def _prefer_inserted_lower_screen_text(self, delta_text): + delta_lines = [ + line for line in delta_text.splitlines() if line.strip() != "" + ] + if len(delta_lines) < 2: + return delta_text + + old_lines = self.env["screen"]["old_content_text"].split("\n") + new_lines = self.env["screen"]["new_content_text"].split("\n") + if len(old_lines) != len(new_lines) or len(new_lines) < 4: + return delta_text + + normalized_old_lines = [ + self._normalize_line(line) for line in old_lines + ] + normalized_new_lines = [ + self._normalize_line(line) for line in new_lines + ] + matcher = difflib.SequenceMatcher( + None, normalized_old_lines, normalized_new_lines, autojunk=False + ) + + top_lines_changed = False + inserted_lines = [] + lower_screen_start = max(2, len(new_lines) // 2) + + for tag, old_start, old_end, new_start, new_end in matcher.get_opcodes(): + if tag in {"replace", "delete"} and old_start < 2: + top_lines_changed = True + if tag != "insert" or new_start < lower_screen_start: + continue + inserted_lines.extend( + line for line in new_lines[new_start:new_end] if line.strip() != "" + ) + + if not top_lines_changed or not inserted_lines: + return delta_text + + normalized_delta_lines = [ + self._normalize_line(line) for line in delta_lines + ] + normalized_inserted_lines = [ + self._normalize_line(line) for line in inserted_lines + ] + if not self._is_subsequence( + normalized_inserted_lines, normalized_delta_lines + ): + return delta_text + if normalized_delta_lines == normalized_inserted_lines: + return delta_text + + return "\n".join(inserted_lines) + def run(self): if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( "speech", "auto_read_incoming" @@ -169,6 +235,7 @@ class command: return # print(x_move, y_move, len(self.env['screen']['new_delta']), len(self.env['screen']['newNegativeDelta'])) + delta_text = self._prefer_inserted_lower_screen_text(delta_text) self.env["runtime"]["OutputManager"].present_text( delta_text, interrupt=False, flush=False ) diff --git a/src/fenrirscreenreader/core/settingsData.py b/src/fenrirscreenreader/core/settingsData.py index 845f2ef7..efc39bb5 100644 --- a/src/fenrirscreenreader/core/settingsData.py +++ b/src/fenrirscreenreader/core/settingsData.py @@ -81,6 +81,7 @@ settings_data = { "focus": { "cursor": True, "highlight": False, + "tui": False, }, "remote": { "enabled": True, diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 00eb9825..63ac5a9b 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.03.04" +version = "2026.03.25" code_name = "testing" diff --git a/tests/unit/test_incoming_command.py b/tests/unit/test_incoming_command.py new file mode 100644 index 00000000..b5ea180d --- /dev/null +++ b/tests/unit/test_incoming_command.py @@ -0,0 +1,140 @@ +""" +Unit tests for incoming text announcement behavior. +""" + +import importlib.util +from pathlib import Path +from unittest.mock import Mock + +import pytest + + +def _load_incoming_module(): + module_path = ( + Path(__file__).resolve().parents[2] + / "src" + / "fenrirscreenreader" + / "commands" + / "onScreenUpdate" + / "70000-incoming.py" + ) + spec = importlib.util.spec_from_file_location( + "fenrir_incoming_command", module_path + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def incoming_command(): + incoming_module = _load_incoming_module() + output_manager = Mock(present_text=Mock()) + settings_manager = Mock() + settings_manager.get_setting_as_bool.side_effect = ( + lambda section, setting: True + if (section, setting) == ("speech", "auto_read_incoming") + else False + ) + settings_manager.get_setting_as_float.side_effect = ( + lambda section, setting: { + ("speech", "rapid_update_window"): 0.3, + ("speech", "batch_flush_interval"): 0.5, + }[(section, setting)] + ) + settings_manager.get_setting_as_int.side_effect = ( + lambda section, setting: { + ("speech", "rapid_update_threshold"): 5, + ("speech", "flood_line_threshold"): 500, + ("speech", "max_batch_lines"): 100, + }[(section, setting)] + ) + + env = { + "runtime": { + "OutputManager": output_manager, + "SettingsManager": settings_manager, + "ScreenManager": Mock( + is_delta=Mock(return_value=True), + is_screen_change=Mock(return_value=False), + ), + }, + "screen": { + "new_delta": "", + "old_content_text": "", + "new_content_text": "", + "old_cursor": {"x": 0, "y": 0}, + "new_cursor": {"x": 0, "y": 0}, + "newNegativeDelta": "", + }, + "commandBuffer": {}, + } + + command = incoming_module.command() + command.initialize(env) + return command, env, output_manager + + +@pytest.mark.unit +class TestIncomingCommand: + """Test incoming text filtering for chat-like screen updates.""" + + def test_prefers_inserted_chat_line_over_changed_headers( + self, incoming_command + ): + command, env, output_manager = incoming_command + env["screen"]["old_content_text"] = "\n".join( + [ + "Status old", + "Users old", + "alice: hi", + "bob: hello", + "> ", + ] + ) + env["screen"]["new_content_text"] = "\n".join( + [ + "Status new", + "Users new", + "bob: hello", + "carol: test", + "> ", + ] + ) + env["screen"]["new_delta"] = "\n".join( + ["Status new", "Users new", "carol: test"] + ) + + command.run() + + output_manager.present_text.assert_called_once_with( + "carol: test", interrupt=False, flush=False + ) + + def test_keeps_header_update_when_no_lower_screen_insert_exists( + self, incoming_command + ): + command, env, output_manager = incoming_command + env["screen"]["old_content_text"] = "\n".join( + [ + "Status old", + "Users old", + "alice: hi", + "bob: hello", + ] + ) + env["screen"]["new_content_text"] = "\n".join( + [ + "Status new", + "Users new", + "alice: hi", + "bob: hello", + ] + ) + env["screen"]["new_delta"] = "\n".join(["Status new", "Users new"]) + + command.run() + + output_manager.present_text.assert_called_once_with( + "Status new\nUsers new", interrupt=False, flush=False + ) diff --git a/tests/unit/test_settings_validation.py b/tests/unit/test_settings_validation.py index c74a78a9..10be49e1 100644 --- a/tests/unit/test_settings_validation.py +++ b/tests/unit/test_settings_validation.py @@ -10,6 +10,7 @@ import sys from pathlib import Path # Import the settings manager +from fenrirscreenreader.core.settingsData import settings_data from fenrirscreenreader.core.settingsManager import SettingsManager @@ -186,3 +187,10 @@ class TestValidationSkipsUnknownSettings: """Unknown settings in known sections should not raise errors.""" # Should not raise - only specific critical settings are validated self.manager._validate_setting_value("speech", "unknown_setting", "value") + + +@pytest.mark.unit +@pytest.mark.settings +def test_focus_settings_define_tui_toggle(): + """Focus settings should include the TUI toggle used by on-screen handlers.""" + assert settings_data["focus"]["tui"] is False