Compare commits
3 Commits
6876547590
...
a8e4d7bb2a
| Author | SHA1 | Date | |
|---|---|---|---|
| a8e4d7bb2a | |||
| 8966275071 | |||
| c48a9a6731 |
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user