Skip to content

Commit 2581ba6

Browse files
authored
Fixes for input combinations (#965)
- Fixing release events when keys are released in order of pressing and other stuff - fix incorrect tests - some maintainability improvements and refactorings
1 parent 7c4ecf3 commit 2581ba6

File tree

5 files changed

+443
-78
lines changed

5 files changed

+443
-78
lines changed

inputremapper/injection/mapping_handlers/combination_handler.py

Lines changed: 102 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from __future__ import annotations # needed for the TYPE_CHECKING import
2121

22-
from typing import TYPE_CHECKING, Dict, Hashable
22+
from typing import TYPE_CHECKING, Dict, Hashable, Tuple
2323

2424
import evdev
2525
from evdev.ecodes import EV_ABS, EV_REL
@@ -44,9 +44,12 @@ class CombinationHandler(MappingHandler):
4444

4545
# map of InputEvent.input_match_hash -> bool , keep track of the combination state
4646
_pressed_keys: Dict[Hashable, bool]
47-
_output_state: bool # the last update we sent to a sub-handler
47+
# the last update we sent to a sub-handler. If this is true, the output key is
48+
# still being held down.
49+
_output_previously_active: bool
4850
_sub_handler: InputEventHandler
4951
_handled_input_hashes: list[Hashable]
52+
_requires_a_release: Dict[Tuple[int, int], bool]
5053

5154
def __init__(
5255
self,
@@ -59,8 +62,9 @@ def __init__(
5962
logger.debug(str(mapping))
6063
super().__init__(combination, mapping, global_uinputs)
6164
self._pressed_keys = {}
62-
self._output_state = False
65+
self._output_previously_active = False
6366
self._context = context
67+
self._requires_a_release = {}
6468

6569
# prepare a key map for all events with non-zero value
6670
for input_config in combination:
@@ -101,58 +105,118 @@ def notify(
101105
# we are not responsible for the event
102106
return False
103107

104-
was_activated = self.is_activated()
105-
106108
# update the state
107109
# The value of non-key input should have been changed to either 0 or 1 at this
108110
# point by other handlers.
109111
is_pressed = event.value == 1
110112
self._pressed_keys[event.input_match_hash] = is_pressed
111113
# maybe this changes the activation status (triggered/not-triggered)
112-
is_activated = self.is_activated()
114+
changed = self._is_activated() != self._output_previously_active
113115

114-
if is_activated == was_activated or is_activated == self._output_state:
115-
# nothing changed
116-
if self._output_state:
117-
# combination is active, consume the event
118-
return True
116+
if changed:
117+
if is_pressed:
118+
return self._handle_freshly_activated(suppress, event, source)
119119
else:
120-
# combination inactive, forward the event
121-
return False
122-
123-
if is_activated:
124-
# send key up events to the forwarded uinput
125-
self.forward_release()
126-
event = event.modify(value=1)
120+
return self._handle_freshly_deactivated(event, source)
127121
else:
128-
if self._output_state or self.mapping.is_axis_mapping():
129-
# we ignore the suppress argument for release events
130-
# otherwise we might end up with stuck keys
131-
# (test_event_pipeline.test_combination)
132-
133-
# we also ignore it if the mapping specifies an output axis
134-
# this will enable us to activate multiple axis with the same button
135-
suppress = False
136-
event = event.modify(value=0)
122+
if is_pressed:
123+
return self._handle_no_change_press(event)
124+
else:
125+
return self._handle_no_change_release(event)
137126

127+
def _handle_no_change_press(self, event: InputEvent) -> bool:
128+
"""A key was pressed, but this doesn't change the combinations activation state.
129+
Can only happen if either the combination wasn't already active, or a duplicate
130+
key-down event arrived (EV_ABS?)
131+
"""
132+
# self._output_previously_active is negated, because if the output is active, a
133+
# key-down event triggered it, which then did not get forwarded, therefore
134+
# it doesn't require a release.
135+
self._require_release_later(not self._output_previously_active, event)
136+
# output is active: consume the event
137+
# output inactive: forward the event
138+
return self._output_previously_active
139+
140+
def _handle_no_change_release(self, event: InputEvent) -> bool:
141+
"""One of the combinations keys was released, but it didn't untrigger the
142+
combination yet."""
143+
# Negate: `False` means that the event-reader will forward the release.
144+
return not self._should_release_event(event)
145+
146+
def _handle_freshly_activated(
147+
self,
148+
suppress: bool,
149+
event: InputEvent,
150+
source: evdev.InputDevice,
151+
) -> bool:
152+
"""The combination was deactivated, but is activated now."""
138153
if suppress:
139154
return False
140155

156+
# Send key up events to the forwarded uinput if configured to do so.
157+
self._forward_release()
158+
141159
logger.debug("Sending %s to sub-handler", self.mapping.input_combination)
142-
self._output_state = bool(event.value)
143-
return self._sub_handler.notify(event, source, suppress)
160+
self._output_previously_active = bool(event.value)
161+
sub_handler_result = self._sub_handler.notify(event, source, suppress)
162+
163+
# Only if the sub-handler return False, we need a release-event later.
164+
# If it handled the event, the user never sees this key-down event.
165+
self._require_release_later(not sub_handler_result, event)
166+
return sub_handler_result
167+
168+
def _handle_freshly_deactivated(
169+
self,
170+
event: InputEvent,
171+
source: evdev.InputDevice,
172+
) -> bool:
173+
"""The combination was activated, but is deactivated now."""
174+
# We ignore the `suppress` argument for release events. Otherwise, we
175+
# might end up with stuck keys (test_event_pipeline.test_combination).
176+
# In the case of output axis, this will enable us to activate multiple
177+
# axis with the same button.
178+
179+
logger.debug("Sending %s to sub-handler", self.mapping.input_combination)
180+
self._output_previously_active = bool(event.value)
181+
self._sub_handler.notify(event, source, suppress=False)
182+
183+
# Negate: `False` means that the event-reader will forward the release.
184+
return not self._should_release_event(event)
185+
186+
def _should_release_event(self, event: InputEvent) -> bool:
187+
"""Check if the key-up event should be forwarded by the event-reader.
188+
189+
After this, the release event needs to be injected by someone, otherwise the
190+
dictionary was modified erroneously. If there is no entry, we assume that there
191+
was no key-down event to release. Maybe a duplicate event arrived.
192+
"""
193+
# Ensure that all injected key-down events will get their release event
194+
# injected eventually.
195+
# If a key-up event arrives that will inactivate the combination, but
196+
# for which previously a key-down event was injected (because it was
197+
# an earlier key in the combination chain), then we need to ensure that its
198+
# release is injected as well. So we get two release events in that case:
199+
# one for the key, and one for the output.
200+
assert event.value == 0
201+
return self._requires_a_release.pop(event.type_and_code, False)
202+
203+
def _require_release_later(self, require: bool, event: InputEvent) -> None:
204+
"""Remember if this key-down event will need a release event later on."""
205+
assert event.value == 1
206+
self._requires_a_release[event.type_and_code] = require
144207

145208
def reset(self) -> None:
146209
self._sub_handler.reset()
147210
for key in self._pressed_keys:
148211
self._pressed_keys[key] = False
149-
self._output_state = False
212+
self._requires_a_release = {}
213+
self._output_previously_active = False
150214

151-
def is_activated(self) -> bool:
215+
def _is_activated(self) -> bool:
152216
"""Return if all keys in the keymap are set to True."""
153217
return False not in self._pressed_keys.values()
154218

155-
def forward_release(self) -> None:
219+
def _forward_release(self) -> None:
156220
"""Forward a button release for all keys if this is a combination.
157221
158222
This might cause duplicate key-up events but those are ignored by evdev anyway
@@ -168,6 +232,9 @@ def forward_release(self) -> None:
168232
logger.debug("Forwarding release for %s", self.mapping.input_combination)
169233

170234
for input_config in keys_to_release:
235+
if not self._requires_a_release.get(input_config.type_and_code):
236+
continue
237+
171238
origin_hash = input_config.origin_hash
172239
if origin_hash is None:
173240
logger.error(
@@ -180,6 +247,9 @@ def forward_release(self) -> None:
180247
forward_to.write(*input_config.type_and_code, 0)
181248
forward_to.syn()
182249

250+
# We are done with this key, forget about it
251+
del self._requires_a_release[input_config.type_and_code]
252+
183253
def needs_ranking(self) -> bool:
184254
return bool(self.input_configs)
185255

inputremapper/injection/mapping_handlers/key_handler.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ def child(self): # used for logging
6969

7070
def notify(self, event: InputEvent, *_, **__) -> bool:
7171
"""Inject event.value to the target key."""
72-
7372
event_tuple = (*self._maps_to, event.value)
7473
try:
7574
self.global_uinputs.write(event_tuple, self.mapping.target_uinput)

0 commit comments

Comments
 (0)