From c48a9a6731432ec6527afda4f962a4f31aa11beb Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 13 May 2026 18:31:11 -0400 Subject: [PATCH] Fixed a regression in reading dialog based TUI. --- .../commands/onScreenUpdate/70000-incoming.py | 72 +++++++++++++++ src/fenrirscreenreader/fenrirVersion.py | 2 +- tests/unit/test_incoming_command.py | 92 +++++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/70000-incoming.py b/src/fenrirscreenreader/commands/onScreenUpdate/70000-incoming.py index 73ba06ae..085c90b1 100644 --- a/src/fenrirscreenreader/commands/onScreenUpdate/70000-incoming.py +++ b/src/fenrirscreenreader/commands/onScreenUpdate/70000-incoming.py @@ -5,6 +5,7 @@ # By Chrys, Storm Dragon, and contributors. import difflib +import re import time from fenrirscreenreader.core.i18n import _ @@ -16,6 +17,8 @@ class command: self._batched_text = [] self._last_flush_time = 0 self._in_flood_mode = False + self._pending_focus_text = "" + self._pending_focus_time = 0 def initialize(self, environment): self.env = environment @@ -115,6 +118,62 @@ class command: def _normalize_line(self, line): return " ".join(line.split()) + def _is_focus_control_line(self, text): + stripped = self._normalize_line(text) + if not stripped: + return False + if re.fullmatch(r"<\s*[A-Za-z][A-Za-z0-9 _.-]{0,30}\s*>", stripped): + return True + return stripped.lower() in { + "ok", + "cancel", + "yes", + "no", + "retry", + "abort", + "ignore", + "continue", + } + + def _is_transient_focus_only_update(self, delta_text): + delta_lines = [ + self._normalize_line(line) + for line in delta_text.splitlines() + if line.strip() + ] + if len(delta_lines) != 1: + return False + if not self._is_focus_control_line(delta_lines[0]): + return False + + screen_lines = [ + self._normalize_line(line) + for line in self.env["screen"]["new_content_text"].split("\n") + if line.strip() + ] + return screen_lines == delta_lines + + def _store_pending_focus_text(self, delta_text): + delta_lines = [ + self._normalize_line(line) + for line in delta_text.splitlines() + if line.strip() + ] + self._pending_focus_text = "\n".join(delta_lines) + self._pending_focus_time = time.time() + + def _take_pending_focus_text(self): + if not self._pending_focus_text: + return "" + if time.time() - self._pending_focus_time > 0.5: + self._pending_focus_text = "" + self._pending_focus_time = 0 + return "" + pending = self._pending_focus_text + self._pending_focus_text = "" + self._pending_focus_time = 0 + return pending + def _is_subsequence(self, subset_lines, source_lines): subset_index = 0 for source_line in source_lines: @@ -161,6 +220,10 @@ class command: if not top_lines_changed or not inserted_lines: return delta_text + if all( + self._is_focus_control_line(line) for line in inserted_lines + ): + return delta_text normalized_delta_lines = [ self._normalize_line(line) for line in delta_lines @@ -192,6 +255,8 @@ class command: if self.env["runtime"]["ScreenManager"].is_screen_change(): self._reset_flood_state() + self._pending_focus_text = "" + self._pending_focus_time = 0 # Skip if tab completion already handled this delta if self._was_handled_by_tab_completion(delta_text): @@ -214,6 +279,10 @@ class command: if "\n" not in delta_text: return + if self._is_transient_focus_only_update(delta_text): + self._store_pending_focus_text(delta_text) + return + rapid = self._is_rapid_updates() high_volume = self._is_high_volume(delta_text) @@ -238,6 +307,9 @@ class command: # 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) + pending_focus_text = self._take_pending_focus_text() + if pending_focus_text: + delta_text = "\n".join([delta_text, pending_focus_text]) self.env["runtime"]["OutputManager"].present_text( delta_text, interrupt=False, flush=False ) diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 3d1a410e..7f2ac83e 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.05.12" +version = "2026.05.13" code_name = "testing" diff --git a/tests/unit/test_incoming_command.py b/tests/unit/test_incoming_command.py index b3b2cccc..bac9c30a 100644 --- a/tests/unit/test_incoming_command.py +++ b/tests/unit/test_incoming_command.py @@ -148,3 +148,95 @@ class TestIncomingCommand: command.run() output_manager.present_text.assert_not_called() + + def test_dialog_button_paint_is_read_after_following_message( + self, incoming_command + ): + command, env, output_manager = incoming_command + env["screen"]["new_content_text"] = "\n".join( + [ + "".ljust(20), + "< OK >".center(20), + "".ljust(20), + ] + ) + env["screen"]["new_delta"] = "\n".join( + [ + " ", + " < OK >", + " ", + ] + ) + + command.run() + + output_manager.present_text.assert_not_called() + + env["screen"]["old_content_text"] = env["screen"]["new_content_text"] + env["screen"]["new_content_text"] = "\n".join( + [ + "This is a test".ljust(20), + "< OK >".center(20), + "".ljust(20), + ] + ) + env["screen"]["new_delta"] = "This is a test" + + command.run() + + output_manager.present_text.assert_called_once_with( + "This is a test\n< OK >", interrupt=False, flush=False + ) + + def test_single_word_output_is_not_treated_as_dialog_button( + self, incoming_command + ): + command, env, output_manager = incoming_command + env["screen"]["new_content_text"] = "Ready" + env["screen"]["new_delta"] = "Ready" + + command.run() + + output_manager.present_text.assert_called_once_with( + "Ready", interrupt=False, flush=False + ) + + def test_dialog_button_is_not_preferred_over_message( + self, incoming_command + ): + command, env, output_manager = incoming_command + env["screen"]["old_content_text"] = "\n".join( + [ + 'bash-5.3$ dialog --msgbox "This is a test" -1 -1'.ljust(80), + "".ljust(80), + "".ljust(80), + "".ljust(80), + "".ljust(80), + "".ljust(80), + ] + ) + env["screen"]["new_content_text"] = "\n".join( + [ + "".ljust(80), + " This is a test".ljust(80), + "".ljust(80), + "".ljust(80), + " < OK >".ljust(80), + "".ljust(80), + ] + ) + env["screen"]["new_delta"] = "\n".join( + [ + " ", + " This is a test ", + " < OK > ", + ] + ) + + command.run() + + output_manager.present_text.assert_called_once_with( + " \n This is a test \n < OK > ", + interrupt=False, + flush=False, + )