creating the symbols file, symlinking, more tests

xkb
sezanzeb 4 years ago committed by sezanzeb
parent fecfa01bc6
commit 02efe37389

@ -32,7 +32,7 @@ gi.require_version('GLib', '2.0')
from gi.repository import Gtk from gi.repository import Gtk
from keymapper.data import get_data_path 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, \ from keymapper.presets import get_presets, get_mappings, \
find_newest_preset, create_preset find_newest_preset, create_preset
from keymapper.logger import logger, update_verbosity, log_info from keymapper.logger import logger, update_verbosity, log_info
@ -82,6 +82,8 @@ class Window:
def __init__(self): def __init__(self):
self.rows = 0 self.rows = 0
self.selected_device = None self.selected_device = None
self.selected_preset = None
self.mappings = []
gladefile = os.path.join(get_data_path(), 'key-mapper.glade') gladefile = os.path.join(get_data_path(), 'key-mapper.glade')
builder = Gtk.Builder() builder = Gtk.Builder()
@ -133,10 +135,14 @@ class Window:
if isinstance(device, Gtk.ComboBoxText): if isinstance(device, Gtk.ComboBoxText):
device = device.get_active_text() device = device.get_active_text()
self.selected_device = device
self.selected_preset = None
self.mappings = []
presets = get_presets(device) presets = get_presets(device)
if len(presets) == 0: if len(presets) == 0:
create_preset(device) create_preset(device)
self.selected_device = device
self.populate_presets() self.populate_presets()
def on_create_preset_clicked(self, button): def on_create_preset_clicked(self, button):
@ -144,12 +150,16 @@ class Window:
new_preset = create_preset(self.selected_device) new_preset = create_preset(self.selected_device)
self.get('preset_selection').append(new_preset, new_preset) self.get('preset_selection').append(new_preset, new_preset)
self.get('preset_selection').set_active_id(new_preset) self.get('preset_selection').set_active_id(new_preset)
self.update_config()
def on_select_preset(self, preset): def on_select_preset(self, preset):
"""Show the mappings of the preset""" """Show the mappings of the preset"""
if isinstance(preset, Gtk.ComboBoxText): if isinstance(preset, Gtk.ComboBoxText):
preset = preset.get_active_text() preset = preset.get_active_text()
self.selected_preset = preset
self.mappings = []
# prepare one empty input to add stuff, and to get the grid to # 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 correct column width, otherwise it may jump if the user adds
# the first row. # the first row.
@ -182,6 +192,9 @@ class Window:
---------- ----------
mapping : SingleKeyMapping mapping : SingleKeyMapping
""" """
# TODO modify self.mappings
self.update_config()
# shrink the window down as much as possible, otherwise it # shrink the window down as much as possible, otherwise it
# will increase with each added mapping but won't go back when they # will increase with each added mapping but won't go back when they
# are removed. # are removed.
@ -194,6 +207,21 @@ class Window:
# add back an empty row # add back an empty row
self.on_add_key_clicked() 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__': if __name__ == '__main__':
parser = ArgumentParser() parser = ArgumentParser()

@ -19,34 +19,81 @@
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>. # along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""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: Resources:
https://wiki.archlinux.org/index.php/Keyboard_input [1] https://wiki.archlinux.org/index.php/Keyboard_input
http://people.uleth.ca/~daniel.odonnell/Blog/custom-keyboard-in-linuxx11 [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 re
import subprocess import subprocess
from keymapper.paths import CONFIG_PATH, SYMBOLS_PATH
from keymapper.logger import logger from keymapper.logger import logger
# mapping of key to character def get_keycode(device, letter):
# This depends on the configured keyboard layout. """Get the keycode that is configured for the given letter."""
# example: AC01: "a A a A ae AE ae". # TODO I have no idea how to do this
key_mapping = {} # 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(): The file is created in ~/.config/key-mapper/<device>/<preset> and
"""Load the current active mapping of keycodes""" a symlink is created in
# to get ASCII codes: xmodmap -pk /usr/share/X11/xkb/symbols/key-mapper/<device>/<preset> to point to it
output = subprocess.check_output(['xmodmap', '-p']).decode() """
for line in output.split('\n'): # setxkbmap cannot handle spaces
search = re.search(r'(\d+) = (.+)', line) device = device.replace(' ', '_')
if search is not None: preset = preset.replace(' ', '_')
key_mapping[search[0]] = search[1]
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 <AE01> { [ 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(): def parse_libinput_list():
@ -95,8 +142,6 @@ def parse_libinput_list():
def parse_evtest(): def parse_evtest():
"""Get a mapping of {name: [paths]} for each evtest device. """Get a mapping of {name: [paths]} for each evtest device.
evtest is quite slow.
This is grouped by name, so "Logitech USB Keyboard" and This is grouped by name, so "Logitech USB Keyboard" and
"Logitech USB Keyboard Consumer Control" are two keys in result. Some "Logitech USB Keyboard Consumer Control" are two keys in result. Some
devices have the same name for each of those entries. devices have the same name for each of those entries.
@ -143,14 +188,20 @@ def parse_evtest():
return result 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(): def get_xinput_list():
"""Run xinput and get the resulting device names as list.""" """Run xinput and get the resulting device names as list."""
xinput = subprocess.check_output(['xinput', 'list', f'--name-only']) xinput = subprocess.check_output(['xinput', 'list', f'--name-only'])
return [line for line in xinput.decode().split('\n') if line != ''] 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

@ -19,28 +19,19 @@
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>. # along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""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: # since this needs to run as sudo,
"""Provides fake functionality for alsaaudio and some services.""" # get the home dir of the user who called sudo.
def __init__(self): who = subprocess.check_output('who').decode().split()[0]
self.patches = [] CONFIG_PATH = os.path.join('/home', who, '.config/key-mapper')
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 = []

@ -23,22 +23,14 @@
import os import os
import glob
import subprocess import subprocess
from pathlib import Path
from keymapper.paths import CONFIG_PATH, SYMBOLS_PATH
from keymapper.logger import logger from keymapper.logger import logger
from keymapper.config import get_config, get_config_path from keymapper.config import get_config, get_config_path
from keymapper.X import find_devices
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 != '']
def get_presets(device): def get_presets(device):
@ -48,11 +40,12 @@ def get_presets(device):
---------- ----------
device : string device : string
""" """
device_folder = get_config_path(device) device_folder = os.path.join(SYMBOLS_PATH, device.replace(' ', '_'))
logger.debug('Listing presets in %s', device_folder)
if not os.path.exists(device_folder): if not os.path.exists(device_folder):
os.makedirs(get_config_path(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): def create_preset(device, name=None):
@ -84,6 +77,40 @@ def get_mappings(device, preset):
def find_newest_preset(): def find_newest_preset():
"""Get the device and present that was most recently modified.""" """Get a tuple of (device, preset) that was most recently modified.
print('find_newest_preset not yet implemented')
return None, None 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

@ -25,8 +25,31 @@
import sys import sys
import unittest 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__": 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:] modules = sys.argv[1:]
# discoverer is really convenient, but it can't find a specific test # discoverer is really convenient, but it can't find a specific test
# in all of the available tests like unittest.main() does..., # in all of the available tests like unittest.main() does...,

@ -43,8 +43,6 @@ class ConfigTest(unittest.TestCase):
self.assertEqual(contents, """a=1\n # test=3\n abc=123\ntest=1234""") self.assertEqual(contents, """a=1\n # test=3\n abc=123\ntest=1234""")
def test_get_config(self): def test_get_config(self):
update_verbosity(True)
config = get_config('device1', 'preset1', '/tmp/key-mapper') config = get_config('device1', 'preset1', '/tmp/key-mapper')
self.assertEqual(config.device, 'device1') self.assertEqual(config.device, 'device1')
self.assertEqual(config.preset, 'preset1') self.assertEqual(config.preset, 'preset1')

@ -0,0 +1,75 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
# Copyright (C) 2020 sezanzeb <proxima@hip70890b.de>
#
# 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 <https://www.gnu.org/licenses/>.
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()
Loading…
Cancel
Save