diff --git a/README.md b/README.md index 6f7f1207..9e4b2ea5 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,8 @@ No idea which one are relevant at the moment # Tests -sudo is required because some tests actually read /dev stuff. - ```bash -sudo python3 setup.py install && sudo python3 tests/test.py +sudo python3 setup.py install && python3 tests/test.py ``` # Roadmap diff --git a/bin/key-mapper-gtk b/bin/key-mapper-gtk index 810dfa88..700ca0a7 100755 --- a/bin/key-mapper-gtk +++ b/bin/key-mapper-gtk @@ -32,7 +32,7 @@ from gi.repository import Gtk, Gdk, GLib from keymapper.data import get_data_path from keymapper.X import create_setxkbmap_config, apply_preset, \ - create_preset, Mapping + create_preset, mapping from keymapper.presets import get_presets, find_newest_preset, \ delete_preset, rename_preset from keymapper.logger import logger, update_verbosity, log_info @@ -43,7 +43,6 @@ window = None # TODO check for sudo rights -# TODO NUM1 doesnt work anymore def gtk_iteration(): @@ -54,7 +53,10 @@ def gtk_iteration(): keycode_reader = KeycodeReader() -mapping = Mapping() + +CTX_SAVE = 0 +CTX_APPLY = 1 +CTX_KEYCODE = 2 class SingleKeyMapping: @@ -109,10 +111,13 @@ class SingleKeyMapping: # 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) + msg = f'Keycode {new_keycode} is already mapped' + logger.info(msg) + window.get('status_bar').push(CTX_KEYCODE, msg) return # it's legal to display the keycode + window.get('status_bar').remove_all(CTX_KEYCODE) self.keycode.set_label(str(new_keycode)) # the character is empty and therefore the mapping is not complete @@ -185,7 +190,9 @@ class SingleKeyMapping: def on_delete_button_clicked(self, *args): """Destroy the row and remove it from the config.""" - mapping.clear() + keycode = self.get_keycode() + if keycode is not None: + mapping.clear(keycode) self.delete_callback(self) @@ -195,6 +202,14 @@ class Window: self.selected_device = None self.selected_preset = None + css_provider = Gtk.CssProvider() + css_provider.load_from_path(get_data_path('style.css')) + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + gladefile = get_data_path('key-mapper.glade') builder = Gtk.Builder() builder.add_from_file(gladefile) @@ -209,13 +224,7 @@ class Window: self.select_newest_preset() - css_provider = Gtk.CssProvider() - css_provider.load_from_path(get_data_path('style.css')) - Gtk.StyleContext.add_provider_for_screen( - Gdk.Screen.get_default(), - css_provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - ) + GLib.timeout_add(100, self.check_add_row) def get(self, name): """Get a widget from the window""" @@ -225,6 +234,18 @@ class Window: """Safely close the application.""" Gtk.main_quit() + def check_add_row(self): + """Ensure that one empty row is available at all times.""" + rows = len(self.get('key_list').get_children()) + + # verify that all mappings are displayed + assert rows >= len(mapping) + + if rows == len(mapping): + self.add_empty() + + return True + def select_newest_preset(self): """Find and select the newest preset.""" device, preset = find_newest_preset() @@ -269,10 +290,16 @@ class Window: def on_save_preset_clicked(self, button): """Save changes to a preset to the file system.""" new_name = self.get('preset_name_input').get_text() + self.save_config() if new_name != '' and new_name != self.selected_preset: rename_preset(self.selected_device, self.selected_preset, new_name) - self.populate_presets() - self.save_config() + # after saving the config, its modification date will be the newest, + # so populate_presets will automatically select the right one again. + self.populate_presets() + self.get('status_bar').push( + CTX_SAVE, + f'Saved "{self.selected_preset}"' + ) def on_delete_preset_clicked(self, button): """Delete a preset from the file system.""" @@ -287,6 +314,10 @@ class Window: self.selected_device ) apply_preset(self.selected_device, self.selected_preset) + self.get('status_bar').push( + CTX_APPLY, + f'Applied "{self.selected_preset}"' + ) def on_select_device(self, dropdown): """List all presets, create one if none exist yet.""" @@ -369,6 +400,9 @@ class Window: self.selected_preset ) + print('lkjahsdfkjashdkj') + print(list(mapping)) + create_setxkbmap_config( self.selected_device, self.selected_preset, diff --git a/data/key-mapper.glade b/data/key-mapper.glade index dda82022..c44c9b87 100644 --- a/data/key-mapper.glade +++ b/data/key-mapper.glade @@ -87,6 +87,7 @@ True True True + In order to apply any changes, save the preset first. True @@ -230,11 +231,41 @@ - False + True True 2 + + + True + False + + + False + True + 3 + + + + + True + False + 10 + 10 + 10 + 10 + 6 + 6 + vertical + 2 + + + False + True + 4 + + False @@ -267,6 +298,7 @@ True False + Click on a cell below and hit a key on your device 5 5 Key @@ -282,6 +314,11 @@ True False + a-z, A-Z, 0-9 +KP_0 - KP_9 +Shift_L, Shift_R +Alt_L, Alt_R +LCTL, RCTL 5 5 Mapping diff --git a/data/screenshot.png b/data/screenshot.png index 3d938488..28634a8c 100644 Binary files a/data/screenshot.png and b/data/screenshot.png differ diff --git a/data/screenshot.png~ b/data/screenshot.png~ new file mode 100644 index 00000000..b150f2cb Binary files /dev/null and b/data/screenshot.png~ differ diff --git a/data/style.css b/data/style.css index e3a29144..e8947905 100644 --- a/data/style.css +++ b/data/style.css @@ -1,5 +1,15 @@ list entry { background-color: transparent; + border-radius: 4px; +} + +list button:not(:focus) { + border-color: transparent; + background-color: transparent; +} + +list button { + border-color: transparent; } .button_container { diff --git a/keymapper/X.py b/keymapper/X.py index bee28b9e..6c279e64 100644 --- a/keymapper/X.py +++ b/keymapper/X.py @@ -50,6 +50,7 @@ class Mapping: """ def __init__(self): self._mapping = {} + self.changed = False def __iter__(self): """Iterate over tuples of unique keycodes and their character.""" @@ -82,6 +83,8 @@ class Mapping: in result } + self.changed = False + def change(self, previous_keycode, new_keycode, character): """Replace the mapping of a keycode with a different one. @@ -100,8 +103,8 @@ class Mapping: # 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 + self.changed = True + return self.changed def clear(self, keycode): """Remove a keycode from the mapping. @@ -112,6 +115,7 @@ class Mapping: """ if self._mapping.get(keycode) is not None: del self._mapping[keycode] + self.changed = True def get(self, keycode): """Read the character that is mapped to this keycode. @@ -123,6 +127,10 @@ class Mapping: return self._mapping.get(keycode) +# one mapping object for the whole application +mapping = Mapping() + + def ensure_symlink(): """Make sure the symlink exists. @@ -276,7 +284,6 @@ def generate_symbols_content(device, preset, mapping): preset : string mapping : Mapping """ - # TODO test this function system_default = 'us' # TODO get the system default if len(mapping) == 0: diff --git a/keymapper/linux.py b/keymapper/linux.py index 0010cb48..584abac5 100644 --- a/keymapper/linux.py +++ b/keymapper/linux.py @@ -120,7 +120,7 @@ def get_devices(): for device in devices: # only keyboard devices # https://www.kernel.org/doc/html/latest/input/event-codes.html - if not evdev.ecodes.EV_KEY in device.capabilities().keys(): + if evdev.ecodes.EV_KEY not in device.capabilities().keys(): continue usb = device.phys.split('/')[0] diff --git a/tests/testcases/config.py b/tests/testcases/config.py index 8bfc1f5d..af9e743a 100644 --- a/tests/testcases/config.py +++ b/tests/testcases/config.py @@ -34,7 +34,7 @@ class TestConfig(unittest.TestCase): def setUp(self): self.mapping = Mapping() self.mapping.change(None, 10, 'a') - self.mapping.change(None, 11, 'NUM1') + self.mapping.change(None, 11, 'KP_1') self.mapping.change(None, 12, 3) if os.path.exists(tmp): shutil.rmtree(tmp) @@ -59,7 +59,7 @@ class TestConfig(unittest.TestCase): with open(get_home_path('device_a', 'preset_b'), 'r') as f: content = f.read() self.assertIn('key <10> { [ a ] };', content) - self.assertIn('key <11> { [ NUM1 ] };', content) + self.assertIn('key <11> { [ KP_1 ] };', content) self.assertIn('key <12> { [ 3 ] };', content) def test_generate_content(self): @@ -80,7 +80,7 @@ class TestConfig(unittest.TestCase): content = generate_symbols_content('device', 'preset', self.mapping) self.assertIn('key <10> { [ a ] };', content) - self.assertIn('key <11> { [ NUM1 ] };', content) + self.assertIn('key <11> { [ KP_1 ] };', content) self.assertIn('key <12> { [ 3 ] };', content) diff --git a/tests/testcases/integration.py b/tests/testcases/integration.py index aab1b993..ed89f592 100644 --- a/tests/testcases/integration.py +++ b/tests/testcases/integration.py @@ -32,6 +32,8 @@ import shutil gi.require_version('Gtk', '3.0') from gi.repository import Gtk +from keymapper.X import mapping + from test import tmp @@ -89,6 +91,31 @@ class Integration(unittest.TestCase): self.assertIsNotNone(self.window) self.assertTrue(self.window.window.get_visible()) + def test_adds_empty_rows(self): + rows = len(self.window.get('key_list').get_children()) + self.assertEqual(rows, 1) + + mapping.change(None, 13, 'a') + time.sleep(0.2) + gtk_iteration() + + rows = len(self.window.get('key_list').get_children()) + self.assertEqual(rows, 2) + + def test_rename_and_save(self): + mapping.change(None, 14, 'a') + self.assertEqual(self.window.selected_preset, 'new preset') + self.window.on_save_preset_clicked(None) + self.assertEqual(mapping.get(14), 'a') + + mapping.change(None, 14, 'b') + self.window.get('preset_name_input').set_text('asdf') + self.window.on_save_preset_clicked(None) + self.assertEqual(self.window.selected_preset, 'asdf') + self.assertTrue(os.path.exists(f'{tmp}/symbols/device_1/asdf')) + self.assertTrue(os.path.exists(f'{tmp}/.config/device_1/asdf')) + self.assertEqual(mapping.get(14), 'b') + def test_select_device_and_preset(self): class FakeDropdown(Gtk.ComboBoxText): def __init__(self, name): diff --git a/tests/testcases/mapping.py b/tests/testcases/mapping.py index 308074d0..40de4a6d 100644 --- a/tests/testcases/mapping.py +++ b/tests/testcases/mapping.py @@ -27,10 +27,12 @@ from keymapper.X import Mapping class TestMapping(unittest.TestCase): def setUp(self): self.mapping = Mapping() + self.assertFalse(self.mapping.changed) def test_change(self): # 1 is not assigned yet, ignore it self.mapping.change(1, 2, 'a') + self.assertTrue(self.mapping.changed) self.assertIsNone(self.mapping.get(1)) self.assertEqual(self.mapping.get(2), 'a') self.assertEqual(len(self.mapping), 1) @@ -58,13 +60,22 @@ class TestMapping(unittest.TestCase): self.assertEqual(len(self.mapping), 2) def test_clear(self): - self.mapping.change(None, 10, 'NUM1') - self.mapping.change(None, 20, 'NUM2') - self.mapping.change(None, 30, 'NUM3') + # does nothing + self.mapping.clear(40) + self.assertFalse(self.mapping.changed) + + self.mapping._mapping[40] = 'b' + self.mapping.clear(40) + self.assertTrue(self.mapping.changed) + + self.mapping.change(None, 10, 'KP_1') + self.assertTrue(self.mapping.changed) + self.mapping.change(None, 20, 'KP_2') + self.mapping.change(None, 30, 'KP_3') self.mapping.clear(20) - self.assertEqual(self.mapping.get(10), 'NUM1') + self.assertEqual(self.mapping.get(10), 'KP_1') self.assertIsNone(self.mapping.get(20)) - self.assertEqual(self.mapping.get(30), 'NUM3') + self.assertEqual(self.mapping.get(30), 'KP_3') def test_iterate_and_convert(self): self.mapping.change(None, 10, 1)