3 Commits

Author SHA1 Message Date
Storm Dragon a8e4d7bb2a Forward keypress bug fixed. 2026-05-14 20:10:11 -04:00
Storm Dragon 8966275071 Missed a couple of keys in translation for -x. 2026-05-13 23:39:15 -04:00
Storm Dragon c48a9a6731 Fixed a regression in reading dialog based TUI. 2026-05-13 18:31:11 -04:00
7 changed files with 270 additions and 5 deletions
@@ -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
)
+1 -1
View File
@@ -4,5 +4,5 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
version = "2026.05.12"
version = "2026.05.14"
code_name = "testing"
@@ -61,7 +61,11 @@ class driver(inputDriver):
"Escape": "KEY_ESC",
"space": "KEY_SPACE",
"minus": "KEY_MINUS",
"underscore": "KEY_MINUS",
"_": "KEY_MINUS",
"equal": "KEY_EQUAL",
"plus": "KEY_EQUAL",
"+": "KEY_EQUAL",
"bracketleft": "KEY_LEFTBRACE",
"bracketright": "KEY_RIGHTBRACE",
"backslash": "KEY_BACKSLASH",
@@ -422,10 +426,13 @@ class driver(inputDriver):
return names
def keysym_to_name(self, keysym):
keysym_name = XK.keysym_to_string(keysym)
if keysym_name:
keysym_name = KEYSYM_NAME_MAP.get(keysym)
if keysym_name and keysym >= 0xFF00:
return keysym_name
return KEYSYM_NAME_MAP.get(keysym)
keysym_string = XK.keysym_to_string(keysym)
if keysym_string:
return keysym_string
return keysym_name
def keysym_name_to_key_name(self, keysym_name):
if not keysym_name:
@@ -610,6 +617,8 @@ class driver(inputDriver):
"KEY_COMPOSE": ["Multi_key"],
"KEY_PAGEUP": ["Page_Up", "Prior"],
"KEY_PAGEDOWN": ["Page_Down", "Next"],
"KEY_MINUS": ["minus", "underscore"],
"KEY_EQUAL": ["equal", "plus"],
"KEY_KP0": ["KP_0", "KP_Insert"],
"KEY_KP1": ["KP_1", "KP_End"],
"KEY_KP2": ["KP_2", "KP_Down"],
@@ -351,7 +351,7 @@ class driver(screenDriver):
)
def synthesize_backspace_shortcut(self, msg_bytes, event_queue):
if msg_bytes not in [b"\x7f", b"\x08"]:
if not self.is_backspace_shortcut_sequence(msg_bytes):
return False
if "KEY_FENRIR" not in self.env["input"]["curr_input"]:
return False
@@ -380,6 +380,27 @@ class driver(screenDriver):
)
return True
def is_backspace_shortcut_sequence(self, msg_bytes):
if msg_bytes in [b"\x7f", b"\x08"]:
return True
try:
sequence = msg_bytes.decode("ascii")
except UnicodeDecodeError:
return False
if not sequence.startswith("\x1b["):
return False
if sequence.endswith("~"):
parts = sequence[2:-1].split(";")
if parts[0] == "3":
return True
if parts[0] == "27" and parts[-1] in ["8", "127"]:
return True
elif sequence.endswith("u"):
parts = sequence[2:-1].split(";")
if parts[0] in ["8", "127"]:
return True
return False
def get_session_information(self):
self.env["screen"]["autoIgnoreScreens"] = []
self.env["general"]["prev_user"] = getpass.getuser()
+92
View File
@@ -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,
)
+43
View File
@@ -217,6 +217,35 @@ def test_pty_backspace_with_fenrir_key_synthesizes_shortcut_events():
assert second_event["data"]["event_state"] == 0
@pytest.mark.unit
@pytest.mark.parametrize(
"sequence",
[
b"\x08",
b"\x1b[3~",
b"\x1b[3;5~",
b"\x1b[27;5;8~",
b"\x1b[27;5;127~",
b"\x1b[8;5u",
b"\x1b[127;5u",
],
)
def test_pty_xterm_backspace_variants_with_fenrir_key_synthesize_shortcut_events(
sequence,
):
pty_driver = PtyDriver()
event_queue = Mock()
pty_driver.env = {
"input": {"curr_input": ["KEY_FENRIR"]},
}
handled = pty_driver.synthesize_backspace_shortcut(sequence, event_queue)
assert handled is True
first_event = event_queue.put.call_args_list[0].args[0]
assert first_event["data"]["event_name"] == "KEY_BACKSPACE"
@pytest.mark.unit
def test_pty_plain_backspace_is_not_synthesized():
pty_driver = PtyDriver()
@@ -229,3 +258,17 @@ def test_pty_plain_backspace_is_not_synthesized():
assert handled is False
event_queue.put.assert_not_called()
@pytest.mark.unit
def test_pty_plain_delete_sequence_is_not_synthesized():
pty_driver = PtyDriver()
event_queue = Mock()
pty_driver.env = {
"input": {"curr_input": []},
}
handled = pty_driver.synthesize_backspace_shortcut(b"\x1b[3~", event_queue)
assert handled is False
event_queue.put.assert_not_called()
+28
View File
@@ -79,6 +79,29 @@ def test_x11_key_name_mapping_for_keypad_and_capslock():
assert x11.keysym_name_to_key_name("Prior") == "KEY_PAGEUP"
assert x11.keysym_name_to_key_name("a") == "KEY_A"
assert x11.keysym_name_to_key_name("F10") == "KEY_F10"
assert x11.keysym_name_to_key_name("plus") == "KEY_EQUAL"
assert x11.keysym_name_to_key_name("+") == "KEY_EQUAL"
assert x11.keysym_name_to_key_name("underscore") == "KEY_MINUS"
assert x11.keysym_name_to_key_name("_") == "KEY_MINUS"
@pytest.mark.unit
@pytest.mark.parametrize(
("keysym_name", "key_name"),
[
("BackSpace", "KEY_BACKSPACE"),
("Tab", "KEY_TAB"),
("Return", "KEY_ENTER"),
("Escape", "KEY_ESC"),
("Delete", "KEY_DELETE"),
],
)
def test_x11_special_keysyms_use_symbolic_names(keysym_name, key_name):
x11 = X11Driver()
resolved_name = x11.keysym_to_name(XK.string_to_keysym(keysym_name))
assert resolved_name == keysym_name
assert x11.keysym_name_to_key_name(resolved_name) == key_name
@pytest.mark.unit
@@ -150,6 +173,11 @@ def test_x11_key_name_to_keysym_names_includes_numlock_aliases():
assert x11.key_name_to_keysym_names("KEY_COMPOSE") == ["Multi_key"]
assert "Next" in x11.key_name_to_keysym_names("KEY_PAGEDOWN")
assert "Prior" in x11.key_name_to_keysym_names("KEY_PAGEUP")
assert x11.key_name_to_keysym_names("KEY_EQUAL") == ["equal", "plus"]
assert x11.key_name_to_keysym_names("KEY_MINUS") == [
"minus",
"underscore",
]
@pytest.mark.unit