#65 device icons

This commit is contained in:
sezanzeb 2021-03-27 13:21:35 +01:00
parent 16c766257d
commit 0abbbf45e1
9 changed files with 242 additions and 99 deletions

View File

@ -104,7 +104,7 @@
</packing>
</child>
<child>
<object class="GtkComboBoxText" id="device_selection">
<object class="GtkComboBox" id="device_selection">
<property name="visible">True</property>
<property name="can-focus">False</property>
<signal name="changed" handler="on_select_device" swapped="no"/>
@ -213,8 +213,7 @@ To give your keys back their original mapping.</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Presets need to be saved before they can be applied.
Don't hold down any keys while the injection starts.</property>
<property name="tooltip-text" translatable="yes">Don't hold down any keys while the injection starts.</property>
<property name="image">check-icon</property>
<property name="relief">none</property>
<property name="always-show-image">True</property>
@ -255,6 +254,7 @@ Don't hold down any keys while the injection starts.</property>
<property name="relief">none</property>
<property name="always-show-image">True</property>
<signal name="clicked" handler="on_create_preset_clicked" swapped="no"/>
<accelerator key="n" signal="activate" modifiers="GDK_CONTROL_MASK"/>
</object>
<packing>
<property name="expand">True</property>

View File

@ -28,7 +28,8 @@ import time
import asyncio
import evdev
from evdev.ecodes import EV_KEY, EV_ABS, KEY_CAMERA, EV_REL, BTN_STYLUS, BTN_A
from evdev.ecodes import EV_KEY, EV_ABS, KEY_CAMERA, EV_REL, BTN_STYLUS, \
BTN_A, ABS_MT_POSITION_X, REL_X, KEY_A, BTN_LEFT
from keymapper.logger import logger
@ -44,6 +45,21 @@ TABLET_KEYS = [
]
GAMEPAD = 'gamepad'
KEYBOARD = 'keyboard'
MOUSE = 'mouse'
TOUCHPAD = 'touchpad'
GRAPHICS_TABLET = 'graphics-tablet'
CAMERA = 'camera'
UNKNOWN = 'unknown'
# sort types that most devices would fall in easily to the right
PRIORITIES = [
GRAPHICS_TABLET, TOUCHPAD, MOUSE, GAMEPAD, KEYBOARD, CAMERA, UNKNOWN
]
if not hasattr(evdev.InputDevice, 'path'):
# for evdev < 1.0.0 patch the path property
@property
@ -53,32 +69,15 @@ if not hasattr(evdev.InputDevice, 'path'):
evdev.InputDevice.path = path
def is_gamepad(device):
"""Check if joystick movements are available for mapping.
Parameters
----------
device : InputDevice
"""
# if false positives appear, prefer requiring more gamepad specific
# capabilities over searching for capabilities that gamepads usually
# don't have.
capabilities = device.capabilities(absinfo=False)
# some tests that should easily match most devices of those
# non-gamepad types:
if EV_REL in capabilities:
# A mouse
return False
if BTN_STYLUS in capabilities.get(EV_KEY, []):
# a graphics tablet
def _is_gamepad(capabilities):
"""Check if joystick movements are available for mapping."""
if len(capabilities.get(EV_REL, [])) > 0:
return False
# buttons
if BTN_A not in capabilities.get(EV_KEY, []):
return False
# joystick
# joysticks
abs_capabilities = capabilities.get(EV_ABS, [])
if evdev.ecodes.ABS_X not in abs_capabilities:
return False
@ -88,6 +87,78 @@ def is_gamepad(device):
return True
def _is_mouse(capabilities):
"""Check if the capabilities represent those of a mouse."""
if not REL_X in capabilities.get(EV_REL, []):
return False
if not BTN_LEFT in capabilities.get(EV_KEY, []):
return False
return True
def _is_graphics_tablet(capabilities):
"""Check if the capabilities represent those of a graphics tablet."""
if BTN_STYLUS in capabilities.get(EV_KEY, []):
return True
return False
def _is_touchpad(capabilities):
"""Check if the capabilities represent those of a touchpad."""
if ABS_MT_POSITION_X in capabilities.get(EV_ABS, []):
return True
return False
def _is_keyboard(capabilities):
"""Check if the capabilities represent those of a keyboard."""
if KEY_A in capabilities.get(EV_KEY, []):
return True
return False
def _is_camera(capabilities):
"""Check if the capabilities represent those of a camera."""
key_capa = capabilities.get(EV_KEY)
return key_capa and len(key_capa) == 1 and key_capa[0] == KEY_CAMERA
def classify(device):
"""Figure out what kind of device this is.
Use this instead of functions like _is_keyboard to avoid getting false
positives.
"""
# TODO test
capabilities = device.capabilities(absinfo=False)
if _is_graphics_tablet(capabilities):
# check this before is_gamepad to avoid classifying abs_x
# as joysticks when they are actually stylus positions
return GRAPHICS_TABLET
if _is_touchpad(capabilities):
return TOUCHPAD
if _is_mouse(capabilities):
return MOUSE
if _is_gamepad(capabilities):
return GAMEPAD
if _is_camera(capabilities):
return CAMERA
if _is_keyboard(capabilities):
# very low in the chain to avoid classifying most devices
# as keyboard, because there are many with ev_key capabilities
return KEYBOARD
return UNKNOWN
class _GetDevices(threading.Thread):
"""Process to get the devices that can be worked with.
@ -123,21 +194,20 @@ class _GetDevices(threading.Thread):
if device.name == 'Power Button':
continue
gamepad = is_gamepad(device)
device_type = classify(device)
if device_type == CAMERA:
continue
# https://www.kernel.org/doc/html/latest/input/event-codes.html
capabilities = device.capabilities(absinfo=False)
key_capa = capabilities.get(EV_KEY)
if key_capa is None and not gamepad:
if key_capa is None and device_type != GAMEPAD:
# skip devices that don't provide buttons that can be mapped
continue
if key_capa and len(key_capa) == 1 and key_capa[0] == KEY_CAMERA:
# skip cameras
continue
name = device.name
path = device.path
@ -152,23 +222,28 @@ class _GetDevices(threading.Thread):
grouped[info] = []
logger.spam(
'Found "%s", "%s", "%s" %s',
info, path, name, '(gamepad)' if gamepad else ''
'Found "%s", "%s", "%s", type: %s',
info, path, name, device_type
)
grouped[info].append((name, path, gamepad))
grouped[info].append((name, path, device_type))
# now write down all the paths of that group
result = {}
for group in grouped.values():
names = [entry[0] for entry in group]
devs = [entry[1] for entry in group]
gamepad = True in [entry[2] for entry in group]
# find the most specific type from all devices per group.
# e.g. a device with mouse and keyboard subdevices is a mouse.
types = sorted([entry[2] for entry in group], key=PRIORITIES.index)
device_type = types[0]
shortest_name = sorted(names, key=len)[0]
result[shortest_name] = {
'paths': devs,
'devices': names,
'gamepad': gamepad
'type': device_type
}
self.pipe.send(result)

View File

@ -131,7 +131,7 @@ class Reader:
if event is None:
continue
gamepad = get_devices()[self.device_name]['gamepad']
gamepad = get_devices()[self.device_name]['type'] == 'gamepad'
if not utils.should_map_as_btn(event, custom_mapping, gamepad):
continue

View File

@ -24,6 +24,7 @@
import math
import os
import sys
from gi.repository import Gtk, Gdk, GLib
@ -34,7 +35,8 @@ from keymapper.presets import get_presets, find_newest_preset, \
delete_preset, rename_preset, get_available_preset_name
from keymapper.logger import logger, COMMIT_HASH, version, evdev_version, \
is_debug
from keymapper.getdevices import get_devices
from keymapper.getdevices import get_devices, GAMEPAD, KEYBOARD, UNKNOWN, \
GRAPHICS_TABLET, TOUCHPAD, MOUSE
from keymapper.gui.row import Row, to_string
from keymapper.gui.reader import reader
from keymapper.gui.helper import is_helper_running
@ -132,6 +134,20 @@ class Window:
builder.connect_signals(self)
self.builder = builder
# set up the device selection
# https://python-gtk-3-tutorial.readthedocs.io/en/latest/treeview.html#the-view
combobox = self.get('device_selection')
self.device_store = Gtk.ListStore(str, str)
combobox.set_model(self.device_store)
renderer_icon = Gtk.CellRendererPixbuf()
renderer_text = Gtk.CellRendererText()
renderer_text.set_padding(5, 0)
combobox.set_id_column(1)
combobox.pack_start(renderer_icon, False)
combobox.pack_start(renderer_text, False)
combobox.add_attribute(renderer_icon, 'icon-name', 0)
combobox.add_attribute(renderer_text, 'text', 1)
self.confirm_delete = builder.get_object('confirm-delete')
self.about = builder.get_object('about-dialog')
self.about.connect('delete-event', on_close_about)
@ -184,7 +200,11 @@ class Window:
cmd = f'pkexec key-mapper-control --command helper {debug}'
logger.debug('Running `%s`', cmd)
os.system(cmd)
exit_code = os.system(cmd)
if exit_code != 0:
logger.error('Failed to pkexec the helper, code %d', exit_code)
sys.exit()
def show_confirm_delete(self):
"""Blocks until the user decided about an action."""
@ -222,7 +242,7 @@ class Window:
def initialize_gamepad_config(self):
"""Set slider and dropdown values when a gamepad is selected."""
devices = get_devices()
if devices[self.selected_device]['gamepad']:
if devices[self.selected_device]['type'] == 'gamepad':
self.get('gamepad_separator').show()
self.get('gamepad_config').show()
else:
@ -311,9 +331,21 @@ class Window:
device_selection = self.get('device_selection')
with HandlerDisabled(device_selection, self.on_select_device):
device_selection.remove_all()
self.device_store.clear()
for device in devices:
device_selection.append(device, device)
icons = {
GAMEPAD: 'input-gaming',
MOUSE: 'input-mouse',
KEYBOARD: 'input-keyboard',
GRAPHICS_TABLET: 'input-tablet',
TOUCHPAD: 'input-touchpad',
UNKNOWN: None,
}
self.device_store.append([
icons[devices[device]['type']],
device
])
self.select_newest_preset()
@ -588,7 +620,7 @@ class Window:
# preset. Prevent another unsaved-changes dialog to pop up
custom_mapping.changed = False
device = dropdown.get_active_text()
device = dropdown.get_active_id()
if device is None:
return

View File

@ -30,7 +30,7 @@ import evdev
from evdev.ecodes import EV_KEY, EV_REL
from keymapper.logger import logger
from keymapper.getdevices import get_devices, is_gamepad
from keymapper.getdevices import get_devices, classify, GAMEPAD
from keymapper import utils
from keymapper.mapping import DISABLE_CODE
from keymapper.injection.keycode_mapper import KeycodeMapper
@ -159,7 +159,7 @@ class Injector(multiprocessing.Process):
needed = True
break
gamepad = is_gamepad(device)
gamepad = classify(device) == GAMEPAD
if gamepad and self.context.maps_joystick():
logger.debug('Grabbing "%s" because of maps_joystick', path)
@ -341,7 +341,7 @@ class Injector(multiprocessing.Process):
self.context.uinput = evdev.UInput(
name=self.get_udef_name(self.device, 'mapped'),
phys=DEV_NAME,
events=self._construct_capabilities(group['gamepad'])
events=self._construct_capabilities(group['type'] == 'gamepad')
)
# Watch over each one of the potentially multiple devices per hardware
@ -355,7 +355,7 @@ class Injector(multiprocessing.Process):
# certain capabilities can have side effects apparently. with an
# EV_ABS capability, EV_REL won't move the mouse pointer anymore.
# so don't merge all InputDevices into one UInput device.
gamepad = is_gamepad(source)
gamepad = classify(source) == GAMEPAD
forward_to = evdev.UInput(
name=self.get_udef_name(source.name, 'forwarded'),
phys=DEV_NAME,
@ -423,7 +423,7 @@ class Injector(multiprocessing.Process):
source.path, source.fd
)
gamepad = is_gamepad(source)
gamepad = classify(source) == GAMEPAD
keycode_handler = KeycodeMapper(self.context, source, forward_to)

View File

@ -32,6 +32,7 @@ import subprocess
import multiprocessing
import asyncio
import psutil
from pickle import UnpicklingError
import evdev
import gi
@ -121,20 +122,29 @@ def read_write_history_pipe():
phys_1 = 'usb-0000:03:00.0-1/input2'
info_1 = evdev.device.DeviceInfo(1, 1, 1, 1)
keyboard_keys = sorted(evdev.ecodes.keys.keys())[:255]
fixtures = {
# device 1
'/dev/input/event11': {
'capabilities': {evdev.ecodes.EV_KEY: [], evdev.ecodes.EV_REL: [
'capabilities': {
evdev.ecodes.EV_KEY: [
evdev.ecodes.BTN_LEFT
],
evdev.ecodes.EV_REL: [
evdev.ecodes.REL_X,
evdev.ecodes.REL_Y,
evdev.ecodes.REL_WHEEL,
evdev.ecodes.REL_HWHEEL
]},
]
},
'phys': f'{phys_1}/input2',
'info': info_1,
'name': 'device 1 foo',
'group': 'device 1'
},
'/dev/input/event10': {
'capabilities': {evdev.ecodes.EV_KEY: list(evdev.ecodes.keys.keys())},
'capabilities': {evdev.ecodes.EV_KEY: keyboard_keys},
'phys': f'{phys_1}/input3',
'info': info_1,
'name': 'device 1',
@ -157,7 +167,7 @@ fixtures = {
# device 2
'/dev/input/event20': {
'capabilities': {evdev.ecodes.EV_KEY: list(evdev.ecodes.keys.keys())},
'capabilities': {evdev.ecodes.EV_KEY: keyboard_keys},
'phys': 'usb-0000:03:00.0-2/input1',
'info': evdev.device.DeviceInfo(2, 1, 2, 1),
'name': 'device 2'
@ -193,7 +203,7 @@ fixtures = {
# key-mapper devices are not displayed in the ui, some instance
# of key-mapper started injecting apparently.
'/dev/input/event40': {
'capabilities': {evdev.ecodes.EV_KEY: list(evdev.ecodes.keys.keys())},
'capabilities': {evdev.ecodes.EV_KEY: keyboard_keys},
'phys': 'key-mapper/input1',
'info': evdev.device.DeviceInfo(5, 1, 5, 1),
'name': 'key-mapper device 2'
@ -350,7 +360,13 @@ class InputDevice:
return None
time.sleep(EVENT_READ_TIMEOUT)
try:
event = pending_events[self.group][1].recv()
except UnpicklingError as error:
# failed in tests sometimes
print(error)
return None
self.log(event, 'read_one')
return event

View File

@ -24,8 +24,9 @@ from unittest import mock
import evdev
from keymapper.getdevices import _GetDevices, get_devices, is_gamepad, \
refresh_devices
from keymapper.getdevices import _GetDevices, get_devices, classify, \
refresh_devices, GAMEPAD, MOUSE, UNKNOWN, GRAPHICS_TABLET, TOUCHPAD, \
KEYBOARD
from tests.test import cleanup, fixtures
@ -56,22 +57,22 @@ class TestGetDevices(unittest.TestCase):
'device 1',
'device 1'
],
'gamepad': False
'type': MOUSE
},
'device 2': {
'paths': ['/dev/input/event20'],
'devices': ['device 2'],
'gamepad': False
'type': KEYBOARD
},
'gamepad': {
'paths': ['/dev/input/event30'],
'devices': ['gamepad'],
'gamepad': True
'type': GAMEPAD
},
'key-mapper device 2': {
'paths': ['/dev/input/event40'],
'devices': ['key-mapper device 2'],
'gamepad': False
'type': KEYBOARD
},
})
self.assertDictEqual(pipe.devices, get_devices(include_keymapper=True))
@ -89,17 +90,17 @@ class TestGetDevices(unittest.TestCase):
'device 1',
'device 1'
],
'gamepad': False
'type': MOUSE
},
'device 2': {
'paths': ['/dev/input/event20'],
'devices': ['device 2'],
'gamepad': False
'type': KEYBOARD
},
'gamepad': {
'paths': ['/dev/input/event30'],
'devices': ['gamepad'],
'gamepad': True
'type': GAMEPAD
},
})
@ -144,7 +145,7 @@ class TestGetDevices(unittest.TestCase):
self.assertIn('gamepad', get_devices())
self.assertNotIn('qux', get_devices())
def test_is_gamepad(self):
def test_classify(self):
# properly detects if the device is a gamepad
EV_ABS = evdev.ecodes.EV_ABS
EV_KEY = evdev.ecodes.EV_KEY
@ -158,38 +159,58 @@ class TestGetDevices(unittest.TestCase):
assert not absinfo
return self.c
"""positive tests"""
"""gamepads"""
self.assertTrue(is_gamepad(FakeDevice({
self.assertEqual(classify(FakeDevice({
EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y],
EV_KEY: [evdev.ecodes.BTN_A]
})))
})), GAMEPAD)
"""negative tests"""
"""mice"""
self.assertFalse(is_gamepad(FakeDevice({
self.assertEqual(classify(FakeDevice({
EV_REL: [evdev.ecodes.REL_X, evdev.ecodes.REL_Y],
EV_KEY: [evdev.ecodes.BTN_LEFT]
})), MOUSE)
"""keyboard"""
self.assertEqual(classify(FakeDevice({
EV_KEY: [evdev.ecodes.KEY_A]
})), KEYBOARD)
"""touchpads"""
self.assertEqual(classify(FakeDevice({
EV_KEY: [evdev.ecodes.KEY_A],
EV_ABS: [evdev.ecodes.ABS_MT_POSITION_X]
})), TOUCHPAD)
"""weird combos"""
self.assertEqual(classify(FakeDevice({
EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y],
EV_KEY: [evdev.ecodes.BTN_A],
EV_REL: [evdev.ecodes.REL_X]
})))
})), UNKNOWN)
self.assertFalse(is_gamepad(FakeDevice({
self.assertEqual(classify(FakeDevice({
EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y],
EV_KEY: [evdev.ecodes.KEY_1]
})))
})), UNKNOWN)
self.assertFalse(is_gamepad(FakeDevice({
self.assertEqual(classify(FakeDevice({
EV_ABS: [evdev.ecodes.ABS_X],
EV_KEY: [evdev.ecodes.BTN_A]
})))
})), UNKNOWN)
self.assertFalse(is_gamepad(FakeDevice({
self.assertEqual(classify(FakeDevice({
EV_KEY: [evdev.ecodes.BTN_A]
})))
})), UNKNOWN)
self.assertFalse(is_gamepad(FakeDevice({
self.assertEqual(classify(FakeDevice({
EV_ABS: [evdev.ecodes.ABS_X]
})))
})), UNKNOWN)
if __name__ == "__main__":

View File

@ -39,11 +39,12 @@ from keymapper.config import config, NONE, MOUSE, WHEEL, BUTTONS
from keymapper.key import Key
from keymapper.injection.macros import parse
from keymapper.injection.context import Context
from keymapper.getdevices import get_devices, is_gamepad
from keymapper.getdevices import get_devices, classify, GAMEPAD
from tests.test import new_event, push_events, fixtures, \
EVENT_READ_TIMEOUT, uinput_write_history_pipe, \
MAX_ABS, quick_cleanup, read_write_history_pipe, InputDevice, uinputs
MAX_ABS, quick_cleanup, read_write_history_pipe, InputDevice, uinputs, \
keyboard_keys
class TestInjector(unittest.TestCase):
@ -84,7 +85,7 @@ class TestInjector(unittest.TestCase):
# _grab_device
self.injector.context = Context(custom_mapping)
device = self.injector._grab_device(path)
gamepad = is_gamepad(device)
gamepad = classify(device) == GAMEPAD
self.assertFalse(gamepad)
self.assertEqual(self.failed, 2)
# success on the third try
@ -134,7 +135,7 @@ class TestInjector(unittest.TestCase):
path = '/dev/input/event30'
device = self.injector._grab_device(path)
gamepad = is_gamepad(device)
gamepad = classify(device) == GAMEPAD
self.assertIsNotNone(device)
self.assertTrue(gamepad)
@ -165,7 +166,7 @@ class TestInjector(unittest.TestCase):
custom_mapping.change(Key(EV_KEY, BTN_A, 1), 'a')
device = self.injector._grab_device(path)
self.assertIsNotNone(device)
gamepad = is_gamepad(device)
gamepad = classify(device) == GAMEPAD
self.assertTrue(gamepad)
capabilities = self.injector._construct_capabilities(gamepad)
self.assertNotIn(EV_ABS, capabilities)
@ -183,7 +184,7 @@ class TestInjector(unittest.TestCase):
# the right joystick maps as mouse, so it is grabbed
# even with an empty mapping
self.assertIsNotNone(device)
gamepad = is_gamepad(device)
gamepad = classify(device) == GAMEPAD
self.assertTrue(gamepad)
capabilities = self.injector._construct_capabilities(gamepad)
self.assertNotIn(EV_ABS, capabilities)
@ -191,7 +192,7 @@ class TestInjector(unittest.TestCase):
custom_mapping.change(Key(EV_KEY, BTN_A, 1), 'a')
device = self.injector._grab_device(path)
gamepad = is_gamepad(device)
gamepad = classify(device) == GAMEPAD
self.assertIsNotNone(device)
self.assertTrue(gamepad)
capabilities = self.injector._construct_capabilities(gamepad)
@ -230,7 +231,7 @@ class TestInjector(unittest.TestCase):
fixtures[path]['capabilities'][EV_KEY].append(BTN_LEFT)
fixtures[path]['capabilities'][EV_KEY].append(KEY_A)
device = self.injector._grab_device(path)
gamepad = is_gamepad(device)
gamepad = classify(device) == GAMEPAD
capabilities = self.injector._construct_capabilities(gamepad)
self.assertIn(EV_KEY, capabilities)
self.assertIn(evdev.ecodes.BTN_MOUSE, capabilities[EV_KEY])
@ -240,7 +241,7 @@ class TestInjector(unittest.TestCase):
path = '/dev/input/event30'
device = self.injector._grab_device(path)
gamepad = is_gamepad(device)
gamepad = classify(device) == GAMEPAD
self.assertIn(EV_KEY, device.capabilities())
self.assertNotIn(evdev.ecodes.BTN_MOUSE, device.capabilities()[EV_KEY])
capabilities = self.injector._construct_capabilities(gamepad)
@ -259,17 +260,14 @@ class TestInjector(unittest.TestCase):
self.assertEqual(self.failed, 0)
def test_skip_unknown_device(self):
custom_mapping.change(Key(EV_KEY, 10, 1), 'a')
# skips a device because its capabilities are not used in the mapping
self.injector = Injector('device 1', custom_mapping)
self.injector.context = Context(custom_mapping)
path = '/dev/input/event11'
device = self.injector._grab_device(path)
# make sure the test uses a fixture without interesting capabilities
capabilities = evdev.InputDevice(path).capabilities()
self.assertEqual(len(capabilities.get(EV_KEY, [])), 0)
self.assertEqual(len(capabilities.get(EV_ABS, [])), 0)
# skips the device alltogether, so no grab attempts fail
self.assertEqual(self.failed, 0)
self.assertIsNone(device)
@ -516,8 +514,8 @@ class TestInjector(unittest.TestCase):
self.assertIn(EV_REL, forwarded_foo.capabilities())
self.assertIn(EV_KEY, forwarded.capabilities())
self.assertEqual(
len(forwarded.capabilities()[EV_KEY]),
len(evdev.ecodes.keys)
sorted(forwarded.capabilities()[EV_KEY]),
keyboard_keys
)
def test_injector(self):

View File

@ -147,8 +147,9 @@ class TestGetDevicesFromHelper(unittest.TestCase):
# the gui an empty dict, because it doesn't know any devices
# without the help of the privileged helper
set_devices({})
else:
cls.original_os_system(cmd)
return 0
return cls.original_os_system(cmd)
os.system = os_system
@ -1342,7 +1343,7 @@ class TestIntegration(unittest.TestCase):
self.assertEqual(self.window.selected_preset, 'preset 1')
# add a device that doesn't exist to the dropdown
device_selection.insert(0, 'foo', 'foo')
self.window.device_store.insert(0, [None, 'foo'])
# now the newest preset should be selected and the non-existing
# device removed