Improve modifier key detection for speech interruption.

This commit is contained in:
Storm Dragon
2026-05-22 13:39:53 -04:00
parent f84167a7fb
commit 3a1dded3ed
3 changed files with 141 additions and 1 deletions
+1 -1
View File
@@ -4,5 +4,5 @@
# Fenrir TTY screen reader # Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors. # By Chrys, Storm Dragon, and contributors.
version = "2026.05.21" version = "2026.05.22"
code_name = "testing" code_name = "testing"
@@ -162,6 +162,7 @@ class driver(inputDriver):
self.fenrir_keys = set() self.fenrir_keys = set()
self.failed_grabs = 0 self.failed_grabs = 0
self.modifier_state = 0 self.modifier_state = 0
self.modifier_interrupt_state = 0
def initialize(self, environment): def initialize(self, environment):
self.env = environment self.env = environment
@@ -194,6 +195,7 @@ class driver(inputDriver):
) )
self.num_lock_mask = self.find_num_lock_mask() self.num_lock_mask = self.find_num_lock_mask()
self.refresh_modifier_state() self.refresh_modifier_state()
self.modifier_interrupt_state = self.modifier_state
self.refresh_interesting_keys() self.refresh_interesting_keys()
self.refresh_grabs(force=True) self.refresh_grabs(force=True)
self.env["runtime"]["ProcessManager"].add_custom_event_thread( self.env["runtime"]["ProcessManager"].add_custom_event_thread(
@@ -274,6 +276,7 @@ class driver(inputDriver):
while active.value: while active.value:
try: try:
self.refresh_grabs() self.refresh_grabs()
self.poll_modifier_interrupt_keys(event_queue)
if not self.display.pending_events(): if not self.display.pending_events():
time.sleep(0.01) time.sleep(0.01)
continue continue
@@ -371,6 +374,67 @@ class driver(inputDriver):
"event_x_time": getattr(event, "time", X.CurrentTime), "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): def refresh_modifier_state(self):
try: try:
pointer = self.root.query_pointer() pointer = self.root.query_pointer()
@@ -612,6 +676,9 @@ class driver(inputDriver):
value: key for key, value in self.key_name_overrides.items() value: key for key, value in self.key_name_overrides.items()
} }
aliases = { 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_META": ["Super_L", "Super_R", "Meta_L", "Meta_R"],
"KEY_LEFTMETA": ["Super_L", "Meta_L"], "KEY_LEFTMETA": ["Super_L", "Meta_L"],
"KEY_RIGHTMETA": ["Super_R", "Meta_R"], "KEY_RIGHTMETA": ["Super_R", "Meta_R"],
@@ -659,6 +726,8 @@ class driver(inputDriver):
def replay_key_event(self, event): def replay_key_event(self, event):
if not isinstance(event, dict): if not isinstance(event, dict):
return return
if event.get("event_synthetic"):
return
if event.get("event_type") != X.KeyPress: if event.get("event_type") != X.KeyPress:
return return
try: try:
+71
View File
@@ -165,6 +165,15 @@ def test_x11_keycode_mapping_detects_keypad_insert():
def test_x11_key_name_to_keysym_names_includes_numlock_aliases(): def test_x11_key_name_to_keysym_names_includes_numlock_aliases():
x11 = X11Driver() 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 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 "KP_Delete" in x11.key_name_to_keysym_names("KEY_KPDOT")
assert "Caps_Lock" in x11.key_name_to_keysym_names("KEY_CAPSLOCK") 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 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 @pytest.mark.unit
def test_x11_optional_modifier_masks_can_exclude_numlock(): def test_x11_optional_modifier_masks_can_exclude_numlock():
x11 = X11Driver() x11 = X11Driver()
@@ -331,6 +378,30 @@ def test_x11_write_event_buffer_does_not_replay_key_release():
assert x11.env["input"]["event_buffer"] == [] 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 @pytest.mark.unit
def test_x11_map_event_keeps_x_event_time_for_replay(): def test_x11_map_event_keeps_x_event_time_for_replay():
x11 = X11Driver() x11 = X11Driver()