diff --git a/bin/key-mapper-gtk b/bin/key-mapper-gtk index e95cef4b..fec33de2 100755 --- a/bin/key-mapper-gtk +++ b/bin/key-mapper-gtk @@ -32,7 +32,8 @@ gi.require_version('GLib', '2.0') from gi.repository import Gtk from keymapper.data import get_data_path -from keymapper.X import find_devices, generate_setxkbmap_config +from keymapper.X import find_devices, create_setxkbmap_config, \ + create_identity_mapping from keymapper.presets import get_presets, get_mappings, \ find_newest_preset, create_preset from keymapper.logger import logger, update_verbosity, log_info @@ -97,7 +98,7 @@ class Window: self.populate_devices() - # find an select the newest preset based on file modification dates + # find and select the newest preset based on file modification dates device, preset = find_newest_preset() if device is not None: self.get('device_selection').set_active_id(device) @@ -121,7 +122,12 @@ class Window: def populate_presets(self): """Show the available presets for the selected device.""" - presets = get_presets(self.selected_device) + device = self.selected_device + presets = get_presets(device) + if len(presets) == 0: + create_preset(device) + else: + logger.debug('Presets for "%s": %s', device, ', '.join(presets)) preset_selection = self.get('preset_selection') preset_selection.remove_all() for preset in presets: @@ -139,11 +145,6 @@ class Window: self.selected_preset = None self.mappings = [] - presets = get_presets(device) - if len(presets) == 0: - create_preset(device) - else: - logger.debug('Presets for "%s": %s', device, ', '.join(presets)) self.populate_presets() def on_create_preset_clicked(self, button): @@ -217,7 +218,11 @@ class Window: self.selected_device, self.selected_preset ) - generate_setxkbmap_config( + + # TODO use user defined mapping + self.mappings = [(10, 'z')] + + create_setxkbmap_config( self.selected_device, self.selected_preset, self.mappings diff --git a/data/xkb_keycodes_template b/data/xkb_keycodes_template new file mode 100644 index 00000000..bc783a16 --- /dev/null +++ b/data/xkb_keycodes_template @@ -0,0 +1,7 @@ +// keycodes configuration for key-mapper presets + +default xkb_keycodes "basic" {{ + minimum = {minimum}; + maximum = {maximum}; + {xkb_keycodes} +}}; diff --git a/data/xkb_symbols_template b/data/xkb_symbols_template new file mode 100644 index 00000000..41066f74 --- /dev/null +++ b/data/xkb_symbols_template @@ -0,0 +1,10 @@ +// key-mapper symbols config file. + +// the corresponding keycodes configuration is in +// /usr/share/X11/xkb/keycodes/key-mapper + +default xkb_symbols "basic" {{ + include "{system_default}" + name[Group1] = "{name}"; + {xkb_symbols} +}}; diff --git a/keymapper/X.py b/keymapper/X.py index 28e7891b..af8f2c48 100644 --- a/keymapper/X.py +++ b/keymapper/X.py @@ -35,8 +35,9 @@ import os import re import subprocess -from keymapper.paths import CONFIG_PATH, SYMBOLS_PATH +from keymapper.paths import CONFIG_PATH, SYMBOLS_PATH, KEYCODES_PATH from keymapper.logger import logger +from keymapper.data import get_data_path def get_keycode(device, letter): @@ -46,7 +47,7 @@ def get_keycode(device, letter): return '' -def generate_setxkbmap_config(device, preset, mappings): +def create_setxkbmap_config(device, preset, mappings): """Generate a config file for setxkbmap. The file is created in ~/.config/key-mapper// and, @@ -56,6 +57,8 @@ def generate_setxkbmap_config(device, preset, mappings): The file in home doesn't have underscore to be more beautiful on the frontend, while the symlink doesn't contain any whitespaces. """ + create_identity_mapping() + config_path = os.path.join(CONFIG_PATH, device, preset) # setxkbmap cannot handle spaces usr_path = os.path.join(SYMBOLS_PATH, device, preset).replace(' ', '_') @@ -69,44 +72,89 @@ def generate_setxkbmap_config(device, preset, mappings): os.makedirs(os.path.dirname(usr_path), exist_ok=True) os.symlink(config_path, usr_path) + logger.info('Writing key mappings') with open(config_path, 'w') as f: f.write(generate_symbols_file_content(device, preset, mappings)) - logger.debug('Wrote key mappings') def apply_preset(device, preset): - # setxkbmap key-mapper/Razer_Razer_Naga_Trinity/new_preset -v 10 -device 12 + # setxkbmap -layout key-mapper/Razer_Razer_Naga_Trinity/new_preset -keycodes key-mapper -v 10 -device 13 # TODO device 12 is from `xinput list` but currently there is no function # to obtain that. And that cli tool outputs all those extra devices. # 1. get all names in the group (similar to parse_libinput_list) # 2. get all ids from xinput list for each name # 3. apply preset to all of them + pass -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 +def create_identity_mapping(): + """Because the concept of "reasonable symbolic names" [3] doesn't apply + when mouse buttons are all over the place. Create an identity mapping + to make generating "symbols" files easier. Keycode 10 -> "<10>" - # 10 is what the mouse reports - result = """ -default xkb_symbols "basic" {{ - include "us" - name[Group1]="{name}"; - key { [ 3 ] }; -}}; - -default xkb_keycodes "basic" {{ - minimum= 8; - maximum= 255; - = 10; -}}; + This has the added benefit that keycodes reported by xev can be + identified in the symbols file. """ - result = result.format(name=f'{device}/{preset}') + # TODO don't create this again if it already exists, as soon as this + # stuff is stable. + + xkb_keycodes = [] + # the maximum specified in /usr/share/X11/xkb/keycodes is usually 255 + # and the minimum 8 + maximum = 255 + minimum = 8 + for code in range(minimum, maximum + 1): + xkb_keycodes.append(f'<{code}> = {code};') + + template_path = os.path.join(get_data_path(), 'xkb_keycodes_template') + with open(template_path, 'r') as template_file: + template = template_file.read() + + result = template.format( + minimum=minimum, + maximum=maximum, + xkb_keycodes='\n '.join(xkb_keycodes) + ) + + logger.info('Creating "%s"', KEYCODES_PATH) + with open(KEYCODES_PATH, 'w') as keycodes: + keycodes.write(result) + - for mapping in mappings: - key = mapping.key - keycode = get_keycode(device, key) - target = mapping.target +def generate_symbols_file_content(device, preset, mappings): + """Create config contents to be placed in /usr/share/X11/xkb/symbols. + + Parameters + ---------- + device : string + preset : string + mappings : array + tuples of code, character + """ + system_default = 'us' # TODO get the system default + + # WARNING if the symbols file contains key codes that are not present in + # the keycodes file, the whole X session will crash! + if not os.path.exists(KEYCODES_PATH): + raise ValueError('Expected the keycodes file to exist.') + with open(KEYCODES_PATH, 'r') as f: + keycodes = re.findall(r'<.+?>', f.read()) + + xkb_symbols = [] + for code, character in mappings: + if f'<{code}>' not in keycodes: + logger.error(f'Unknown keycode <{code}> for "{character}"') + xkb_symbols.append(f'key <{code}> {{ [ {character} ] }};') + + template_path = os.path.join(get_data_path(), 'xkb_symbols_template') + with open(template_path, 'r') as template_file: + template = template_file.read() + + result = template.format( + name=f'{device}/{preset}', + xkb_symbols='\n '.join(xkb_symbols), + system_default=system_default + ) return result diff --git a/keymapper/paths.py b/keymapper/paths.py index fa1d0c20..cf5ff9f5 100644 --- a/keymapper/paths.py +++ b/keymapper/paths.py @@ -31,6 +31,8 @@ import subprocess SYMBOLS_PATH = '/usr/share/X11/xkb/symbols/key-mapper' +KEYCODES_PATH = '/usr/share/X11/xkb/keycodes/key-mapper' + # 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] diff --git a/keymapper/presets.py b/keymapper/presets.py index ca857c23..7308b918 100644 --- a/keymapper/presets.py +++ b/keymapper/presets.py @@ -27,7 +27,7 @@ import glob from keymapper.paths import CONFIG_PATH from keymapper.logger import logger -from keymapper.X import find_devices, generate_setxkbmap_config +from keymapper.X import find_devices, create_setxkbmap_config def get_presets(device): @@ -40,7 +40,15 @@ def get_presets(device): device_folder = os.path.join(CONFIG_PATH, device) if not os.path.exists(device_folder): os.makedirs(device_folder) - presets = os.listdir(device_folder) + presets = [ + os.path.basename(path) + for path in sorted( + glob.glob(os.path.join(device_folder, '*')), + key=os.path.getmtime + ) + ] + # the highest timestamp to the front + presets.reverse() return presets @@ -51,12 +59,13 @@ def create_preset(device, name=None): name = 'new preset' # find a name that is not already taken - i = 1 - while name in existing_names: - i += 1 + if name in existing_names: + i = 2 + while f'{name} {i}' in existing_names: + i += 1 name = f'{name} {i}' - generate_setxkbmap_config(device, name, []) + create_setxkbmap_config(device, name, []) return name @@ -83,7 +92,6 @@ def find_newest_preset(): If no device has been configured yet, return arbitrarily. """ - # sort the oldest files to the front paths = sorted( glob.glob(os.path.join(CONFIG_PATH, '*/*')), @@ -110,4 +118,6 @@ def find_newest_preset(): logger.debug('None of the configured devices is currently online.') return get_any_preset() + logger.debug('The newest preset is "%s", "%s"', device, preset) + return device, preset diff --git a/setup.py b/setup.py index a1e6ba52..d4a8ca50 100644 --- a/setup.py +++ b/setup.py @@ -29,5 +29,6 @@ DistUtilsExtra.auto.setup( license='GPL-3.0', data_files=[ ('share/applications/', ['data/key-mapper.desktop']), + ('share/key-mapper/', ['data/xkb_symbols_template']), ], ) diff --git a/tests/testcases/presets.py b/tests/testcases/presets.py index 38584910..3124376c 100644 --- a/tests/testcases/presets.py +++ b/tests/testcases/presets.py @@ -31,6 +31,10 @@ tmp = '/tmp/key-mapper-test' class TestCreatePreset(unittest.TestCase): + def setUp(self): + if os.path.exists(tmp): + shutil.rmtree(tmp) + def test_create_preset_1(self): create_preset('device 1') self.assertTrue(os.path.exists(f'{tmp}/symbols/device_1/new_preset')) @@ -55,7 +59,8 @@ class TestCreatePreset(unittest.TestCase): class TestFindPresets(unittest.TestCase): def setUp(self): - shutil.rmtree(f'{tmp}') + if os.path.exists(tmp): + shutil.rmtree(tmp) def test_find_newest_preset_1(self): print('test_find_newest_preset_1')