diff --git a/bin/key-mapper-gtk b/bin/key-mapper-gtk index ced3ac18..e95cef4b 100755 --- a/bin/key-mapper-gtk +++ b/bin/key-mapper-gtk @@ -100,9 +100,9 @@ class Window: # find an select the newest preset based on file modification dates device, preset = find_newest_preset() if device is not None: - self.on_select_device(device) + self.get('device_selection').set_active_id(device) if preset is not None: - self.on_select_preset(preset) + self.get('device_selection').set_active_id(preset) def get(self, name): """Get a widget from the window""" @@ -117,7 +117,6 @@ class Window: devices = find_devices() device_selection = self.get('device_selection') for device in devices: - ids = devices[device] device_selection.append(device, device) def populate_presets(self): @@ -130,10 +129,11 @@ class Window: # and select the newest one (on the top) preset_selection.set_active(0) - def on_select_device(self, device): + def on_select_device(self, dropdown): """List all presets, create one if none exist yet.""" - if isinstance(device, Gtk.ComboBoxText): - device = device.get_active_text() + device = dropdown.get_active_text() + + logger.debug('Selecting device "%s"', device) self.selected_device = device self.selected_preset = None @@ -142,7 +142,8 @@ class Window: 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): @@ -152,10 +153,10 @@ class Window: 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() + def on_select_preset(self, dropdown): + """Show the mappings of the preset.""" + preset = dropdown.get_active_text() + logger.debug('Selecting preset "%s"', preset) self.selected_preset = preset self.mappings = [] diff --git a/keymapper/X.py b/keymapper/X.py index 5e5e98ee..95b30e91 100644 --- a/keymapper/X.py +++ b/keymapper/X.py @@ -49,16 +49,16 @@ def get_keycode(device, letter): def generate_setxkbmap_config(device, preset, mappings): """Generate a config file for setxkbmap. - 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 + The file is created in ~/.config/key-mapper// and, + in order to find all presets in the home dir to make backing them up + more intuitive, a symlink is created in + /usr/share/X11/xkb/symbols/key-mapper// to point to it. + The file in home doesn't have underscore to be more beautiful on the + frontend, while the symlink doesn't contain any whitespaces. """ - # 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) + # setxkbmap cannot handle spaces + usr_path = os.path.join(SYMBOLS_PATH, device, preset).replace(' ', '_') if not os.path.exists(config_path): logger.info('Creating config file "%s"', config_path) @@ -72,12 +72,11 @@ def generate_setxkbmap_config(device, preset, mappings): 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 + # TODO I think I also have to create a file in /usr/share/X11/xkb/keycodes result = '\n'.join([ 'default xkb_symbols "basic" {', ' minimum = 8;', diff --git a/keymapper/config.py b/keymapper/config.py deleted file mode 100644 index ffc872f9..00000000 --- a/keymapper/config.py +++ /dev/null @@ -1,220 +0,0 @@ -#!/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 . - - -"""Query settings, parse and write config files.""" - - -import os - -from keymapper.logger import logger - - -# one config file per preset, one folder per device -_configs = {} - - -_defaults = {} - - -# TODO this works on xmodmaps instead of config files - - -def _modify_config(config_contents, key, value): - """Return a string representing the modified contents of the config file. - - Parameters - ---------- - config_contents : string - Contents of the config file in ~/.config/key-mapper/config. - It is not edited in place and the config file is not overwritten. - key : string - Settings key that should be modified - value : string, int - Value to write - """ - logger.info('Setting "%s" to "%s"', key, value) - - split = config_contents.split('\n') - if split[-1] == '': - split = split[:-1] - - found = False - setting = f'{key}={value}' - for i, line in enumerate(split): - strip = line.strip() - if strip.startswith('#'): - continue - if strip.startswith(f'{key}='): - # replace the setting - logger.debug('Overwriting "%s=%s" in config', key, value) - split[i] = setting - found = True - break - if not found: - logger.debug('Adding "%s=%s" to config', key, value) - split.append(setting) - return '\n'.join(split) - - -def get_config_path(device, preset=None, path=None): - """Get the path that leads to the coniguration of that preset. - - Parameters - ---------- - device : string - preset : string or None - If none, will return the folder of the device - path : string or None - If none, will default to '~/.config/key-mapper/'. - In that directory, a folder for the device and a file for - the preset will be created. - """ - path = path or os.path.expanduser('~/.config/key-mapper/') - return os.path.join(path, device, preset or '') - - -class Config: - """Read and set config values.""" - def __init__(self, device, preset, path=None): - """Initialize the interface to the config file. - - Parameters - ---------- - device : string - preset : string - path : string or None - If none, will default to '~/.config/key-mapper/'. - In that directory, a folder for the device and a file for - the preset will be created. - """ - path = get_config_path(device, preset, path) - logger.debug('Using config file at %s', path) - - self.device = device - self.preset = preset - self._path = path - self._config = {} - self.mtime = 0 - - self.create_config_file() - - self.load_config() - - def create_config_file(self): - """Create an empty config if it doesn't exist.""" - if not os.path.exists(os.path.dirname(self._path)): - os.makedirs(os.path.dirname(self._path)) - if not os.path.exists(self._path): - logger.info('Creating config file "%s"', self._path) - os.mknod(self._path) - - def load_config(self): - """Read the config file.""" - logger.debug('Loading configuration') - self._config = {} - # load config - self.mtime = os.path.getmtime(self._path) - with open(self._path, 'r') as config_file: - for line in config_file: - line = line.strip() - if not line.startswith('#'): - split = line.split('=', 1) - if len(split) == 2: - key = split[0] - value = split[1] - else: - key = split[0] - value = None - if value.isdigit(): - value = int(value) - else: - try: - value = float(value) - except ValueError: - pass - if value == 'True': - value = True - if value == 'False': - value = False - self._config[key] = value - - def check_mtime(self): - """Check if the config file has been modified and reload if needed.""" - if os.path.getmtime(self._path) != self.mtime: - logger.info('Config changed, reloading') - self.load_config() - - def get(self, key): - """Read a value from the configuration or get the default.""" - self.check_mtime() - return self._config.get(key, _defaults[key]) - - def set(self, key, value): - """Write a setting into memory and ~/.config/key-mapper/.""" - self.check_mtime() - - if key in self._config and self._config[key] == value: - logger.debug('Setting "%s" is already "%s"', key, value) - return False - - self._config[key] = value - - with open(self._path, 'r+') as config_file: - config_contents = config_file.read() - config_contents = _modify_config(config_contents, key, value) - - # overwrite completely - with open(self._path, 'w') as config_file: - if not config_contents.endswith('\n'): - config_contents += '\n' - config_file.write(config_contents) - - self.mtime = os.path.getmtime(self._path) - return True - - -def get_config(device, preset, path=None): - """Ask for a config object. - - There should not be multiple Config objects for the same preset, so make - sure to use this function insted of the Config constructor. - - Creates a config file if it doesn't exist yet. - - Parameters - ---------- - device : string - preset : string - path : string or None - If none, will default to '~/.config/key-mapper/'. - In that directory, a folder for the device and a file for - the preset will be created. - """ - # don't initialize it right away in the global scope, to avoid having - # the wrong logging verbosity. - global _configs - if _configs.get(device) is None: - _configs[device] = {} - - if _configs[device].get(preset) is None: - _configs[device][preset] = Config(device, preset, path) - - return _configs[device][preset] diff --git a/keymapper/presets.py b/keymapper/presets.py index 69580e69..1fc87066 100644 --- a/keymapper/presets.py +++ b/keymapper/presets.py @@ -29,8 +29,7 @@ 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 -from keymapper.X import find_devices +from keymapper.X import find_devices, generate_setxkbmap_config def get_presets(device): @@ -40,11 +39,10 @@ def get_presets(device): ---------- device : string """ - device_folder = os.path.join(SYMBOLS_PATH, device.replace(' ', '_')) + device_folder = os.path.join(CONFIG_PATH, device) if not os.path.exists(device_folder): - os.makedirs(get_config_path(device_folder)) - presets = os.listdir(get_config_path(device_folder)) - logger.debug('Presets in "%s": %s', device_folder, ', '.join(presets)) + os.makedirs(device_folder) + presets = os.listdir(device_folder) return presets @@ -58,10 +56,9 @@ def create_preset(device, name=None): i = 1 while name in existing_names: i += 1 - name = f'new preset {i}' + name = f'{name} {i}' - # trigger the creation of a new config file: - get_config(device, name) + generate_setxkbmap_config(device, name, []) return name @@ -76,41 +73,43 @@ def get_mappings(device, preset): pass +def get_any_preset(): + """Return the first found tuple of (device, preset).""" + any_device = list(find_devices().keys())[0] + any_preset = (get_presets(any_device) or [None])[0] + return any_device, any_preset + + def find_newest_preset(): """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, '*/*')), + glob.glob(os.path.join(CONFIG_PATH, '*/*')), key=os.path.getmtime ) - # map "vendor_keyboard" to "vendor keyboard" - device_mapping = { - name.replace(' ', '_'): name - for name in find_devices().keys() - } + if len(paths) == 0: + logger.debug('No presets found.') + return get_any_preset() + + online_devices = 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 + device = os.path.split(os.path.split(path)[0])[1] + if device in online_devices: 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 get_any_preset() return device, preset diff --git a/tests/test.py b/tests/test.py index 127662e8..3353caa7 100644 --- a/tests/test.py +++ b/tests/test.py @@ -25,29 +25,21 @@ import sys import unittest +# quickly fake some stuff before any other file gets imported and initialized 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' +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'] - } +from keymapper import X +X.find_devices = lambda: ({ + 'device 1': ['/dev/input/event10', '/dev/input/event11'], + 'device 2': ['/dev/input/event3'] +}) - X.find_devices = find_devices +from keymapper.logger import update_verbosity 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:] diff --git a/tests/testcases/config.py b/tests/testcases/config.py deleted file mode 100644 index 53ef941f..00000000 --- a/tests/testcases/config.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/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 - -from keymapper.logger import update_verbosity -from keymapper.config import _modify_config, get_config - - -class ConfigTest(unittest.TestCase): - def test_first_line(self): - contents = """a=1\n # test=3\n abc=123""" - contents = _modify_config(contents, 'a', 3) - self.assertEqual(contents, """a=3\n # test=3\n abc=123""") - - def test_last_line(self): - contents = """a=1\n # test=3\n abc=123""" - contents = _modify_config(contents, 'abc', 'foo') - self.assertEqual(contents, """a=1\n # test=3\nabc=foo""") - - def test_new_line(self): - contents = """a=1\n # test=3\n abc=123""" - contents = _modify_config(contents, 'test', '1234') - self.assertEqual(contents, """a=1\n # test=3\n abc=123\ntest=1234""") - - def test_get_config(self): - config = get_config('device1', 'preset1', '/tmp/key-mapper') - self.assertEqual(config.device, 'device1') - self.assertEqual(config.preset, 'preset1') - self.assertTrue(os.path.isfile('/tmp/key-mapper/device1/preset1')) - - get_config('device1', 'preset2', '/tmp/key-mapper') - self.assertTrue(os.path.isfile('/tmp/key-mapper/device1/preset2')) - - get_config('device2', 'preset3', '/tmp/key-mapper') - self.assertTrue(os.path.isfile('/tmp/key-mapper/device2/preset3')) - - config.set('key1', 'value') - config.set('key2', 123) - with open('/tmp/key-mapper/device1/preset1', 'r') as f: - contents = f.read() - self.assertEqual(contents, 'key1=value\nkey2=123\n') - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/testcases/integration.py b/tests/testcases/integration.py index 094a0f9b..efde3ca0 100644 --- a/tests/testcases/integration.py +++ b/tests/testcases/integration.py @@ -19,7 +19,6 @@ # along with key-mapper. If not, see . -import os import sys import unittest from unittest.mock import patch @@ -27,12 +26,10 @@ from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader import gi +import shutil gi.require_version('Gtk', '3.0') from gi.repository import Gtk -from fakes import UseFakes, fake_config_path -from keymapper.config import get_config - def gtk_iteration(): """Iterate while events are pending.""" @@ -74,17 +71,13 @@ class Integration(unittest.TestCase): Gtk.main_quit = lambda: None def setUp(self): - self.fakes = UseFakes() - self.fakes.patch() self.window = launch() def tearDown(self): self.window.on_close() self.window.window.destroy() gtk_iteration() - self.fakes.restore() - # TODO iterate over all config files in the fake_path and - # empty them + shutil.rmtree('/tmp/key-mapper-test') def test_can_start(self): self.assertIsNotNone(self.window) diff --git a/tests/testcases/presets.py b/tests/testcases/presets.py index a50f89de..38584910 100644 --- a/tests/testcases/presets.py +++ b/tests/testcases/presets.py @@ -24,47 +24,69 @@ import unittest import shutil import time -from keymapper.presets import find_newest_preset +from keymapper.presets import find_newest_preset, create_preset -class PresetsTest(unittest.TestCase): +tmp = '/tmp/key-mapper-test' + + +class TestCreatePreset(unittest.TestCase): + def test_create_preset_1(self): + create_preset('device 1') + self.assertTrue(os.path.exists(f'{tmp}/symbols/device_1/new_preset')) + self.assertTrue(os.path.exists(f'{tmp}/.config/device 1/new preset')) + + def test_create_preset_2(self): + create_preset('device 1') + create_preset('device 1') + self.assertTrue(os.path.exists(f'{tmp}/symbols/device_1/new_preset')) + self.assertTrue(os.path.exists(f'{tmp}/.config/device 1/new preset')) + self.assertTrue(os.path.exists(f'{tmp}/symbols/device_1/new_preset_2')) + self.assertTrue(os.path.exists(f'{tmp}/.config/device 1/new preset 2')) + + def test_create_preset_3(self): + create_preset('device 1', 'pre set') + create_preset('device 1', 'pre set') + self.assertTrue(os.path.exists(f'{tmp}/symbols/device_1/pre_set')) + self.assertTrue(os.path.exists(f'{tmp}/.config/device 1/pre set')) + self.assertTrue(os.path.exists(f'{tmp}/symbols/device_1/pre_set_2')) + self.assertTrue(os.path.exists(f'{tmp}/.config/device 1/pre set 2')) + + +class TestFindPresets(unittest.TestCase): def setUp(self): - shutil.rmtree('/tmp/key-mapper-test') + shutil.rmtree(f'{tmp}') 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') + print('test_find_newest_preset_1') + create_preset('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')) + create_preset('device 2', 'preset 2') + 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') + os.makedirs(f'{tmp}/symbols/device_1') + os.makedirs(f'{tmp}/.config/device_1') time.sleep(0.01) - os.makedirs('/tmp/key-mapper-test/symbols/device_2') + os.makedirs(f'{tmp}/symbols/device_2') + os.makedirs(f'{tmp}/.config/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') + os.makedirs(f'{tmp}/symbols/device_1') + os.makedirs(f'{tmp}/.config/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')) + create_preset('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') + create_preset('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')) + create_preset('unknown device 3', 'preset 3') + 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