input-remapper/inputremapper/gui/helper.py
jonasBoss 3637204bff
Frontend Refactor (#375)
* 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>
2022-07-23 10:53:41 +02:00

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