diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index e764a03f..fbee5d53 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.21" +version = "2026.05.22" code_name = "testing" diff --git a/src/fenrirscreenreader/inputDriver/x11Driver.py b/src/fenrirscreenreader/inputDriver/x11Driver.py index 2f39b2be..c78de3ce 100644 --- a/src/fenrirscreenreader/inputDriver/x11Driver.py +++ b/src/fenrirscreenreader/inputDriver/x11Driver.py @@ -162,6 +162,7 @@ class driver(inputDriver): self.fenrir_keys = set() self.failed_grabs = 0 self.modifier_state = 0 + self.modifier_interrupt_state = 0 def initialize(self, environment): self.env = environment @@ -194,6 +195,7 @@ class driver(inputDriver): ) self.num_lock_mask = self.find_num_lock_mask() self.refresh_modifier_state() + self.modifier_interrupt_state = self.modifier_state self.refresh_interesting_keys() self.refresh_grabs(force=True) self.env["runtime"]["ProcessManager"].add_custom_event_thread( @@ -274,6 +276,7 @@ class driver(inputDriver): while active.value: try: self.refresh_grabs() + self.poll_modifier_interrupt_keys(event_queue) if not self.display.pending_events(): time.sleep(0.01) continue @@ -371,6 +374,67 @@ class driver(inputDriver): "event_x_time": getattr(event, "time", X.CurrentTime), } + def poll_modifier_interrupt_keys(self, event_queue): + if not self.active or not self.should_poll_modifier_interrupt_keys(): + return + try: + pointer = self.root.query_pointer() + current_state = getattr(pointer, "mask", 0) + except Exception: + return + previous_state = self.modifier_interrupt_state + self.modifier_interrupt_state = current_state + self.modifier_state = current_state + for key_name, modifier_mask in self.interrupt_modifier_masks(): + if current_state & modifier_mask and not previous_state & modifier_mask: + self.queue_modifier_interrupt_event(event_queue, key_name, 1) + elif previous_state & modifier_mask and not current_state & modifier_mask: + self.queue_modifier_interrupt_event(event_queue, key_name, 0) + + def should_poll_modifier_interrupt_keys(self): + try: + settings_manager = self.env["runtime"]["SettingsManager"] + except Exception: + return False + if not settings_manager.get_setting_as_bool( + "keyboard", "interrupt_on_key_press" + ): + return False + return ( + settings_manager.get_setting( + "keyboard", "interrupt_on_key_press_filter" + ).strip() + == "" + ) + + def interrupt_modifier_masks(self): + return [ + ("KEY_CTRL", X.ControlMask), + ("KEY_SHIFT", X.ShiftMask), + ("KEY_ALT", X.Mod1Mask), + ] + + def queue_modifier_interrupt_event(self, event_queue, key_name, event_state): + event_time = time.time() + input_event = { + "event_name": key_name, + "event_value": 0, + "event_sec": int(event_time), + "event_usec": int((event_time % 1) * 1000000), + "event_state": event_state, + "event_type": X.KeyPress if event_state == 1 else X.KeyRelease, + "event_raw_state": self.modifier_state, + "event_x_time": X.CurrentTime, + "event_synthetic": True, + } + self.env["input"]["event_buffer"].append(input_event.copy()) + event_queue.put( + { + "Type": FenrirEventType.keyboard_input, + "data": input_event, + } + ) + def refresh_modifier_state(self): try: pointer = self.root.query_pointer() @@ -612,6 +676,9 @@ class driver(inputDriver): value: key for key, value in self.key_name_overrides.items() } aliases = { + "KEY_CTRL": ["Control_L", "Control_R"], + "KEY_SHIFT": ["Shift_L", "Shift_R"], + "KEY_ALT": ["Alt_L", "Alt_R"], "KEY_META": ["Super_L", "Super_R", "Meta_L", "Meta_R"], "KEY_LEFTMETA": ["Super_L", "Meta_L"], "KEY_RIGHTMETA": ["Super_R", "Meta_R"], @@ -659,6 +726,8 @@ class driver(inputDriver): def replay_key_event(self, event): if not isinstance(event, dict): return + if event.get("event_synthetic"): + return if event.get("event_type") != X.KeyPress: return try: diff --git a/tests/unit/test_x11_terminal_mode.py b/tests/unit/test_x11_terminal_mode.py index c9233c76..1734efc5 100644 --- a/tests/unit/test_x11_terminal_mode.py +++ b/tests/unit/test_x11_terminal_mode.py @@ -165,6 +165,15 @@ def test_x11_keycode_mapping_detects_keypad_insert(): def test_x11_key_name_to_keysym_names_includes_numlock_aliases(): x11 = X11Driver() + assert x11.key_name_to_keysym_names("KEY_CTRL") == [ + "Control_L", + "Control_R", + ] + assert x11.key_name_to_keysym_names("KEY_SHIFT") == [ + "Shift_L", + "Shift_R", + ] + assert x11.key_name_to_keysym_names("KEY_ALT") == ["Alt_L", "Alt_R"] assert x11.key_name_to_keysym_names("KEY_KP0") == ["KP_0", "KP_Insert"] assert "KP_Delete" in x11.key_name_to_keysym_names("KEY_KPDOT") assert "Caps_Lock" in x11.key_name_to_keysym_names("KEY_CAPSLOCK") @@ -217,6 +226,44 @@ def test_x11_build_passive_grabs_for_fenrir_keys_and_shortcuts(): assert ("KEY_BACKSPACE", X.Mod4Mask, True) in grabs +@pytest.mark.unit +def test_x11_poll_modifier_interrupt_keys_emits_press_and_release(): + x11 = X11Driver() + x11.active = True + x11.modifier_interrupt_state = 0 + x11.modifier_state = 0 + x11.root = Mock() + x11.root.query_pointer.return_value = Mock(mask=X.ControlMask) + settings_manager = Mock() + settings_manager.get_setting_as_bool.return_value = True + settings_manager.get_setting.return_value = "" + x11.env = { + "input": {"event_buffer": []}, + "runtime": {"SettingsManager": settings_manager}, + } + event_queue = Mock() + + x11.poll_modifier_interrupt_keys(event_queue) + + event_queue.put.assert_called_once() + event = event_queue.put.call_args.args[0] + assert event["data"]["event_name"] == "KEY_CTRL" + assert event["data"]["event_state"] == 1 + assert x11.env["input"]["event_buffer"][0]["event_synthetic"] is True + + event_queue.reset_mock() + x11.env["input"]["event_buffer"] = [] + x11.root.query_pointer.return_value = Mock(mask=0) + + x11.poll_modifier_interrupt_keys(event_queue) + + event_queue.put.assert_called_once() + event = event_queue.put.call_args.args[0] + assert event["data"]["event_name"] == "KEY_CTRL" + assert event["data"]["event_state"] == 0 + assert x11.env["input"]["event_buffer"][0]["event_synthetic"] is True + + @pytest.mark.unit def test_x11_optional_modifier_masks_can_exclude_numlock(): x11 = X11Driver() @@ -331,6 +378,30 @@ def test_x11_write_event_buffer_does_not_replay_key_release(): assert x11.env["input"]["event_buffer"] == [] +@pytest.mark.unit +def test_x11_write_event_buffer_does_not_replay_synthetic_modifier_event(): + x11 = X11Driver() + x11._initialized = True + x11.display = Mock() + x11.env = { + "input": { + "event_buffer": [ + { + "event_name": "KEY_CTRL", + "event_state": 1, + "event_type": X.KeyPress, + "event_synthetic": True, + } + ] + } + } + + x11.write_event_buffer() + + x11.display.allow_events.assert_not_called() + assert x11.env["input"]["event_buffer"] == [] + + @pytest.mark.unit def test_x11_map_event_keeps_x_event_time_for_replay(): x11 = X11Driver()