From 02efe373891d83064a821e19d1f8ee54367ce402 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Mon, 2 Nov 2020 01:25:43 +0100 Subject: [PATCH] creating the symbols file, symlinking, more tests --- bin/key-mapper-gtk | 32 ++++++++- keymapper/X.py | 99 +++++++++++++++++++++------- tests/fakes.py => keymapper/paths.py | 29 +++----- keymapper/presets.py | 63 +++++++++++++----- tests/test.py | 23 +++++++ tests/testcases/config.py | 2 - tests/testcases/presets.py | 75 +++++++++++++++++++++ 7 files changed, 258 insertions(+), 65 deletions(-) rename tests/fakes.py => keymapper/paths.py (56%) create mode 100644 tests/testcases/presets.py diff --git a/bin/key-mapper-gtk b/bin/key-mapper-gtk index 7272705a..ced3ac18 100755 --- a/bin/key-mapper-gtk +++ b/bin/key-mapper-gtk @@ -32,7 +32,7 @@ gi.require_version('GLib', '2.0') from gi.repository import Gtk from keymapper.data import get_data_path -from keymapper.X import find_devices +from keymapper.X import find_devices, generate_setxkbmap_config from keymapper.presets import get_presets, get_mappings, \ find_newest_preset, create_preset from keymapper.logger import logger, update_verbosity, log_info @@ -82,6 +82,8 @@ class Window: def __init__(self): self.rows = 0 self.selected_device = None + self.selected_preset = None + self.mappings = [] gladefile = os.path.join(get_data_path(), 'key-mapper.glade') builder = Gtk.Builder() @@ -133,10 +135,14 @@ class Window: if isinstance(device, Gtk.ComboBoxText): device = device.get_active_text() + self.selected_device = device + self.selected_preset = None + self.mappings = [] + presets = get_presets(device) if len(presets) == 0: create_preset(device) - self.selected_device = device + self.populate_presets() def on_create_preset_clicked(self, button): @@ -144,12 +150,16 @@ class Window: new_preset = create_preset(self.selected_device) self.get('preset_selection').append(new_preset, new_preset) self.get('preset_selection').set_active_id(new_preset) + self.update_config() def on_select_preset(self, preset): """Show the mappings of the preset""" if isinstance(preset, Gtk.ComboBoxText): preset = preset.get_active_text() + self.selected_preset = preset + self.mappings = [] + # prepare one empty input to add stuff, and to get the grid to # the correct column width, otherwise it may jump if the user adds # the first row. @@ -182,6 +192,9 @@ class Window: ---------- mapping : SingleKeyMapping """ + # TODO modify self.mappings + self.update_config() + # shrink the window down as much as possible, otherwise it # will increase with each added mapping but won't go back when they # are removed. @@ -194,6 +207,21 @@ class Window: # add back an empty row self.on_add_key_clicked() + def update_config(self): + if self.selected_device is None or self.selected_preset is None: + return + + logger.info( + 'Updating configs for "%s", "%s"', + self.selected_device, + self.selected_preset + ) + generate_setxkbmap_config( + self.selected_device, + self.selected_preset, + self.mappings + ) + if __name__ == '__main__': parser = ArgumentParser() diff --git a/keymapper/X.py b/keymapper/X.py index 17a1a6ca..5e5e98ee 100644 --- a/keymapper/X.py +++ b/keymapper/X.py @@ -19,34 +19,81 @@ # along with key-mapper. If not, see . -"""Stuff that interacts with the X Server +"""Stuff that interacts with the X Server, be it commands or config files. + +TODO create a base class to standardize the interface if a different + display server should be supported. Resources: -https://wiki.archlinux.org/index.php/Keyboard_input -http://people.uleth.ca/~daniel.odonnell/Blog/custom-keyboard-in-linuxx11 +[1] https://wiki.archlinux.org/index.php/Keyboard_input +[2] http://people.uleth.ca/~daniel.odonnell/Blog/custom-keyboard-in-linuxx11 +[3] https://www.x.org/releases/X11R7.7/doc/xorg-docs/input/XKB-Enhancing.html """ +import os import re import subprocess +from keymapper.paths import CONFIG_PATH, SYMBOLS_PATH from keymapper.logger import logger -# mapping of key to character -# This depends on the configured keyboard layout. -# example: AC01: "a A a A ae AE ae". -key_mapping = {} +def get_keycode(device, letter): + """Get the keycode that is configured for the given letter.""" + # TODO I have no idea how to do this + # in /usr/share/X11/xkb/keycodes the mapping is made + return '' + +def generate_setxkbmap_config(device, preset, mappings): + """Generate a config file for setxkbmap. -def load_keymapping(): - """Load the current active mapping of keycodes""" - # to get ASCII codes: xmodmap -pk - output = subprocess.check_output(['xmodmap', '-p']).decode() - for line in output.split('\n'): - search = re.search(r'(\d+) = (.+)', line) - if search is not None: - key_mapping[search[0]] = search[1] + The file is created in ~/.config/key-mapper// and + a symlink is created in + /usr/share/X11/xkb/symbols/key-mapper// to point to it + """ + # setxkbmap cannot handle spaces + device = device.replace(' ', '_') + preset = preset.replace(' ', '_') + + config_path = os.path.join(CONFIG_PATH, device, preset) + usr_path = os.path.join(SYMBOLS_PATH, device, preset) + + if not os.path.exists(config_path): + logger.info('Creating config file "%s"', config_path) + os.makedirs(os.path.dirname(config_path), exist_ok=True) + os.mknod(config_path) + if not os.path.exists(usr_path): + logger.info('Creating symlink in "%s"', usr_path) + os.makedirs(os.path.dirname(usr_path), exist_ok=True) + os.symlink(config_path, usr_path) + + with open(config_path, 'w') as f: + f.write(generate_symbols_file_content(device, preset, mappings)) + + logger.debug('Successfully wrote the config file') + + +def generate_symbols_file_content(device, preset, mappings): + """Create config contents to be placed in /usr/share/X11/xkb/symbols.""" + system_default = 'us' # TODO get the system default + result = '\n'.join([ + 'default xkb_symbols "basic" {', + ' minimum = 8;', + ' maximum = 255;', + f' include "{system_default}"', + f' name[Group1]="{device}/{preset}";', + ' key { [ 2, 2, 2, 2 ] };', + '};', + ]) + '\n' + for mapping in mappings: + key = mapping.key + keycode = get_keycode(device, key) + target = mapping.target + # TODO support NUM block keys and modifiers somehow + + return result def parse_libinput_list(): @@ -95,8 +142,6 @@ def parse_libinput_list(): def parse_evtest(): """Get a mapping of {name: [paths]} for each evtest device. - evtest is quite slow. - This is grouped by name, so "Logitech USB Keyboard" and "Logitech USB Keyboard Consumer Control" are two keys in result. Some devices have the same name for each of those entries. @@ -143,14 +188,20 @@ def parse_evtest(): return result -def find_devices(): - """Return a mapping of {name: [paths]} for each input device.""" - result = parse_libinput_list() - logger.info('Found %s', ', '.join([f'"{name}"' for name in result])) - return result - - def get_xinput_list(): """Run xinput and get the resulting device names as list.""" xinput = subprocess.check_output(['xinput', 'list', f'--name-only']) return [line for line in xinput.decode().split('\n') if line != ''] + + +_devices = None + + +def find_devices(): + """Return a mapping of {name: [paths]} for each input device.""" + global _devices + # this is expensive, do it only once + if _devices is None: + _devices = parse_libinput_list() + logger.info('Found %s', ', '.join([f'"{name}"' for name in _devices])) + return _devices diff --git a/tests/fakes.py b/keymapper/paths.py similarity index 56% rename from tests/fakes.py rename to keymapper/paths.py index a0f63ae7..fa1d0c20 100644 --- a/tests/fakes.py +++ b/keymapper/paths.py @@ -19,28 +19,19 @@ # along with key-mapper. If not, see . -"""Patch stuff to get reproducible tests.""" +"""Path constants to be used. +Is a module so that tests can modify them. +""" -from unittest.mock import patch +import os +import subprocess -fake_config_path = '/tmp/keymapper-test-config' +SYMBOLS_PATH = '/usr/share/X11/xkb/symbols/key-mapper' -class UseFakes: - """Provides fake functionality for alsaaudio and some services.""" - def __init__(self): - self.patches = [] - - def patch(self): - """Replace the functions with various fakes.""" - # self.patches.append(patch.object(keymapper, 'foo', self.foo)) - for p in self.patches: - p.__enter__() - - def restore(self): - """Restore functionality.""" - for p in self.patches: - p.__exit__(None, None, None) - self.patches = [] +# since this needs to run as sudo, +# get the home dir of the user who called sudo. +who = subprocess.check_output('who').decode().split()[0] +CONFIG_PATH = os.path.join('/home', who, '.config/key-mapper') diff --git a/keymapper/presets.py b/keymapper/presets.py index 2ba79ee6..69580e69 100644 --- a/keymapper/presets.py +++ b/keymapper/presets.py @@ -23,22 +23,14 @@ import os +import glob import subprocess +from pathlib import Path +from keymapper.paths import CONFIG_PATH, SYMBOLS_PATH from keymapper.logger import logger from keymapper.config import get_config, get_config_path - - -def get_xinput_list(type): - """Run xinput and get the result as list. - - Parameters - ---------- - type : string - Ine of 'id' or 'name' - """ - output = subprocess.check_output(['xinput', 'list', f'--{type}-only']) - return [line for line in output.decode().split('\n') if line != ''] +from keymapper.X import find_devices def get_presets(device): @@ -48,11 +40,12 @@ def get_presets(device): ---------- device : string """ - device_folder = get_config_path(device) - logger.debug('Listing presets in %s', device_folder) + device_folder = os.path.join(SYMBOLS_PATH, device.replace(' ', '_')) if not os.path.exists(device_folder): os.makedirs(get_config_path(device_folder)) - return os.listdir(get_config_path(device_folder)) + presets = os.listdir(get_config_path(device_folder)) + logger.debug('Presets in "%s": %s', device_folder, ', '.join(presets)) + return presets def create_preset(device, name=None): @@ -84,6 +77,40 @@ def get_mappings(device, preset): def find_newest_preset(): - """Get the device and present that was most recently modified.""" - print('find_newest_preset not yet implemented') - return None, None + """Get a tuple of (device, preset) that was most recently modified. + + If no device has been configured yet, return arbitrarily. + """ + # sort the oldest files to the front + paths = sorted( + glob.glob(os.path.join(SYMBOLS_PATH, '*/*')), + key=os.path.getmtime + ) + + # map "vendor_keyboard" to "vendor keyboard" + device_mapping = { + name.replace(' ', '_'): name + for name in find_devices().keys() + } + + newest_path = None + while len(paths) > 0: + # take the newest path + path = paths.pop() + preset = os.path.split(path)[1] + device_underscored = os.path.split(os.path.split(path)[0])[1] + if device_mapping.get(device_underscored) is not None: + # this device is online + newest_path = path + break + + if newest_path is None: + logger.debug('None of the configured devices is currently online.') + # return anything + device = list(find_devices().keys())[0] + preset = (get_presets(device) or [None])[0] + return device, preset + + device = device_mapping.get(device_underscored) + + return device, preset diff --git a/tests/test.py b/tests/test.py index e87fa20a..127662e8 100644 --- a/tests/test.py +++ b/tests/test.py @@ -25,8 +25,31 @@ import sys import unittest +from keymapper import paths +from keymapper import X +from keymapper.logger import update_verbosity + + +def create_stubs(): + """Stub some linux stuff.""" + paths.SYMBOLS_PATH = '/tmp/key-mapper-test/symbols' + paths.CONFIG_PATH = '/tmp/key-mapper-test/.config' + + def find_devices(): + return { + 'device 1': ['/dev/input/event10', '/dev/input/event11'], + 'device 2': ['/dev/input/event3'] + } + + X.find_devices = find_devices + if __name__ == "__main__": + # make sure to do this before any other file gets a chance to do imports + create_stubs() + + update_verbosity(True) + modules = sys.argv[1:] # discoverer is really convenient, but it can't find a specific test # in all of the available tests like unittest.main() does..., diff --git a/tests/testcases/config.py b/tests/testcases/config.py index cee7bf69..53ef941f 100644 --- a/tests/testcases/config.py +++ b/tests/testcases/config.py @@ -43,8 +43,6 @@ class ConfigTest(unittest.TestCase): self.assertEqual(contents, """a=1\n # test=3\n abc=123\ntest=1234""") def test_get_config(self): - update_verbosity(True) - config = get_config('device1', 'preset1', '/tmp/key-mapper') self.assertEqual(config.device, 'device1') self.assertEqual(config.preset, 'preset1') diff --git a/tests/testcases/presets.py b/tests/testcases/presets.py new file mode 100644 index 00000000..a50f89de --- /dev/null +++ b/tests/testcases/presets.py @@ -0,0 +1,75 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# key-mapper - GUI for device specific keyboard mappings +# Copyright (C) 2020 sezanzeb +# +# This file is part of key-mapper. +# +# key-mapper 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. +# +# key-mapper 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 key-mapper. If not, see . + + +import os +import unittest +import shutil +import time + +from keymapper.presets import find_newest_preset + + +class PresetsTest(unittest.TestCase): + def setUp(self): + shutil.rmtree('/tmp/key-mapper-test') + + def test_find_newest_preset_1(self): + os.makedirs('/tmp/key-mapper-test/symbols/device_1') + os.makedirs('/tmp/key-mapper-test/symbols/device_2') + os.mknod('/tmp/key-mapper-test/symbols/device_1/preset_1') + time.sleep(0.01) + os.mknod('/tmp/key-mapper-test/symbols/device_2/preset_2') + # since presets are loaded from the path, and devices from the + # x command line tools, the presets have the exact same name as + # the path whereas devices need their whitespaces removed. + self.assertEqual(find_newest_preset(), ('device 2', 'preset_2')) + + def test_find_newest_preset_2(self): + os.makedirs('/tmp/key-mapper-test/symbols/device_1') + time.sleep(0.01) + os.makedirs('/tmp/key-mapper-test/symbols/device_2') + # takes the first one that the test-fake returns + self.assertEqual(find_newest_preset(), ('device 1', None)) + + def test_find_newest_preset_3(self): + os.makedirs('/tmp/key-mapper-test/symbols/device_1') + self.assertEqual(find_newest_preset(), ('device 1', None)) + + def test_find_newest_preset_4(self): + os.makedirs('/tmp/key-mapper-test/symbols/device_1') + os.mknod('/tmp/key-mapper-test/symbols/device_1/preset_1') + self.assertEqual(find_newest_preset(), ('device 1', 'preset_1')) + + def test_find_newest_preset_5(self): + os.makedirs('/tmp/key-mapper-test/symbols/device_1') + os.mknod('/tmp/key-mapper-test/symbols/device_1/preset_1') + time.sleep(0.01) + os.makedirs('/tmp/key-mapper-test/symbols/unknown_device3') + os.mknod('/tmp/key-mapper-test/symbols/unknown_device3/preset_1') + self.assertEqual(find_newest_preset(), ('device 1', 'preset_1')) + + def test_find_newest_preset_6(self): + # takes the first one that the test-fake returns + self.assertEqual(find_newest_preset(), ('device 1', None)) + + +if __name__ == "__main__": + unittest.main()