diff --git a/src/fenrirscreenreader/commands/onKeyInput/80500-numlock.py b/src/fenrirscreenreader/commands/onKeyInput/80500-numlock.py index d7efe0c6..188e9e38 100644 --- a/src/fenrirscreenreader/commands/onKeyInput/80500-numlock.py +++ b/src/fenrirscreenreader/commands/onKeyInput/80500-numlock.py @@ -28,15 +28,21 @@ class command: # Only announce numlock changes if an actual numlock key was pressed # AND the LED state actually changed (some numpads send spurious NUMLOCK events) current_input = self.env["input"]["curr_input"] + previous_input = self.env["input"]["prev_input"] + relevant_input = current_input or previous_input # Check if this is a genuine numlock key press by verifying: # 1. KEY_NUMLOCK is in the current input sequence # 2. The LED state has actually changed # 3. This isn't just a side effect from a KP_ key (which some buggy numpads do) is_genuine_numlock = ( - current_input and - "KEY_NUMLOCK" in current_input and - not any(key.startswith("KEY_KP") for key in current_input if isinstance(key, str)) + relevant_input and + "KEY_NUMLOCK" in relevant_input and + not any( + key.startswith("KEY_KP") + for key in relevant_input + if isinstance(key, str) + ) ) if is_genuine_numlock: diff --git a/src/fenrirscreenreader/screenDriver/ptyDriver.py b/src/fenrirscreenreader/screenDriver/ptyDriver.py index ac3212c8..7cf3ecb9 100644 --- a/src/fenrirscreenreader/screenDriver/ptyDriver.py +++ b/src/fenrirscreenreader/screenDriver/ptyDriver.py @@ -302,6 +302,35 @@ class driver(screenDriver): return self.env["runtime"]["OutputManager"].interrupt_output() + def handle_stdin_input(self, msg_bytes, event_queue): + if self.synthesize_backspace_shortcut(msg_bytes, event_queue): + return + self.interrupt_output_on_stdin_input(msg_bytes) + self.inject_text_to_screen(msg_bytes) + + def synthesize_backspace_shortcut(self, msg_bytes, event_queue): + if msg_bytes not in [b"\x7f", b"\x08"]: + return False + if "KEY_FENRIR" not in self.env["input"]["curr_input"]: + return False + + event_time = time.time() + for event_state in [1, 0]: + event_queue.put( + { + "Type": FenrirEventType.keyboard_input, + "data": { + "event_name": "KEY_BACKSPACE", + "event_value": 0, + "event_sec": int(event_time), + "event_usec": int((event_time % 1) * 1000000), + "event_state": event_state, + "event_type": 0, + }, + } + ) + return True + def get_session_information(self): self.env["screen"]["autoIgnoreScreens"] = [] self.env["general"]["prev_user"] = getpass.getuser() @@ -434,8 +463,7 @@ class driver(screenDriver): ) break try: - self.interrupt_output_on_stdin_input(msg_bytes) - self.inject_text_to_screen(msg_bytes) + self.handle_stdin_input(msg_bytes, event_queue) except Exception as e: self.env["runtime"][ "DebugManager" diff --git a/tests/unit/test_numlock_command.py b/tests/unit/test_numlock_command.py new file mode 100644 index 00000000..9ba4b13c --- /dev/null +++ b/tests/unit/test_numlock_command.py @@ -0,0 +1,65 @@ +import importlib.util +from pathlib import Path +from unittest.mock import Mock + +import pytest + + +def load_numlock_command(): + command_path = ( + Path(__file__).parents[2] + / "src" + / "fenrirscreenreader" + / "commands" + / "onKeyInput" + / "80500-numlock.py" + ) + spec = importlib.util.spec_from_file_location( + "numlock_command", command_path + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.command + + +@pytest.mark.unit +def test_numlock_off_can_be_announced_from_release_event(): + output_manager = Mock() + command = load_numlock_command()() + command.initialize( + { + "input": { + "old_num_lock": True, + "new_num_lock": False, + "curr_input": [], + "prev_input": ["KEY_NUMLOCK"], + }, + "runtime": {"OutputManager": output_manager}, + } + ) + + command.run() + + output_manager.present_text.assert_called_once() + assert output_manager.present_text.call_args.args[0] == "Numlock off" + + +@pytest.mark.unit +def test_numlock_command_ignores_non_numlock_release(): + output_manager = Mock() + command = load_numlock_command()() + command.initialize( + { + "input": { + "old_num_lock": True, + "new_num_lock": False, + "curr_input": [], + "prev_input": ["KEY_KP1"], + }, + "runtime": {"OutputManager": output_manager}, + } + ) + + command.run() + + output_manager.present_text.assert_not_called() diff --git a/tests/unit/test_pty_terminal_sequences.py b/tests/unit/test_pty_terminal_sequences.py index 7bed7b51..a4522904 100644 --- a/tests/unit/test_pty_terminal_sequences.py +++ b/tests/unit/test_pty_terminal_sequences.py @@ -1,6 +1,7 @@ import pytest from unittest.mock import Mock +from fenrirscreenreader.core.eventData import FenrirEventType from fenrirscreenreader.screenDriver.ptyDriver import PTYConstants from fenrirscreenreader.screenDriver.ptyDriver import Terminal from fenrirscreenreader.screenDriver.ptyDriver import driver as PtyDriver @@ -109,3 +110,37 @@ def test_pty_stdin_input_leaves_filtered_interrupts_to_key_events(): pty_driver.interrupt_output_on_stdin_input(b"a") output_manager.interrupt_output.assert_not_called() + + +@pytest.mark.unit +def test_pty_backspace_with_fenrir_key_synthesizes_shortcut_events(): + pty_driver = PtyDriver() + event_queue = Mock() + pty_driver.env = { + "input": {"curr_input": ["KEY_FENRIR"]}, + } + + handled = pty_driver.synthesize_backspace_shortcut(b"\x7f", event_queue) + + assert handled is True + assert event_queue.put.call_count == 2 + first_event = event_queue.put.call_args_list[0].args[0] + second_event = event_queue.put.call_args_list[1].args[0] + assert first_event["Type"] == FenrirEventType.keyboard_input + assert first_event["data"]["event_name"] == "KEY_BACKSPACE" + assert first_event["data"]["event_state"] == 1 + assert second_event["data"]["event_state"] == 0 + + +@pytest.mark.unit +def test_pty_plain_backspace_is_not_synthesized(): + pty_driver = PtyDriver() + event_queue = Mock() + pty_driver.env = { + "input": {"curr_input": []}, + } + + handled = pty_driver.synthesize_backspace_shortcut(b"\x7f", event_queue) + + assert handled is False + event_queue.put.assert_not_called() diff --git a/tests/unit/test_x11_terminal_mode.py b/tests/unit/test_x11_terminal_mode.py index 3c780eae..3bf82249 100644 --- a/tests/unit/test_x11_terminal_mode.py +++ b/tests/unit/test_x11_terminal_mode.py @@ -167,23 +167,50 @@ def test_x11_build_passive_grabs_for_fenrir_keys_and_shortcuts(): input_manager = Mock(convert_event_name=lambda key: key) x11.env = { "input": { - "fenrir_key": ["KEY_KP0", "KEY_CAPSLOCK"], + "fenrir_key": ["KEY_KP0", "KEY_META"], "script_key": [], }, "rawBindings": { "fenrir_combo": [1, ["KEY_FENRIR", "KEY_KP8"]], "bare_keypad": [1, ["KEY_KP5"]], "ctrl_keypad": [1, ["KEY_CTRL", "KEY_KP2"]], + "meta_combo": [1, ["KEY_FENRIR", "KEY_BACKSPACE"]], }, "runtime": {"InputManager": input_manager}, } grabs = x11.build_passive_grabs() - assert ("KEY_KP0", 0) in grabs - assert ("KEY_CAPSLOCK", 0) in grabs - assert ("KEY_KP5", 0) in grabs - assert ("KEY_KP2", X.ControlMask) in grabs + assert ("KEY_KP0", 0, True) in grabs + assert ("KEY_META", 0, True) in grabs + assert ("KEY_NUMLOCK", 0, True) in grabs + assert ("KEY_KP5", 0, False) in grabs + assert ("KEY_KP2", X.ControlMask, False) in grabs + assert ("KEY_BACKSPACE", X.Mod4Mask, True) in grabs + + +@pytest.mark.unit +def test_x11_optional_modifier_masks_can_exclude_numlock(): + x11 = X11Driver() + x11.num_lock_mask = X.Mod2Mask + + masks = x11.optional_modifier_masks(0, include_num_lock=False) + + assert X.Mod2Mask not in masks + assert X.Mod2Mask | X.LockMask not in masks + + +@pytest.mark.unit +def test_x11_get_led_state_reads_lock_modifiers_from_pointer_mask(): + x11 = X11Driver() + x11.num_lock_mask = X.Mod2Mask + pointer = Mock(mask=X.Mod2Mask | X.LockMask) + x11.root = Mock() + x11.root.query_pointer.return_value = pointer + + assert x11.get_led_state(0) is True + assert x11.get_led_state(1) is True + assert x11.get_led_state(2) is False @pytest.mark.unit