From 8a496644eb21229cfc981e80e30138d760b83c92 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Sun, 8 Nov 2020 00:54:19 +0100 Subject: [PATCH] wip, tests failing, stuff broken --- bin/key-mapper-gtk | 70 +++++++++++++++++++++---------- keymapper/X.py | 75 +++++++++++++++++++++++++++------- keymapper/linux.py | 9 ++-- keymapper/presets.py | 4 ++ tests/test.py | 17 ++++++-- tests/testcases/integration.py | 46 +++++++++++++++++++-- tests/testcases/presets.py | 3 +- tests/testcases/test.py | 33 +++++++++++++++ 8 files changed, 206 insertions(+), 51 deletions(-) create mode 100644 tests/testcases/test.py diff --git a/bin/key-mapper-gtk b/bin/key-mapper-gtk index 56f7150a..796190d6 100755 --- a/bin/key-mapper-gtk +++ b/bin/key-mapper-gtk @@ -80,25 +80,55 @@ class SingleKeyMapping: return character if character else None def start_watching_keycodes(self, *args): - print('start_watching_keycodes') + """Start to periodically check if a keycode has been pressed. + + This is different from just listening for text input events + (as in Gtk.Entry), since keys may not write characters into the form + because they are not mapped. Furthermore their keycode is needed, + not their mapped character.""" keycode_reader.clear() - GLib.timeout_add(1000 / 30, self.get_newest_keycode) - def get_newest_keycode(self): + def iterate(): + self.check_newest_keycode() + return self.keycode.is_focus() + + GLib.timeout_add(1000 / 30, iterate) + + def check_newest_keycode(self): + """Check if a keycode has been pressed and if so, display it.""" new_keycode = keycode_reader.read() previous_keycode = self.get_keycode() character = self.get_character() - # TODO this is broken af - if new_keycode is not None: - try: - mapping.change(previous_keycode, new_keycode, character) - self.keycode.set_label(str(new_keycode)) - except KeyError as e: - # Show error in status bar - logger.info(str(e)[1:-1]) + # no input + if new_keycode is None: + return - return self.keycode.is_focus() + # keycode didn't change, do nothing + if new_keycode == previous_keycode: + return + + # keycode is already set by some other row + if mapping.get(new_keycode) is not None: + logger.info('Keycode %s is already mapped', new_keycode) + return + + # it's legal to display the keycode + self.keycode.set_label(str(new_keycode)) + + # the character is empty and therefore the mapping is not complete + if character is None: + return + + # else, the keycode has changed, the character is set, all good + mapping.change(previous_keycode, new_keycode, character) + + def on_character_input_change(self, entry): + keycode = self.get_keycode() + character = self.get_character() + + if keycode is not None: + mapping.change(None, keycode, character) def put_together(self, keycode, character): """Create all GTK widgets.""" @@ -134,6 +164,10 @@ class SingleKeyMapping: character_input.set_has_frame(False) if character is not None: character_input.set_text(character) + character_input.connect( + 'changed', + self.on_character_input_change + ) row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) row.set_homogeneous(True) @@ -176,11 +210,8 @@ class Window: self.select_newest_preset() - print(1) css_provider = Gtk.CssProvider() - print(2) css_provider.load_from_path(get_data_path('style.css')) - print(3) Gtk.StyleContext.add_provider_for_screen( Gdk.Screen.get_default(), css_provider, @@ -242,9 +273,7 @@ class Window: if new_name != '' and new_name != self.selected_preset: rename_preset(self.selected_device, self.selected_preset, new_name) self.populate_presets() - - self.update_mappings() - self.update_config() + self.save_config() def on_delete_preset_clicked(self, button): """Delete a preset from the file system.""" @@ -253,7 +282,6 @@ class Window: def on_apply_preset_clicked(self, button): """Apply a preset without saving changes.""" - self.update_mappings() logger.debug( 'Applying preset "%s" for "%s"', self.selected_preset, @@ -280,7 +308,7 @@ 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() + self.save_config() def on_select_preset(self, dropdown): """Show the mappings of the preset.""" @@ -331,7 +359,7 @@ class Window: window = self.get('window') window.resize(window.get_size()[0], 1) - def update_config(self): + def save_config(self): """Write changes to disk""" if self.selected_device is None or self.selected_preset is None: return diff --git a/keymapper/X.py b/keymapper/X.py index 8e5d6436..62abd647 100644 --- a/keymapper/X.py +++ b/keymapper/X.py @@ -56,9 +56,22 @@ class Mapping: """Iterate over tuples of unique keycodes and their character.""" return iter(self._mapping.items()) + def __len__(self): + return len(self._mapping) + def load(self, device, preset): """Parse the X config to replace the current mapping with that one.""" - with open(get_home_path(device, preset), 'r') as f: + path = get_home_path(device, preset) + + if not os.path.exists(path): + logger.debug( + 'Tried to load non existing preset "%s" for %s', + preset, device + ) + self._mapping = {} + return + + with open(path, 'r') as f: # from "key <12> { [ 1 ] };" extract 12 and 1, # avoid lines that start with special characters # (might be comments) @@ -71,23 +84,45 @@ class Mapping: } def change(self, previous_keycode, new_keycode, character): - """Change a mapping. Return True on success.""" + """Replace the mapping of a keycode with a different one. + + Return True on success. + + Parameters + ---------- + previous_keycode : int or None + new_keycode : int + character : string + """ + print('change', previous_keycode, new_keycode, character) if new_keycode and character and new_keycode != previous_keycode: self.add(new_keycode, character) - # clear previous mapping of that code, because the line - # representing that one will now represent a different one. - self.clear(previous_keycode) + if new_keycode != previous_keycode: + # clear previous mapping of that code, because the line + # representing that one will now represent a different one. + self.clear(previous_keycode) return True return False def clear(self, keycode): - """Remove a keycode from the mapping.""" + """Remove a keycode from the mapping. + + Parameters + ---------- + keycode : int + """ print('clear', id(self), keycode, self._mapping.get(keycode)) if self._mapping.get(keycode) is not None: del self._mapping[keycode] def add(self, keycode, character): - """Add a mapping.""" + """Add a mapping for a hardware key to a character. + + Parameters + ---------- + keycode : int + character : string + """ if self._mapping.get(keycode) is not None: raise KeyError( f'Keycode {keycode} is already mapped ' @@ -96,6 +131,12 @@ class Mapping: self._mapping[keycode] = character def get(self, keycode): + """Read the character that is mapped to this keycode. + + Parameters + ---------- + keycode : int + """ return self._mapping.get(keycode) @@ -114,7 +155,6 @@ def ensure_symlink(): def create_preset(device, name=None): """Create an empty preset and return the name.""" - existing_names = get_presets(device) if name is None: name = 'new preset' @@ -125,12 +165,16 @@ def create_preset(device, name=None): i += 1 name = f'{name} {i}' - os.mknod(get_home_path(device, name)) + path = get_home_path(device, name) + if not os.path.exists(path): + logger.info('Creating new file %s', path) + os.makedirs(os.path.dirname(path), exist_ok=True) + os.mknod(path) ensure_symlink() return name -def create_setxkbmap_config(device, preset, mappings): +def create_setxkbmap_config(device, preset, mapping): """Generate a config file for setxkbmap. The file is created in ~/.config/key-mapper// and, @@ -144,10 +188,10 @@ def create_setxkbmap_config(device, preset, mappings): ---------- device : string preset : string - mappings : list - List of (keycode, character) tuples + mapping : Mapping """ - if len(mappings) == 0: + if len(mapping) == 0: + print(mapping._mapping) logger.debug('Got empty mappings') return None @@ -167,7 +211,7 @@ def create_setxkbmap_config(device, preset, mappings): logger.info('Writing key mappings') with open(home_preset_path, 'w') as f: - contents = generate_symbols_file_content(device, preset, mappings) + contents = generate_symbols_file_content(device, preset, mapping) if contents is not None: f.write(contents) @@ -246,6 +290,7 @@ def generate_symbols_file_content(device, preset, mapping): preset : string mapping : Mapping """ + # TODO test this function system_default = 'us' # TODO get the system default # If the symbols file contains key codes that are not present in @@ -256,7 +301,7 @@ def generate_symbols_file_content(device, preset, mapping): keycodes = re.findall(r'<.+?>', f.read()) xkb_symbols = [] - for keycode, character in mapping.iter(): + for keycode, character in mapping: if f'<{keycode}>' not in keycodes: logger.error(f'Unknown keycode <{keycode}> for "{character}"') # continue, otherwise X would crash when loading diff --git a/keymapper/linux.py b/keymapper/linux.py index aaf08f15..0010cb48 100644 --- a/keymapper/linux.py +++ b/keymapper/linux.py @@ -22,7 +22,6 @@ """Device stuff that is independent from the display server.""" -import re import subprocess import evdev @@ -66,13 +65,11 @@ class KeycodeReader: pass def start_reading(self, device): - """Start a loop that keeps reading keycodes. + """Tell the evdev lib to start looking for keycodes. - This keeps the main loop running, however, it is blocking for the - function that calls this until stop_reading is called from somewhere - else. + If read is called without prior start_reading, no keycodes + will be available. """ - # start the next one logger.debug('Starting reading keycodes for %s', device) # Watch over each one of the potentially multiple devices per hardware diff --git a/keymapper/presets.py b/keymapper/presets.py index 81b14d6d..64977bb6 100644 --- a/keymapper/presets.py +++ b/keymapper/presets.py @@ -23,6 +23,7 @@ import os +import time import glob from keymapper.paths import CONFIG_PATH, get_home_path @@ -159,3 +160,6 @@ def rename_preset(device, old_preset_name, new_preset_name): get_home_path(device, old_preset_name), get_home_path(device, new_preset_name) ) + # set the modification date to now + now = time.time() + os.utime(get_home_path(device, new_preset_name), (now, now)) diff --git a/tests/test.py b/tests/test.py index 2a46c08c..af0adcae 100644 --- a/tests/test.py +++ b/tests/test.py @@ -25,13 +25,14 @@ import sys import unittest -# quickly fake some stuff before any other file gets imported and initialized +# quickly fake some stuff before any other file gets a chance to import +# the original version from keymapper import paths paths.SYMBOLS_PATH = '/tmp/key-mapper-test/symbols' paths.CONFIG_PATH = '/tmp/key-mapper-test/.config' -from keymapper import presets -presets.get_devices = lambda: ({ +from keymapper import linux +linux._devices = { 'device 1': { 'paths': [ '/dev/input/event10', @@ -48,10 +49,18 @@ presets.get_devices = lambda: ({ 'paths': ['/dev/input/event3'], 'names': ['device 2'] } -}) +} +linux.get_devices = lambda: linux._devices from keymapper.logger import update_verbosity +# some class function stubs. +# can be overwritten in tests as well at any time. +linux.KeycodeReader.start_reading = lambda *args: None +linux.KeycodeReader.read = lambda *args: None + +tmp = '/tmp/key-mapper-test' + if __name__ == "__main__": update_verbosity(True) diff --git a/tests/testcases/integration.py b/tests/testcases/integration.py index efde3ca0..160f36a1 100644 --- a/tests/testcases/integration.py +++ b/tests/testcases/integration.py @@ -20,6 +20,8 @@ import sys +import time +import os import unittest from unittest.mock import patch from importlib.util import spec_from_loader, module_from_spec @@ -30,6 +32,8 @@ import shutil gi.require_version('Gtk', '3.0') from gi.repository import Gtk +from test import tmp + def gtk_iteration(): """Iterate while events are pending.""" @@ -71,6 +75,8 @@ class Integration(unittest.TestCase): Gtk.main_quit = lambda: None def setUp(self): + if os.path.exists(tmp): + shutil.rmtree(tmp) self.window = launch() def tearDown(self): @@ -91,9 +97,43 @@ class Integration(unittest.TestCase): def get_active_text(self): return self.name - self.window.on_select_device(FakeDropdown('fakeDevice1')) - self.window.on_select_preset(FakeDropdown('fakePreset1')) - # TODO test meaningful stuff here + # created on start because the first device is selected and some empty + # preset prepared. + self.assertTrue(os.path.exists(f'{tmp}/symbols/device_1/new_preset')) + self.assertEqual(self.window.selected_device, 'device 1') + self.assertEqual(self.window.selected_preset, 'new preset') + + self.window.on_create_preset_clicked(None) + gtk_iteration() + # until save is clicked, this is still not saved + self.assertTrue(os.path.exists(f'{tmp}/symbols/device_1/new_preset')) + self.assertTrue(os.path.exists(f'{tmp}/symbols/device_1/new_preset_2')) + self.assertEqual(self.window.selected_preset, 'new preset 2') + + self.window.on_select_preset(FakeDropdown('new preset')) + gtk_iteration() + self.assertEqual(self.window.selected_preset, 'new preset') + + # now try to change the name + self.window.get('preset_name_input').set_text('abc 123') + gtk_iteration() + self.assertEqual(self.window.selected_preset, 'new preset') + self.assertFalse(os.path.exists(f'{tmp}/symbols/device_1/abc_123')) + self.window.on_save_preset_clicked(None) + gtk_iteration() + self.assertEqual(self.window.selected_preset, 'abc 123') + self.assertTrue(os.path.exists(f'{tmp}/symbols/device_1/abc_123')) + + # TODO test presence of file in {tmp}/keycodes + + self.assertListEqual( + sorted(os.listdir(f'{tmp}/symbols')), + ['device_1'] + ) + self.assertListEqual( + sorted(os.listdir(f'{tmp}/symbols/device_1')), + ['abc_123', 'new_preset', 'new_preset_2'] + ) if __name__ == "__main__": diff --git a/tests/testcases/presets.py b/tests/testcases/presets.py index e0eea145..953e29a7 100644 --- a/tests/testcases/presets.py +++ b/tests/testcases/presets.py @@ -27,8 +27,7 @@ import time from keymapper.presets import find_newest_preset, rename_preset from keymapper.X import create_preset - -tmp = '/tmp/key-mapper-test' +from test import tmp class TestCreatePreset(unittest.TestCase): diff --git a/tests/testcases/test.py b/tests/testcases/test.py new file mode 100644 index 00000000..f4209477 --- /dev/null +++ b/tests/testcases/test.py @@ -0,0 +1,33 @@ +#!/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 unittest + +from keymapper.linux import get_devices + + +class TestTest(unittest.TestCase): + def test_stubs(self): + self.assertIn('device 1', get_devices()) + + +if __name__ == "__main__": + unittest.main()