Fix IRC incoming announcements and add focus.tui default
This commit is contained in:
@@ -241,6 +241,8 @@ diff_verbosity=compact
|
|||||||
cursor=True
|
cursor=True
|
||||||
# Follow and announce highlighted/selected text changes (useful in menus)
|
# Follow and announce highlighted/selected text changes (useful in menus)
|
||||||
highlight=False
|
highlight=False
|
||||||
|
# Suppress generic incoming announcements for full-screen TUIs that redraw often
|
||||||
|
tui=False
|
||||||
|
|
||||||
[remote]
|
[remote]
|
||||||
enable=True
|
enable=True
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
# Fenrir TTY screen reader
|
# Fenrir TTY screen reader
|
||||||
# By Chrys, Storm Dragon, and contributors.
|
# By Chrys, Storm Dragon, and contributors.
|
||||||
|
|
||||||
|
import difflib
|
||||||
import time
|
import time
|
||||||
from fenrirscreenreader.core.i18n import _
|
from fenrirscreenreader.core.i18n import _
|
||||||
|
|
||||||
@@ -111,6 +112,71 @@ class command:
|
|||||||
|
|
||||||
return False
|
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):
|
def run(self):
|
||||||
if not self.env["runtime"]["SettingsManager"].get_setting_as_bool(
|
if not self.env["runtime"]["SettingsManager"].get_setting_as_bool(
|
||||||
"speech", "auto_read_incoming"
|
"speech", "auto_read_incoming"
|
||||||
@@ -169,6 +235,7 @@ class command:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# print(x_move, y_move, len(self.env['screen']['new_delta']), len(self.env['screen']['newNegativeDelta']))
|
# 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(
|
self.env["runtime"]["OutputManager"].present_text(
|
||||||
delta_text, interrupt=False, flush=False
|
delta_text, interrupt=False, flush=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ settings_data = {
|
|||||||
"focus": {
|
"focus": {
|
||||||
"cursor": True,
|
"cursor": True,
|
||||||
"highlight": False,
|
"highlight": False,
|
||||||
|
"tui": False,
|
||||||
},
|
},
|
||||||
"remote": {
|
"remote": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
|
|||||||
@@ -4,5 +4,5 @@
|
|||||||
# Fenrir TTY screen reader
|
# Fenrir TTY screen reader
|
||||||
# By Chrys, Storm Dragon, and contributors.
|
# By Chrys, Storm Dragon, and contributors.
|
||||||
|
|
||||||
version = "2026.03.04"
|
version = "2026.03.25"
|
||||||
code_name = "testing"
|
code_name = "testing"
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -10,6 +10,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Import the settings manager
|
# Import the settings manager
|
||||||
|
from fenrirscreenreader.core.settingsData import settings_data
|
||||||
from fenrirscreenreader.core.settingsManager import SettingsManager
|
from fenrirscreenreader.core.settingsManager import SettingsManager
|
||||||
|
|
||||||
|
|
||||||
@@ -186,3 +187,10 @@ class TestValidationSkipsUnknownSettings:
|
|||||||
"""Unknown settings in known sections should not raise errors."""
|
"""Unknown settings in known sections should not raise errors."""
|
||||||
# Should not raise - only specific critical settings are validated
|
# Should not raise - only specific critical settings are validated
|
||||||
self.manager._validate_setting_value("speech", "unknown_setting", "value")
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user