Support modifier-first Fenrir shortcuts

This commit is contained in:
Storm Dragon
2026-08-12 23:42:01 -04:00
parent ee441c319a
commit cca688328b
7 changed files with 386 additions and 9 deletions
+24 -8
View File
@@ -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
@@ -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"]
+1 -1
View File
@@ -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"
@@ -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:
+199
View File
@@ -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
@@ -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 = ""
+60
View File
@@ -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