mirror of
https://github.com/sezanzeb/input-remapper
synced 2024-11-12 01:10:38 +00:00
3637204bff
* Tests for the GuiEventHandler * Implement GuiEventHandler * tests for data manager * Implemented data_manager * Remove Ellipsis from type hint * workaround for old pydantic version * workaround for old pydantic version * some more tests for data_manager * Updated Data Manager * move DeviceSelection to its own class * Data Manager no longer listens for events * Moved PresetSelection to its own class * MappingListBox and SelectionLable Listen to the EventHandler * DataManager no longer creates its own data objects in the init * removed global reader object * Changed UI startup * created backend Interface * event_handler debug logs show function which emit a event * some cleanup * added target selector to components * created code editor component * adapted autocompletion & some cleanup * black * connected some buttons to the event_handler * tests for data_manager newest_preset and group * cleanup presets and test_presets * migrated confirm delete dialog * backend tests * controller tests * add python3-gi to ci * more dependencies * and more ... * Github-Actions workaround remove this commit * not so many permission denyed errors in test.yml * Fix #404 (hopefully) * revert Github-Actions workaround * More tests * event_handler allows for event supression * more tests * WIP Implement Key recording * Start and Stop Injection * context no longer stores preset * restructured the RelToBtnHandler * Simplified read_loop * Implement async iterator for ipc.pipe * multiple event actions * helper now implements mapping handlers to read inputs all with async * updated and simplified reader the helper uses the mapping handlers, so the reader now can be much simpler * Fixed race condition in tests * implemented DataBus * Fixed a UIMapping bug where the last_error would not be deleted * added a immutable variant of the UIMapping * updated data_manager to use data_bus * Uptdated tests to use the DataBus * Gui uses DataBus * removed EventHandler * Renamed controller methods * Implemented recording toggle * implemented StatusBar * Sending validation errors to status bar * sending injection status to status bar * proper preset renaming * implemented copy preset in the data manager * implemented copy_preset in controller * fixed a bug where a wron selection lable would update * no longer send invalid data over the bus, if the preset or group changes * Implement create and delete mapping * Allow for frontend specific mapping defaults * implemented autoload toggle * cleanup user_interface * removed editor * Docstings renaming and ordering of methods * more simplifications to user_interface * integrated backend into data_manager * removed active preset * transformation tests * controller tests * fix missing uinputs in gui * moved some tests and implemented basic tests for mapping handlers * docstring reformatting Co-authored-by: Tobi <proxima@sezanzeb.de> * allow for empty groups * docstring * fixed TestGroupFromHelper * some work on integration tests * test for annoying import error in tests * testing if test_user_interface works * I feel lucky * not so lucky * some more tests * fixed but where the group_key was used as folder name * Fixed a bug where state=NO_GRAB would never be read from the injector * allow to stop the recorder * working on integration tests * integration tests * fixed more integration tests * updated coveragerc * no longer attempt to record keys when injecting * event_reader cleans up not finished tasks * More integration tests * All tests pass * renamed data_bus * WIP fixing typing issues * more typing fixes * added keyboard+mouse device to tests * cleanup imports * new read loop because the evdev async read loop can not be cancelled * Added field to modify mapping name * created tests for components * even more component tests * do component tests need a screen? * apparently they do :_( * created release_input switch * Don't record relative axis when movement is slow * show delete dialog above main window * wip basic dialog to edit combination * some gui changes to the combination-editor * Simple implementation of CombinationListbox * renamed attach_to_events method and mark as private * shorter str() for UInputsData * moved logic to generate readable event string from combination to event * new mapping parameter force release timeout this helps with the helper when recording multiple relative axis at once * make it possible to rearange the event_combination * more work on the combination editor * tests for DataManager.load_event * simplyfied test_controller * more controller tests * Implement input threshold in gui * greater range for time dependent unit test * implemented a output-axis selector * data_manager now provides injector state * black * mypy * Updated confirm cancel dialog * created release timeout input * implemented transformation graph * Added sliders for gain, expo and deadzone * fix bug where the system_mapping was overridden in each injector thread * updated slider settings * removed debug statement * explicitly checking output code against None (0 is a valid code) * usage * Allow for multiple axis to be activated by same button * readme * only warn about not implemented mapping-handler don't fail to create event-pipelines * More accurate event names * Allow removal of single events from the input-combination * rename callback to notify_callback * rename event message to selected_event * made read_continuisly private * typing for autocompletion * docstrings for message_broker messages * make components methods and propreties private * gui spacings * removed eval * make some controller functions private * move status message generation from data_manager to controller * parse mapping errors in controller for more helpful messages * remove system_mapping from code editor * More component tests * more tests * mypy * make grab_devices less greedy (partial mitigation for #435) only grab one device if there are multiple which can satisfy the same mapping * accumulate more values in test * docstrings * Updated status messages * comments, docstrings, imports Co-authored-by: Tobi <proxima@sezanzeb.de>
311 lines
11 KiB
Python
311 lines
11 KiB
Python
#!/usr/bin/python3
|
|
# -*- coding: utf-8 -*-
|
|
# input-remapper - GUI for device specific keyboard mappings
|
|
# Copyright (C) 2022 sezanzeb <proxima@hip70890b.de>
|
|
#
|
|
# 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/>.
|
|
|
|
|
|
"""Process that sends stuff to the GUI.
|
|
|
|
It should be started via input-remapper-control and pkexec.
|
|
|
|
GUIs should not run as root
|
|
https://wiki.archlinux.org/index.php/Running_GUI_applications_as_root
|
|
|
|
The service shouldn't do that even though it has root rights, because that
|
|
would provide a key-logger that can be accessed by any user at all times,
|
|
whereas for the helper to start a password is needed and it stops when the ui
|
|
closes.
|
|
|
|
This uses the backend injection.event_reader and mapping_handlers to process all the
|
|
different input-events into simple on/off events and sends them to the gui.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import multiprocessing
|
|
import subprocess
|
|
import sys
|
|
from collections import defaultdict
|
|
from typing import Set, List
|
|
|
|
import evdev
|
|
from evdev.ecodes import EV_KEY, EV_ABS, EV_REL, REL_HWHEEL, REL_WHEEL
|
|
|
|
from inputremapper.configs.mapping import UIMapping
|
|
from inputremapper.event_combination import EventCombination
|
|
from inputremapper.groups import _Groups, _Group
|
|
from inputremapper.injection.event_reader import EventReader
|
|
from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler
|
|
from inputremapper.injection.mapping_handlers.mapping_handler import MappingHandler
|
|
from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler
|
|
from inputremapper.input_event import InputEvent, EventActions
|
|
from inputremapper.ipc.pipe import Pipe
|
|
from inputremapper.logger import logger
|
|
from inputremapper.user import USER
|
|
|
|
# received by the helper
|
|
CMD_TERMINATE = "terminate"
|
|
CMD_REFRESH_GROUPS = "refresh_groups"
|
|
|
|
# sent by the helper to the reader
|
|
MSG_GROUPS = "groups"
|
|
MSG_EVENT = "event"
|
|
|
|
|
|
def is_helper_running():
|
|
"""Check if the helper is running."""
|
|
try:
|
|
subprocess.check_output(["pgrep", "-f", "input-remapper-helper"])
|
|
except subprocess.CalledProcessError:
|
|
return False
|
|
return True
|
|
|
|
|
|
class RootHelper:
|
|
"""Client that runs as root and works for the GUI.
|
|
|
|
Sends device information and keycodes to the GUIs socket.
|
|
|
|
Commands are either numbers for generic commands,
|
|
or strings to start listening on a specific device.
|
|
"""
|
|
|
|
# the speed threshold at which relative axis are considered moving
|
|
# and will be sent as "pressed" to the frontend.
|
|
# We want to allow some mouse movement before we record it as an input
|
|
rel_speed = defaultdict(lambda: 3)
|
|
# wheel events usually don't produce values higher than 1
|
|
rel_speed[REL_WHEEL] = 1
|
|
rel_speed[REL_HWHEEL] = 1
|
|
|
|
def __init__(self, groups: _Groups):
|
|
"""Construct the helper and initialize its sockets."""
|
|
self.groups = groups
|
|
self._results = Pipe(f"/tmp/input-remapper-{USER}/results")
|
|
self._commands = Pipe(f"/tmp/input-remapper-{USER}/commands")
|
|
self._pipe = multiprocessing.Pipe()
|
|
|
|
self._tasks: Set[asyncio.Task] = set()
|
|
self._stop_event = asyncio.Event()
|
|
|
|
def run(self):
|
|
"""Start doing stuff. Blocks."""
|
|
# the reader will check for new commands later, once it is running
|
|
# it keeps running for one device or another.
|
|
loop = asyncio.get_event_loop()
|
|
logger.debug("Discovering initial groups")
|
|
self.groups.refresh()
|
|
self._send_groups()
|
|
logger.debug("Waiting commands")
|
|
loop.run_until_complete(self._read_commands())
|
|
logger.debug("Helper terminates")
|
|
sys.exit(0)
|
|
|
|
def _send_groups(self):
|
|
"""Send the groups to the gui."""
|
|
logger.debug("Sending groups")
|
|
self._results.send({"type": MSG_GROUPS, "message": self.groups.dumps()})
|
|
|
|
async def _read_commands(self):
|
|
"""Handle all unread commands.
|
|
this will run until it receives CMD_TERMINATE
|
|
"""
|
|
async for cmd in self._commands:
|
|
logger.debug('Received command "%s"', cmd)
|
|
|
|
if cmd == CMD_TERMINATE:
|
|
await self._stop_reading()
|
|
return
|
|
|
|
if cmd == CMD_REFRESH_GROUPS:
|
|
self.groups.refresh()
|
|
self._send_groups()
|
|
continue
|
|
|
|
group = self.groups.find(key=cmd)
|
|
if group is None:
|
|
# this will block for a bit maybe we want to do this async?
|
|
self.groups.refresh()
|
|
group = self.groups.find(key=cmd)
|
|
|
|
if group is not None:
|
|
await self._stop_reading()
|
|
self._start_reading(group)
|
|
continue
|
|
|
|
logger.error('Received unknown command "%s"', cmd)
|
|
|
|
def _start_reading(self, group: _Group):
|
|
"""find all devices of that group, filter interesting ones and send the events
|
|
to the gui"""
|
|
sources = []
|
|
for path in group.paths:
|
|
try:
|
|
device = evdev.InputDevice(path)
|
|
except (FileNotFoundError, OSError):
|
|
logger.error('Could not find "%s"', path)
|
|
return None
|
|
|
|
capabilities = device.capabilities(absinfo=False)
|
|
if (
|
|
EV_KEY in capabilities
|
|
or EV_ABS in capabilities
|
|
or EV_REL in capabilities
|
|
):
|
|
sources.append(device)
|
|
|
|
context = self._create_event_pipeline(sources)
|
|
# create the event reader and start it
|
|
for device in sources:
|
|
reader = EventReader(context, device, ForwardDummy, self._stop_event)
|
|
self._tasks.add(asyncio.create_task(reader.run()))
|
|
|
|
async def _stop_reading(self):
|
|
"""stop the running event_reader"""
|
|
self._stop_event.set()
|
|
if self._tasks:
|
|
await asyncio.gather(*self._tasks)
|
|
self._tasks = set()
|
|
self._stop_event.clear()
|
|
|
|
def _create_event_pipeline(self, sources: List[evdev.InputDevice]) -> ContextDummy:
|
|
"""create a custom event pipeline for each event code in the
|
|
device capabilities.
|
|
Instead of sending the events to a uinput they will be sent to the frontend"""
|
|
context = ContextDummy()
|
|
# create a context for each source
|
|
for device in sources:
|
|
capabilities = device.capabilities(absinfo=False)
|
|
|
|
for ev_code in capabilities.get(EV_KEY) or ():
|
|
context.notify_callbacks[(EV_KEY, ev_code)].append(
|
|
ForwardToUIHandler(self._results).notify
|
|
)
|
|
|
|
for ev_code in capabilities.get(EV_ABS) or ():
|
|
# positive direction
|
|
mapping = UIMapping(
|
|
event_combination=EventCombination((EV_ABS, ev_code, 30)),
|
|
target_uinput="keyboard",
|
|
)
|
|
handler: MappingHandler = AbsToBtnHandler(
|
|
EventCombination((EV_ABS, ev_code, 30)), mapping
|
|
)
|
|
handler.set_sub_handler(ForwardToUIHandler(self._results))
|
|
context.notify_callbacks[(EV_ABS, ev_code)].append(handler.notify)
|
|
|
|
# negative direction
|
|
mapping = UIMapping(
|
|
event_combination=EventCombination((EV_ABS, ev_code, -30)),
|
|
target_uinput="keyboard",
|
|
)
|
|
handler = AbsToBtnHandler(
|
|
EventCombination((EV_ABS, ev_code, -30)), mapping
|
|
)
|
|
handler.set_sub_handler(ForwardToUIHandler(self._results))
|
|
context.notify_callbacks[(EV_ABS, ev_code)].append(handler.notify)
|
|
|
|
for ev_code in capabilities.get(EV_REL) or ():
|
|
# positive direction
|
|
mapping = UIMapping(
|
|
event_combination=EventCombination(
|
|
(EV_REL, ev_code, self.rel_speed[ev_code])
|
|
),
|
|
target_uinput="keyboard",
|
|
release_timeout=0.3,
|
|
force_release_timeout=True,
|
|
)
|
|
handler = RelToBtnHandler(
|
|
EventCombination((EV_REL, ev_code, self.rel_speed[ev_code])),
|
|
mapping,
|
|
)
|
|
handler.set_sub_handler(ForwardToUIHandler(self._results))
|
|
context.notify_callbacks[(EV_REL, ev_code)].append(handler.notify)
|
|
|
|
# negative direction
|
|
mapping = UIMapping(
|
|
event_combination=EventCombination(
|
|
(EV_REL, ev_code, -self.rel_speed[ev_code])
|
|
),
|
|
target_uinput="keyboard",
|
|
release_timeout=0.3,
|
|
force_release_timeout=True,
|
|
)
|
|
handler = RelToBtnHandler(
|
|
EventCombination((EV_REL, ev_code, -self.rel_speed[ev_code])),
|
|
mapping,
|
|
)
|
|
handler.set_sub_handler(ForwardToUIHandler(self._results))
|
|
context.notify_callbacks[(EV_REL, ev_code)].append(handler.notify)
|
|
|
|
return context
|
|
|
|
|
|
class ContextDummy:
|
|
def __init__(self):
|
|
self.listeners = set()
|
|
self.notify_callbacks = defaultdict(list)
|
|
|
|
def reset(self):
|
|
pass
|
|
|
|
|
|
class ForwardDummy:
|
|
@staticmethod
|
|
def write(*_):
|
|
pass
|
|
|
|
|
|
class ForwardToUIHandler:
|
|
"""implements the InputEventHandler protocol. Sends all events into the pipe"""
|
|
|
|
def __init__(self, pipe: Pipe):
|
|
self.pipe = pipe
|
|
self._last_event = InputEvent.from_tuple((99, 99, 99))
|
|
|
|
def notify(
|
|
self,
|
|
event: InputEvent,
|
|
source: evdev.InputDevice,
|
|
forward: evdev.UInput,
|
|
supress: bool = False,
|
|
) -> bool:
|
|
"""filter duplicates and send into the pipe"""
|
|
if event != self._last_event:
|
|
self._last_event = event
|
|
if EventActions.negative_trigger in event.actions:
|
|
event = event.modify(value=-1)
|
|
|
|
logger.debug_key(event, f"to frontend:")
|
|
self.pipe.send(
|
|
{
|
|
"type": MSG_EVENT,
|
|
"message": (
|
|
event.sec,
|
|
event.usec,
|
|
event.type,
|
|
event.code,
|
|
event.value,
|
|
),
|
|
}
|
|
)
|
|
return True
|
|
|
|
def reset(self):
|
|
pass
|