Support modifier-first Fenrir shortcuts
This commit is contained in:
@@ -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"]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user