chore(repo): baseline du fork input-remapper (upstream v2.2.1)
Le dépôt ne contenait que README.md ; ajout de l'intégralité du code fonctionnel du fork comme socle stable de la branche de release. L'état runtime d'orchestration (.ideai/) est exclu du suivi. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
306
inputremapper/gui/reader_client.py
Normal file
306
inputremapper/gui/reader_client.py
Normal file
@ -0,0 +1,306 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Talking to the ReaderService that has root permissions.
|
||||
|
||||
see gui.reader_service.ReaderService
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Optional, List, Generator, Dict, Set
|
||||
|
||||
import evdev
|
||||
from gi.repository import GLib
|
||||
|
||||
from inputremapper.configs.input_config import (
|
||||
InputCombination,
|
||||
DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE,
|
||||
)
|
||||
from inputremapper.groups import _Groups, _Group
|
||||
from inputremapper.gui.gettext import _
|
||||
from inputremapper.gui.messages.message_broker import MessageBroker
|
||||
from inputremapper.gui.messages.message_data import (
|
||||
GroupsData,
|
||||
CombinationRecorded,
|
||||
StatusData,
|
||||
)
|
||||
from inputremapper.gui.messages.message_types import MessageType
|
||||
from inputremapper.gui.reader_service import (
|
||||
MSG_EVENT,
|
||||
MSG_GROUPS,
|
||||
CMD_TERMINATE,
|
||||
CMD_REFRESH_GROUPS,
|
||||
CMD_STOP_READING,
|
||||
ReaderService,
|
||||
)
|
||||
from inputremapper.gui.utils import CTX_ERROR
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.ipc.pipe import Pipe
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
BLACKLISTED_EVENTS = [(1, evdev.ecodes.BTN_TOOL_DOUBLETAP)]
|
||||
RecordingGenerator = Generator[None, InputEvent, None]
|
||||
|
||||
|
||||
class ReaderClient:
|
||||
"""Processes events from the reader-service for the GUI to use.
|
||||
|
||||
Does not serve any purpose for the injection service.
|
||||
|
||||
When a button was pressed, the newest keycode can be obtained from this object.
|
||||
GTK has get_key for keyboard keys, but Reader also has knowledge of buttons like
|
||||
the middle-mouse button.
|
||||
"""
|
||||
|
||||
# how long to wait for the reader-service at most
|
||||
_timeout: int = 5
|
||||
|
||||
def __init__(self, message_broker: MessageBroker, groups: _Groups):
|
||||
self.groups = groups
|
||||
self.message_broker = message_broker
|
||||
|
||||
self.group: Optional[_Group] = None
|
||||
|
||||
self._recording_generator: Optional[RecordingGenerator] = None
|
||||
self._results_pipe, self._commands_pipe = self.connect()
|
||||
|
||||
self.attach_to_events()
|
||||
|
||||
self._read_timeout = GLib.timeout_add(30, self._read)
|
||||
|
||||
def ensure_reader_service_running(self):
|
||||
if ReaderService.is_running():
|
||||
return
|
||||
|
||||
logger.info("ReaderService not running anymore, restarting")
|
||||
ReaderService.pkexec_reader_service()
|
||||
|
||||
# wait until the ReaderService is up
|
||||
|
||||
# wait no more than:
|
||||
polling_period = 0.01
|
||||
# this will make the gui non-responsive for 0.4s or something. The pkexec
|
||||
# password prompt will appear, so the user understands that the lag has to
|
||||
# be connected to the authentication. I would actually prefer the frozen gui
|
||||
# over a reactive one here, because the short lag shows that stuff is going on
|
||||
# behind the scenes.
|
||||
for __ in range(int(self._timeout / polling_period)):
|
||||
if self._results_pipe.poll():
|
||||
logger.info("ReaderService started")
|
||||
break
|
||||
|
||||
time.sleep(polling_period)
|
||||
else:
|
||||
msg = "The reader-service did not start"
|
||||
logger.error(msg)
|
||||
self.message_broker.publish(StatusData(CTX_ERROR, _(msg)))
|
||||
|
||||
def _send_command(self, command: str):
|
||||
"""Send a command to the ReaderService."""
|
||||
if command not in [CMD_TERMINATE, CMD_STOP_READING]:
|
||||
self.ensure_reader_service_running()
|
||||
|
||||
logger.debug('Sending "%s" to ReaderService', command)
|
||||
self._commands_pipe.send(command)
|
||||
|
||||
def connect(self):
|
||||
"""Connect to the reader-service."""
|
||||
results_pipe = Pipe(ReaderService.get_pipe_paths()[0])
|
||||
commands_pipe = Pipe(ReaderService.get_pipe_paths()[1])
|
||||
return results_pipe, commands_pipe
|
||||
|
||||
def attach_to_events(self):
|
||||
"""Connect listeners to event_reader."""
|
||||
self.message_broker.subscribe(
|
||||
MessageType.terminate,
|
||||
lambda _: self.terminate(),
|
||||
)
|
||||
|
||||
def _read(self):
|
||||
"""Read the messages from the reader-service and handle them."""
|
||||
while self._results_pipe.poll():
|
||||
message = self._results_pipe.recv()
|
||||
|
||||
logger.debug("received %s", message)
|
||||
|
||||
message_type = message["type"]
|
||||
message_body = message["message"]
|
||||
|
||||
if message_type == MSG_GROUPS:
|
||||
self._update_groups(message_body)
|
||||
|
||||
if message_type == MSG_EVENT:
|
||||
# update the generator
|
||||
try:
|
||||
if self._recording_generator is not None:
|
||||
self._recording_generator.send(InputEvent(**message_body))
|
||||
else:
|
||||
# the ReaderService should only send events while the gui
|
||||
# is recording, so this is unexpected.
|
||||
logger.error("Got event, but recorder is not running.")
|
||||
except StopIteration:
|
||||
# the _recording_generator returned
|
||||
logger.debug("Recorder finished.")
|
||||
self.stop_recorder()
|
||||
break
|
||||
|
||||
return True
|
||||
|
||||
def start_recorder(self) -> None:
|
||||
"""Record user input."""
|
||||
if self.group is None:
|
||||
logger.error("No group set")
|
||||
return
|
||||
|
||||
logger.debug("Starting recorder.")
|
||||
self._send_command(self.group.key)
|
||||
|
||||
self._recording_generator = self._recorder()
|
||||
next(self._recording_generator)
|
||||
|
||||
self.message_broker.signal(MessageType.recording_started)
|
||||
|
||||
def stop_recorder(self) -> None:
|
||||
"""Stop recording the input.
|
||||
|
||||
Will send recording_finished signals.
|
||||
"""
|
||||
logger.debug("Stopping recorder.")
|
||||
self._send_command(CMD_STOP_READING)
|
||||
|
||||
if self._recording_generator:
|
||||
self._recording_generator.close()
|
||||
self._recording_generator = None
|
||||
else:
|
||||
# this would be unexpected. but this is not critical enough to
|
||||
# show to the user without debug logs
|
||||
logger.debug("No recording generator existed")
|
||||
|
||||
self.message_broker.signal(MessageType.recording_finished)
|
||||
|
||||
@staticmethod
|
||||
def _input_event_to_config(event: InputEvent):
|
||||
# This used to default to event.value, which was broken for joysticks because
|
||||
# it resulted in a very low value (I tihnk 1). Which I think was because we
|
||||
# overwrote the event.value with 1 in the handlers.
|
||||
|
||||
# For joysticks the default uses DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE
|
||||
# in percent now. This value would break moues movements, because mice don't
|
||||
# have a maximum value that we could use for percent calculations.
|
||||
|
||||
# So for EV_REL we just use 1 or -1 to keep it working the same way it used to
|
||||
# work.
|
||||
analog_threshold = event.direction
|
||||
|
||||
if event.type == evdev.ecodes.EV_ABS:
|
||||
analog_threshold = event.direction * DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE
|
||||
|
||||
return {
|
||||
"type": event.type,
|
||||
"code": event.code,
|
||||
"analog_threshold": analog_threshold,
|
||||
"origin_hash": event.origin_hash,
|
||||
}
|
||||
|
||||
def _recorder(self) -> RecordingGenerator:
|
||||
"""Generator which receives InputEvents.
|
||||
|
||||
It accumulates them into EventCombinations and sends those on the
|
||||
message_broker. It will stop once all keys or inputs are released.
|
||||
"""
|
||||
active: Set = set()
|
||||
accumulator: List[InputEvent] = []
|
||||
while True:
|
||||
event: InputEvent = yield
|
||||
if event.type_and_code in BLACKLISTED_EVENTS:
|
||||
continue
|
||||
|
||||
if not event.is_pressed():
|
||||
try:
|
||||
active.remove(event.input_match_hash)
|
||||
except KeyError:
|
||||
# we haven't seen this before probably a key got released which
|
||||
# was pressed before we started recording. ignore it.
|
||||
continue
|
||||
|
||||
if not active:
|
||||
# all previously recorded events are released
|
||||
return
|
||||
continue
|
||||
|
||||
active.add(event.input_match_hash)
|
||||
accu_input_hashes = [e.input_match_hash for e in accumulator]
|
||||
if event.input_match_hash in accu_input_hashes and event not in accumulator:
|
||||
# the value has changed but the event is already in the accumulator
|
||||
# update the event
|
||||
i = accu_input_hashes.index(event.input_match_hash)
|
||||
accumulator[i] = event
|
||||
self.message_broker.publish(
|
||||
CombinationRecorded(
|
||||
InputCombination(map(self._input_event_to_config, accumulator))
|
||||
)
|
||||
)
|
||||
|
||||
if event not in accumulator:
|
||||
accumulator.append(event)
|
||||
self.message_broker.publish(
|
||||
CombinationRecorded(
|
||||
InputCombination(map(self._input_event_to_config, accumulator))
|
||||
)
|
||||
)
|
||||
|
||||
def set_group(self, group: Optional[_Group]):
|
||||
"""Set the group for which input events should be read later."""
|
||||
# TODO load the active_group from the controller instead?
|
||||
self.group = group
|
||||
|
||||
def terminate(self):
|
||||
"""Stop reading keycodes for good."""
|
||||
self._send_command(CMD_TERMINATE)
|
||||
|
||||
self.stop_recorder()
|
||||
|
||||
if self._read_timeout is not None:
|
||||
GLib.source_remove(self._read_timeout)
|
||||
self._read_timeout = None
|
||||
|
||||
while self._results_pipe.poll():
|
||||
self._results_pipe.recv()
|
||||
|
||||
def refresh_groups(self):
|
||||
"""Ask the ReaderService for new device groups."""
|
||||
self._send_command(CMD_REFRESH_GROUPS)
|
||||
|
||||
def publish_groups(self):
|
||||
"""Announce all known groups."""
|
||||
groups: Dict[str, List[str]] = {
|
||||
group.key: group.types or [] for group in self.groups.get_groups()
|
||||
}
|
||||
self.message_broker.publish(GroupsData(groups))
|
||||
|
||||
def _update_groups(self, dump: str):
|
||||
if dump != self.groups.dumps():
|
||||
self.groups.loads(dump)
|
||||
logger.debug("Received %d devices", len(self.groups.get_groups()))
|
||||
self._groups_updated = True
|
||||
|
||||
# send this even if the groups did not change, as the user expects the ui
|
||||
# to respond in some form
|
||||
self.publish_groups()
|
||||
Reference in New Issue
Block a user