diff --git a/src/fenrirscreenreader/core/fenrirManager.py b/src/fenrirscreenreader/core/fenrirManager.py index ed19624f..60a4315a 100644 --- a/src/fenrirscreenreader/core/fenrirManager.py +++ b/src/fenrirscreenreader/core/fenrirManager.py @@ -46,6 +46,7 @@ class FenrirManager: self.is_initialized = True self.modifierInput = False + self.modifier_prefix_input = False self.singleKeyCommand = False self.command = "" self.set_process_name() @@ -121,13 +122,16 @@ class FenrirManager: self.environment["runtime"][ "InputManager" ].clear_event_buffer() - else: + # Hold conventional modifier presses long enough to determine + # whether a Fenrir or Script modifier follows them. + elif not self.modifier_prefix_input: self.environment["runtime"][ "InputManager" ].write_event_buffer() if self.environment["runtime"]["InputManager"].no_key_pressed(): self.modifierInput = False + self.modifier_prefix_input = False self.singleKeyCommand = False self.environment["runtime"]["InputManager"].write_event_buffer() self.environment["runtime"]["InputManager"].handle_device_grab() @@ -281,14 +285,26 @@ class FenrirManager: ): return - if self.environment["runtime"]["InputManager"].is_key_press(): - self.modifierInput = self.environment["runtime"][ - "InputManager" - ].curr_key_is_modifier() + input_manager = self.environment["runtime"]["InputManager"] + if input_manager.is_key_press(): + self.modifierInput = input_manager.curr_input_has_command_modifier() + self.modifier_prefix_input = ( + not self.modifierInput + and input_manager.curr_input_is_modifier_prefix() + ) else: - if not self.environment["runtime"][ - "InputManager" - ].no_key_pressed(): + if ( + not self.modifierInput + and input_manager.curr_input_has_command_modifier() + ): + self.modifierInput = True + self.modifier_prefix_input = False + elif ( + self.modifier_prefix_input + and not input_manager.curr_input_is_modifier_prefix() + ): + self.modifier_prefix_input = False + if not input_manager.no_key_pressed(): if self.singleKeyCommand: self.singleKeyCommand = ( len(self.environment["input"]["curr_input"]) == 1 diff --git a/src/fenrirscreenreader/core/inputManager.py b/src/fenrirscreenreader/core/inputManager.py index 1324668e..1e78dbd6 100644 --- a/src/fenrirscreenreader/core/inputManager.py +++ b/src/fenrirscreenreader/core/inputManager.py @@ -18,6 +18,13 @@ currentdir = os.path.dirname( ) fenrir_path = os.path.dirname(currentdir) +SHORTCUT_MODIFIER_KEYS = { + "KEY_ALT", + "KEY_CTRL", + "KEY_META", + "KEY_SHIFT", +} + class InputManager: def __init__(self): @@ -468,6 +475,16 @@ class InputManager: self.env["input"]["curr_input"][0] == "KEY_SCRIPT" ) + def curr_input_has_command_modifier(self): + current_input = self.env["input"]["curr_input"] + return "KEY_FENRIR" in current_input or "KEY_SCRIPT" in current_input + + def curr_input_is_modifier_prefix(self): + current_input = self.env["input"]["curr_input"] + return bool(current_input) and all( + key_name in SHORTCUT_MODIFIER_KEYS for key_name in current_input + ) + def is_fenrir_key(self, event_name): return event_name in self.env["input"]["fenrir_key"] diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 0db3d02e..658a556b 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.08.06" +version = "2026.08.12" code_name = "testing" diff --git a/src/fenrirscreenreader/inputDriver/x11Driver.py b/src/fenrirscreenreader/inputDriver/x11Driver.py index 7d6335e8..c7b70d42 100644 --- a/src/fenrirscreenreader/inputDriver/x11Driver.py +++ b/src/fenrirscreenreader/inputDriver/x11Driver.py @@ -148,6 +148,13 @@ class driver(inputDriver): modifier_key_names = set(modifier_masks.keys()) + canonical_modifier_masks = ( + ("KEY_SHIFT", X.ShiftMask if X else 1), + ("KEY_CTRL", X.ControlMask if X else 4), + ("KEY_ALT", X.Mod1Mask if X else 8), + ("KEY_META", X.Mod4Mask if X else 64), + ) + def __init__(self): inputDriver.__init__(self) self.display = None @@ -163,6 +170,8 @@ class driver(inputDriver): self.failed_grabs = 0 self.modifier_state = 0 self.modifier_interrupt_state = 0 + self.command_key_active = False + self.chord_modifiers = set() def initialize(self, environment): self.env = environment @@ -316,6 +325,11 @@ class driver(inputDriver): if not self.should_emit_key(key_name): return self.update_modifier_state_from_event(input_event) + is_command_key = self.is_command_key(key_name) + if is_command_key and input_event["event_state"] == 1: + self.start_command_chord(input_event, event_queue) + elif self.command_key_active and not is_command_key: + self.track_chord_modifier(input_event) self.write_debug( "x11Driver key event " + key_name @@ -332,6 +346,73 @@ class driver(inputDriver): "data": input_event, } ) + if is_command_key and input_event["event_state"] == 0: + self.finish_command_chord(input_event, event_queue) + + def is_command_key(self, key_name): + if key_name in self.fenrir_keys or key_name in self.env["input"].get( + "script_key", [] + ): + return True + converted_name = self.env["runtime"][ + "InputManager" + ].convert_event_name(key_name) + return converted_name in {"KEY_FENRIR", "KEY_SCRIPT"} + + def start_command_chord(self, input_event, event_queue): + self.command_key_active = True + raw_state = input_event.get("event_raw_state", 0) + current_input = self.env["input"]["curr_input"] + for key_name, modifier_mask in self.canonical_modifier_masks: + if not raw_state & modifier_mask: + continue + self.chord_modifiers.add(key_name) + if key_name not in current_input: + self.queue_synthetic_modifier( + key_name, 1, input_event, event_queue + ) + + def track_chord_modifier(self, input_event): + key_name = self.canonical_modifier_name(input_event["event_name"]) + if key_name is None: + return + if input_event["event_state"] == 1: + self.chord_modifiers.add(key_name) + elif input_event["event_state"] == 0: + self.chord_modifiers.discard(key_name) + + def canonical_modifier_name(self, key_name): + modifier_mask = self.modifier_masks.get(key_name, 0) + for canonical_name, canonical_mask in self.canonical_modifier_masks: + if modifier_mask == canonical_mask: + return canonical_name + return None + + def finish_command_chord(self, input_event, event_queue): + for key_name in sorted(self.chord_modifiers): + self.queue_synthetic_modifier(key_name, 0, input_event, event_queue) + self.chord_modifiers.clear() + self.command_key_active = False + + def queue_synthetic_modifier( + self, key_name, event_state, source_event, event_queue + ): + synthetic_event = { + "event_name": key_name, + "event_value": 0, + "event_sec": source_event["event_sec"], + "event_usec": source_event["event_usec"], + "event_state": event_state, + "event_type": 0, + "event_raw_state": source_event.get("event_raw_state", 0), + "event_x_time": source_event.get("event_x_time", X.CurrentTime), + } + event_queue.put( + { + "Type": FenrirEventType.keyboard_input, + "data": synthetic_event, + } + ) def event_is_in_target_tree(self, event): event_window = getattr(event, "event", None) @@ -698,6 +779,8 @@ class driver(inputDriver): return [] def reset_input_state(self): + self.command_key_active = False + self.chord_modifiers.clear() try: self.env["runtime"]["InputManager"].reset_input_state() except Exception: diff --git a/tests/unit/test_fenrir_manager_shortcuts.py b/tests/unit/test_fenrir_manager_shortcuts.py new file mode 100644 index 00000000..c037d975 --- /dev/null +++ b/tests/unit/test_fenrir_manager_shortcuts.py @@ -0,0 +1,199 @@ +from unittest.mock import Mock + +import pytest + +from fenrirscreenreader.core.eventData import FenrirEventType +from fenrirscreenreader.core.fenrirManager import FenrirManager +from fenrirscreenreader.core.inputManager import InputManager + + +def create_handle_input_manager(no_key_pressed): + manager = FenrirManager.__new__(FenrirManager) + manager.modifierInput = False + manager.modifier_prefix_input = True + manager.singleKeyCommand = False + manager.command = "" + manager.detect_shortcut_command = Mock() + manager.update_key_forward = Mock() + + input_manager = Mock( + convert_event_name=Mock(side_effect=lambda key_name: key_name), + no_key_pressed=Mock(return_value=no_key_pressed), + ) + manager.environment = { + "input": {"key_forward": 0}, + "runtime": { + "DebugManager": Mock(write_debug_out=Mock()), + "InputManager": input_manager, + "ScreenManager": Mock(is_ignored_screen=Mock(return_value=False)), + "HelpManager": Mock(is_tutorial_mode=Mock(return_value=False)), + "VmenuManager": Mock(get_active=Mock(return_value=False)), + "DiffReviewManager": Mock(is_active=Mock(return_value=False)), + "SpeechHistoryManager": Mock(is_active=Mock(return_value=False)), + "CommandManager": Mock(execute_default_trigger=Mock()), + }, + } + return manager, input_manager + + +@pytest.mark.unit +def test_input_manager_recognizes_conventional_modifier_prefix(): + input_manager = InputManager() + input_manager.env = {"input": {"curr_input": ["KEY_CTRL", "KEY_SHIFT"]}} + + assert input_manager.curr_input_is_modifier_prefix() is True + + input_manager.env["input"]["curr_input"].append("KEY_S") + + assert input_manager.curr_input_is_modifier_prefix() is False + + +@pytest.mark.unit +def test_first_conventional_modifier_starts_deferred_prefix(): + manager = FenrirManager.__new__(FenrirManager) + manager.modifierInput = False + manager.modifier_prefix_input = False + manager.singleKeyCommand = False + manager.command = "" + input_manager = Mock( + is_key_press=Mock(return_value=True), + curr_input_has_command_modifier=Mock(return_value=False), + curr_input_is_modifier_prefix=Mock(return_value=True), + get_curr_shortcut=Mock(return_value=str([1, ["KEY_CTRL"]])), + get_command_for_shortcut=Mock(return_value=""), + ) + manager.environment = { + "input": { + "key_forward": 0, + "prev_input": [], + "curr_input": ["KEY_CTRL"], + }, + "runtime": { + "InputManager": input_manager, + "EventManager": Mock(put_to_event_queue=Mock()), + "VmenuManager": Mock(get_active=Mock(return_value=False)), + "DiffReviewManager": Mock(is_active=Mock(return_value=False)), + "SpeechHistoryManager": Mock(is_active=Mock(return_value=False)), + }, + } + + manager.detect_shortcut_command() + + assert manager.modifier_prefix_input is True + + +@pytest.mark.unit +def test_fenrir_shortcut_is_dispatched_when_ctrl_is_pressed_first(): + manager = FenrirManager.__new__(FenrirManager) + manager.modifierInput = False + manager.modifier_prefix_input = True + manager.singleKeyCommand = False + manager.command = "" + + event_manager = Mock(put_to_event_queue=Mock()) + input_manager = Mock( + is_key_press=Mock(return_value=False), + no_key_pressed=Mock(return_value=False), + curr_input_has_command_modifier=Mock(return_value=True), + get_curr_shortcut=Mock( + side_effect=[ + str([1, ["KEY_CTRL", "KEY_FENRIR"]]), + str([1, ["KEY_CTRL", "KEY_FENRIR", "KEY_S"]]), + ] + ), + get_command_for_shortcut=Mock(side_effect=["", "SAVE_SETTINGS"]), + ) + manager.environment = { + "input": { + "key_forward": 0, + "prev_input": ["KEY_CTRL"], + "curr_input": ["KEY_CTRL", "KEY_FENRIR"], + }, + "runtime": { + "InputManager": input_manager, + "EventManager": event_manager, + "VmenuManager": Mock(get_active=Mock(return_value=False)), + "DiffReviewManager": Mock(is_active=Mock(return_value=False)), + "SpeechHistoryManager": Mock(is_active=Mock(return_value=False)), + }, + } + + manager.detect_shortcut_command() + + assert manager.modifierInput is True + assert manager.modifier_prefix_input is False + event_manager.put_to_event_queue.assert_not_called() + + manager.environment["input"]["prev_input"] = [ + "KEY_CTRL", + "KEY_FENRIR", + ] + manager.environment["input"]["curr_input"] = [ + "KEY_CTRL", + "KEY_FENRIR", + "KEY_S", + ] + manager.detect_shortcut_command() + + event_manager.put_to_event_queue.assert_called_once_with( + FenrirEventType.execute_command, "SAVE_SETTINGS" + ) + + +@pytest.mark.unit +def test_action_key_disarms_modifier_prefix(): + manager = FenrirManager.__new__(FenrirManager) + manager.modifierInput = False + manager.modifier_prefix_input = True + manager.singleKeyCommand = False + manager.command = "" + input_manager = Mock( + is_key_press=Mock(return_value=False), + no_key_pressed=Mock(return_value=False), + curr_input_has_command_modifier=Mock(return_value=False), + curr_input_is_modifier_prefix=Mock(return_value=False), + get_curr_shortcut=Mock(return_value=str([1, ["KEY_C", "KEY_CTRL"]])), + get_command_for_shortcut=Mock(return_value=""), + ) + manager.environment = { + "input": { + "key_forward": 0, + "prev_input": ["KEY_CTRL"], + "curr_input": ["KEY_C", "KEY_CTRL"], + }, + "runtime": { + "InputManager": input_manager, + "EventManager": Mock(put_to_event_queue=Mock()), + "VmenuManager": Mock(get_active=Mock(return_value=False)), + "DiffReviewManager": Mock(is_active=Mock(return_value=False)), + "SpeechHistoryManager": Mock(is_active=Mock(return_value=False)), + }, + } + + manager.detect_shortcut_command() + + assert manager.modifier_prefix_input is False + + +@pytest.mark.unit +def test_modifier_prefix_is_not_forwarded_before_chord_is_known(): + manager, input_manager = create_handle_input_manager(False) + + manager.handle_input( + {"data": {"event_name": "KEY_CTRL", "event_state": 1}} + ) + + input_manager.write_event_buffer.assert_not_called() + input_manager.clear_event_buffer.assert_not_called() + + +@pytest.mark.unit +def test_modifier_prefix_is_forwarded_when_released_unused(): + manager, input_manager = create_handle_input_manager(True) + + manager.handle_input( + {"data": {"event_name": "KEY_CTRL", "event_state": 0}} + ) + + input_manager.write_event_buffer.assert_called_once_with() + assert manager.modifier_prefix_input is False diff --git a/tests/unit/test_fenrir_manager_speech_history.py b/tests/unit/test_fenrir_manager_speech_history.py index fd68ecc2..1bc9d05a 100644 --- a/tests/unit/test_fenrir_manager_speech_history.py +++ b/tests/unit/test_fenrir_manager_speech_history.py @@ -10,6 +10,7 @@ from fenrirscreenreader.core.fenrirManager import FenrirManager def test_speech_history_plain_key_modal_command_is_dispatched(): manager = FenrirManager.__new__(FenrirManager) manager.modifierInput = False + manager.modifier_prefix_input = False manager.singleKeyCommand = False manager.command = "" @@ -50,6 +51,7 @@ def test_speech_history_plain_key_modal_command_is_dispatched(): def test_vmenu_plain_key_modal_command_is_dispatched(): manager = FenrirManager.__new__(FenrirManager) manager.modifierInput = False + manager.modifier_prefix_input = False manager.singleKeyCommand = False manager.command = "" diff --git a/tests/unit/test_x11_terminal_mode.py b/tests/unit/test_x11_terminal_mode.py index 9ca6d376..bf2f4a6b 100644 --- a/tests/unit/test_x11_terminal_mode.py +++ b/tests/unit/test_x11_terminal_mode.py @@ -425,3 +425,63 @@ def test_x11_handle_key_event_keeps_event_buffer_for_input_manager(): assert x11.env["input"]["event_buffer"][0]["event_name"] == "KEY_KP0" event_queue.put.assert_called_once() + + +@pytest.mark.unit +def test_x11_fenrir_press_emits_modifiers_from_raw_state_first(): + x11 = X11Driver() + x11.active = True + x11.fenrir_keys = {"KEY_KP0"} + x11.interesting_keys = {"KEY_KP0"} + x11.env = { + "input": { + "curr_input": [], + "event_buffer": [], + }, + "runtime": { + "InputManager": Mock(convert_event_name=lambda key: key), + "DebugManager": Mock(), + }, + } + event_queue = Mock() + event = Mock(type=X.KeyPress, detail=90, state=X.ControlMask) + x11.keycode_to_key_name = Mock(return_value="KEY_KP0") + + x11.handle_x_event(event, event_queue) + + modifier_event = event_queue.put.call_args_list[0].args[0] + fenrir_event = event_queue.put.call_args_list[1].args[0] + assert modifier_event["data"]["event_name"] == "KEY_CTRL" + assert modifier_event["data"]["event_state"] == 1 + assert fenrir_event["data"]["event_name"] == "KEY_KP0" + + +@pytest.mark.unit +def test_x11_fenrir_release_balances_synthesized_modifiers(): + x11 = X11Driver() + x11.active = True + x11.fenrir_keys = {"KEY_KP0"} + x11.interesting_keys = {"KEY_KP0"} + x11.command_key_active = True + x11.chord_modifiers = {"KEY_CTRL"} + x11.env = { + "input": { + "curr_input": ["KEY_CTRL", "KEY_FENRIR"], + "event_buffer": [], + }, + "runtime": { + "InputManager": Mock(convert_event_name=lambda key: key), + "DebugManager": Mock(), + }, + } + event_queue = Mock() + event = Mock(type=X.KeyRelease, detail=90, state=X.ControlMask) + x11.keycode_to_key_name = Mock(return_value="KEY_KP0") + + x11.handle_x_event(event, event_queue) + + fenrir_event = event_queue.put.call_args_list[0].args[0] + modifier_event = event_queue.put.call_args_list[1].args[0] + assert fenrir_event["data"]["event_name"] == "KEY_KP0" + assert modifier_event["data"]["event_name"] == "KEY_CTRL" + assert modifier_event["data"]["event_state"] == 0