From a402732bc473472fb3f0488e00ed8464157363d7 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Mon, 9 Nov 2020 23:16:30 +0100 Subject: [PATCH] refactoring, restructuring --- bin/key-mapper-gtk | 413 +-------------------------------- keymapper/X.py | 112 +-------- keymapper/data.py | 2 +- keymapper/gtk/__init__.py | 0 keymapper/gtk/error.py | 50 ++++ keymapper/gtk/row.py | 173 ++++++++++++++ keymapper/gtk/window.py | 276 ++++++++++++++++++++++ keymapper/linux.py | 5 +- keymapper/mapping.py | 140 +++++++++++ keymapper/paths.py | 5 +- keymapper/presets.py | 7 +- keymapper/x11/__init__.py | 19 ++ tests/test.py | 2 +- tests/testcases/integration.py | 8 +- tests/testcases/mapping.py | 2 +- 15 files changed, 681 insertions(+), 533 deletions(-) mode change 100755 => 100644 bin/key-mapper-gtk create mode 100644 keymapper/gtk/__init__.py create mode 100644 keymapper/gtk/error.py create mode 100644 keymapper/gtk/row.py create mode 100755 keymapper/gtk/window.py create mode 100644 keymapper/mapping.py create mode 100644 keymapper/x11/__init__.py diff --git a/bin/key-mapper-gtk b/bin/key-mapper-gtk old mode 100755 new mode 100644 index c7c41ec9..13a40398 --- a/bin/key-mapper-gtk +++ b/bin/key-mapper-gtk @@ -19,8 +19,6 @@ # along with key-mapper. If not, see . -"""User Interface.""" - import sys import getpass @@ -29,418 +27,17 @@ from argparse import ArgumentParser import gi gi.require_version('Gtk', '3.0') gi.require_version('GLib', '2.0') -from gi.repository import Gtk, Gdk, GLib +from gi.repository import Gtk -from keymapper.data import get_data_path -from keymapper.X import create_setxkbmap_config, apply_preset, \ - 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 -from keymapper.linux import get_devices, KeycodeReader +from keymapper.gtk.window import Window +from keymapper.gtk.error import ErrorDialog +from keymapper.gtk.window import window window = None -def gtk_iteration(): - """Iterate while events are pending.""" - while Gtk.events_pending(): - Gtk.main_iteration() - - -keycode_reader = KeycodeReader() - - -CTX_SAVE = 0 -CTX_APPLY = 1 -CTX_KEYCODE = 2 -CTX_ERROR = 3 - - -# TODO test on wayland -# TODO reorganize classes and files - - -class SingleKeyMapping: - """A single, configurable key mapping.""" - def __init__(self, device, delete_callback, keycode=None, character=None): - """Construct a row widget.""" - self.widget = None - self.device = device - self.delete_callback = delete_callback - self.put_together(keycode, character) - - def get_widget(self): - """Return the widget that wraps all the widgets of the row.""" - return self.widget - - def get_keycode(self): - keycode = self.keycode.get_label() - return int(keycode) if keycode else None - - def get_character(self): - character = self.character_input.get_text() - return character if character else None - - def start_watching_keycodes(self, *args): - """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() - - def iterate(): - self.check_newest_keycode() - return self.keycode.is_focus() and window.window.is_active() - - 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() - - # no input - if new_keycode is None: - return - - # 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: - 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 - 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.""" - delete_button = Gtk.EventBox() - delete_button.add(Gtk.Image.new_from_icon_name( - 'window-close', - Gtk.IconSize.BUTTON - )) - delete_button.connect( - 'button-press-event', - self.on_delete_button_clicked - ) - delete_button.set_margin_start(5) - delete_button.set_margin_end(5) - - keycode_input = Gtk.ToggleButton() - if keycode is not None: - keycode_input.set_label(str(keycode)) - keycode_input.connect( - 'focus-in-event', - self.start_watching_keycodes - ) - # make the togglebutton go back to its normal state when doing - # something else in the UI - keycode_input.connect( - 'focus-out-event', - lambda *args: keycode_input.set_active(False) - ) - - character_input = Gtk.Entry() - character_input.set_alignment(0.5) - character_input.set_width_chars(4) - 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) - row.set_spacing(2) - row.pack_start(keycode_input, expand=True, fill=True, padding=0) - row.pack_start(character_input, expand=True, fill=True, padding=0) - row.pack_start(delete_button, expand=True, fill=False, padding=0) - - row.show_all() - # in order to get this object when iterating over the listbox - row.logic = self - - self.widget = row - self.character_input = character_input - self.keycode = keycode_input - - def on_delete_button_clicked(self, *args): - """Destroy the row and remove it from the config.""" - keycode = self.get_keycode() - if keycode is not None: - mapping.clear(keycode) - self.delete_callback(self) - - -class Window: - """User Interface.""" - def __init__(self): - 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) - builder.connect_signals(self) - self.builder = builder - - window = self.get('window') - window.show() - self.window = window - - self.populate_devices() - - self.select_newest_preset() - - GLib.timeout_add(100, self.check_add_row) - - def get(self, name): - """Get a widget from the window""" - return self.builder.get_object(name) - - def on_close(self, *_): - """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() - if device is not None: - self.get('device_selection').set_active_id(device) - if preset is not None: - self.get('device_selection').set_active_id(preset) - - def populate_devices(self): - """Make the devices selectable.""" - devices = get_devices() - device_selection = self.get('device_selection') - for device in devices: - device_selection.append(device, device) - - def populate_presets(self): - """Show the available presets for the selected device.""" - device = self.selected_device - presets = get_presets(device) - self.get('preset_name_input').set_text('') - if len(presets) == 0: - presets = [create_preset(device)] - else: - logger.debug('Presets for "%s": %s', device, ', '.join(presets)) - preset_selection = self.get('preset_selection') - - preset_selection.handler_block_by_func(self.on_select_preset) - # otherwise the handler is called with None for each removed preset - preset_selection.remove_all() - preset_selection.handler_unblock_by_func(self.on_select_preset) - - for preset in presets: - preset_selection.append(preset, preset) - # and select the newest one (on the top) - preset_selection.set_active(0) - - def clear_mapping_table(self): - """Remove all rows from the mappings table.""" - key_list = self.get('key_list') - key_list.forall(key_list.remove) - - 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() - try: - self.save_config() - if new_name != '' and new_name != self.selected_preset: - rename_preset( - self.selected_device, - self.selected_preset, - new_name - ) - # 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}"' - ) - except PermissionError as e: - window.get('status_bar').push( - CTX_ERROR, - 'Error: Permission denied!' - ) - logger.error(str(e)) - - def on_delete_preset_clicked(self, button): - """Delete a preset from the file system.""" - delete_preset(self.selected_device, self.selected_preset) - self.populate_presets() - - def on_apply_preset_clicked(self, button): - """Apply a preset without saving changes.""" - logger.debug( - 'Applying preset "%s" for "%s"', - self.selected_preset, - 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.""" - device = dropdown.get_active_text() - - logger.debug('Selecting device "%s"', device) - - self.selected_device = device - self.selected_preset = None - - self.populate_presets() - GLib.idle_add( - lambda: keycode_reader.start_reading(self.selected_device) - ) - - def on_create_preset_clicked(self, button): - """Create a new preset and select it.""" - 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.save_config() - - def on_select_preset(self, dropdown): - """Show the mappings of the preset.""" - self.clear_mapping_table() - - preset = dropdown.get_active_text() - logger.debug('Selecting preset "%s"', preset) - - self.selected_preset = preset - mapping.load( - self.selected_device, - self.selected_preset - ) - - key_list = self.get('key_list') - for keycode, character in mapping: - single_key_mapping = SingleKeyMapping( - device=self.selected_device, - delete_callback=self.on_row_removed, - keycode=keycode, - character=character - ) - key_list.insert(single_key_mapping.get_widget(), -1) - - self.add_empty() - - def add_empty(self): - empty = SingleKeyMapping( - device=self.selected_device, - delete_callback=self.on_row_removed - ) - key_list = self.get('key_list') - key_list.insert(empty.get_widget(), -1) - - def on_row_removed(self, single_key_mapping): - """Stuff to do when a row was removed - - Parameters - ---------- - single_key_mapping : SingleKeyMapping - """ - key_list = self.get('key_list') - # https://stackoverflow.com/a/30329591/4417769 - key_list.remove(single_key_mapping.get_widget().get_parent()) - # shrink the window down as much as possible, otherwise it - # will increase with each added mapping but won't go back when they - # are removed. - window = self.get('window') - window.resize(window.get_size()[0], 1) - - def save_config(self): - """Write changes to disk""" - 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 - ) - - create_setxkbmap_config( - self.selected_device, - self.selected_preset - ) - - -class ErrorDialog: - """An Error that closes the application afterwards.""" - def __init__(self, primary, secondary): - """ - Parameters - ---------- - primary : string - secondary : string - """ - gladefile = get_data_path('key-mapper.glade') - builder = Gtk.Builder() - builder.add_from_file(gladefile) - error_dialog = builder.get_object('error_dialog') - error_dialog.show() - builder.get_object('primary_error_label').set_text(primary) - builder.get_object('secondary_error_label').set_text(secondary) - error_dialog.run() - error_dialog.hide() - - if __name__ == '__main__': parser = ArgumentParser() parser.add_argument( @@ -465,4 +62,4 @@ if __name__ == '__main__': 'Key Mapper needs administrator privileges to run properly.' ) - Gtk.main() + Gtk.main() \ No newline at end of file diff --git a/keymapper/X.py b/keymapper/X.py index 70fd9e83..43b7b45c 100644 --- a/keymapper/X.py +++ b/keymapper/X.py @@ -42,115 +42,7 @@ from keymapper.paths import get_home_path, get_usr_path, KEYCODES_PATH, \ from keymapper.logger import logger from keymapper.data import get_data_path from keymapper.linux import get_devices - - -class Mapping: - """Contains and manages mappings. - - The keycode is always unique, multiple keycodes may map to the same - character. - """ - def __init__(self): - self._mapping = {} - self.changed = False - - def __iter__(self): - """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.""" - 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) - result = re.findall(r'\n\s+?key <(.+?)>.+?\[\s+(\w+)', f.read()) - logger.debug('Found %d mappings in this preset', len(result)) - self._mapping = { - int(keycode): character - for keycode, character - in result - } - - self.changed = False - - def change(self, previous_keycode, new_keycode, character): - """Replace the mapping of a keycode with a different one. - - Return True on success. - - Parameters - ---------- - previous_keycode : int or None - If None, will not remove any previous mapping. - new_keycode : int - character : string - """ - try: - new_keycode = int(new_keycode) - except ValueError: - logger.error('Cannot use %s as keycode', new_keycode) - return False - - if previous_keycode is not None: - try: - previous_keycode = int(previous_keycode) - except ValueError: - logger.error('Cannot use %s as keycode', previous_keycode) - return False - - if new_keycode and character: - self._mapping[new_keycode] = str(character) - 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) - self.changed = True - return True - - return False - - def clear(self, keycode): - """Remove a keycode from the mapping. - - Parameters - ---------- - keycode : int - """ - if self._mapping.get(keycode) is not None: - del self._mapping[keycode] - self.changed = True - - def empty(self): - """Remove all mappings.""" - self._mapping = {} - self.changed = True - - def get(self, keycode): - """Read the character that is mapped to this keycode. - - Parameters - ---------- - keycode : int - """ - return self._mapping.get(keycode) - - -# one mapping object for the whole application -mapping = Mapping() +from keymapper.mapping import mapping def ensure_symlink(): @@ -210,6 +102,7 @@ def create_setxkbmap_config(device, preset): device : string preset : string """ + print('create_setxkbmap_config', len(mapping), mapping._mapping) if len(mapping) == 0: logger.debug('Got empty mappings') return None @@ -237,6 +130,7 @@ def create_setxkbmap_config(device, preset): def apply_preset(device, preset): """Apply a preset to the device.""" + logger.info('Applying the preset') group = get_devices()[device] # apply it to every device that hangs on the same usb port, because I diff --git a/keymapper/data.py b/keymapper/data.py index f83be167..ed95b279 100644 --- a/keymapper/data.py +++ b/keymapper/data.py @@ -19,7 +19,7 @@ # along with key-mapper. If not, see . -"""Query settings.""" +"""Get stuff from /usr/share/key-mapper, depending on the prefix.""" import os diff --git a/keymapper/gtk/__init__.py b/keymapper/gtk/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/keymapper/gtk/error.py b/keymapper/gtk/error.py new file mode 100644 index 00000000..d3c473c1 --- /dev/null +++ b/keymapper/gtk/error.py @@ -0,0 +1,50 @@ +#!/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 . + + +"""Error dialog.""" + + +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('GLib', '2.0') +from gi.repository import Gtk + +from keymapper.data import get_data_path + + +class ErrorDialog: + """An Error that closes the application afterwards.""" + def __init__(self, primary, secondary): + """ + Parameters + ---------- + primary : string + secondary : string + """ + gladefile = get_data_path('key-mapper.glade') + builder = Gtk.Builder() + builder.add_from_file(gladefile) + error_dialog = builder.get_object('error_dialog') + error_dialog.show() + builder.get_object('primary_error_label').set_text(primary) + builder.get_object('secondary_error_label').set_text(secondary) + error_dialog.run() + error_dialog.hide() \ No newline at end of file diff --git a/keymapper/gtk/row.py b/keymapper/gtk/row.py new file mode 100644 index 00000000..1b96b64b --- /dev/null +++ b/keymapper/gtk/row.py @@ -0,0 +1,173 @@ +#!/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 . + + +"""A single, configurable key mapping.""" + + +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('GLib', '2.0') +from gi.repository import Gtk, GLib + +from keymapper.mapping import mapping +from keymapper.logger import logger +from keymapper.linux import keycode_reader + + +CTX_KEYCODE = 2 + + +class Row: + """A single, configurable key mapping.""" + def __init__(self, delete_callback, window, keycode=None, character=None): + """Construct a row widget.""" + self.widget = None + self.device = window.selected_device + self.window = window + self.delete_callback = delete_callback + self.put_together(keycode, character) + + def get_widget(self): + """Return the widget that wraps all the widgets of the row.""" + return self.widget + + def get_keycode(self): + keycode = self.keycode.get_label() + return int(keycode) if keycode else None + + def get_character(self): + character = self.character_input.get_text() + return character if character else None + + def start_watching_keycodes(self, *args): + """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() + + def iterate(): + self.check_newest_keycode() + return self.keycode.is_focus() and self.window.window.is_active() + + 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() + + # no input + if new_keycode is None: + return + + # 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: + msg = f'Keycode {new_keycode} is already mapped' + logger.info(msg) + self.window.get('status_bar').push(CTX_KEYCODE, msg) + return + + # it's legal to display the keycode + self.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 + 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.""" + delete_button = Gtk.EventBox() + delete_button.add(Gtk.Image.new_from_icon_name( + 'window-close', + Gtk.IconSize.BUTTON + )) + delete_button.connect( + 'button-press-event', + self.on_delete_button_clicked + ) + delete_button.set_margin_start(5) + delete_button.set_margin_end(5) + + keycode_input = Gtk.ToggleButton() + if keycode is not None: + keycode_input.set_label(str(keycode)) + keycode_input.connect( + 'focus-in-event', + self.start_watching_keycodes + ) + # make the togglebutton go back to its normal state when doing + # something else in the UI + keycode_input.connect( + 'focus-out-event', + lambda *args: keycode_input.set_active(False) + ) + + character_input = Gtk.Entry() + character_input.set_alignment(0.5) + character_input.set_width_chars(4) + 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) + row.set_spacing(2) + row.pack_start(keycode_input, expand=True, fill=True, padding=0) + row.pack_start(character_input, expand=True, fill=True, padding=0) + row.pack_start(delete_button, expand=True, fill=False, padding=0) + + row.show_all() + # in order to get this object when iterating over the listbox + row.logic = self + + self.widget = row + self.character_input = character_input + self.keycode = keycode_input + + def on_delete_button_clicked(self, *args): + """Destroy the row and remove it from the config.""" + keycode = self.get_keycode() + if keycode is not None: + mapping.clear(keycode) + self.delete_callback(self) diff --git a/keymapper/gtk/window.py b/keymapper/gtk/window.py new file mode 100755 index 00000000..f97907ff --- /dev/null +++ b/keymapper/gtk/window.py @@ -0,0 +1,276 @@ +#!/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 . + + +"""User Interface.""" + + +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('GLib', '2.0') +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 +from keymapper.presets import get_presets, find_newest_preset, \ + delete_preset, rename_preset +from keymapper.logger import logger +from keymapper.linux import get_devices, keycode_reader +from keymapper.gtk.row import Row + + +def gtk_iteration(): + """Iterate while events are pending.""" + while Gtk.events_pending(): + Gtk.main_iteration() + + +CTX_SAVE = 0 +CTX_APPLY = 1 +CTX_ERROR = 3 + + +# TODO test on wayland + + +class Window: + """User Interface.""" + def __init__(self): + 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) + builder.connect_signals(self) + self.builder = builder + + window = self.get('window') + window.show() + self.window = window + + self.populate_devices() + + self.select_newest_preset() + + GLib.timeout_add(100, self.check_add_row) + + def get(self, name): + """Get a widget from the window""" + return self.builder.get_object(name) + + def on_close(self, *_): + """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() + if device is not None: + self.get('device_selection').set_active_id(device) + if preset is not None: + self.get('device_selection').set_active_id(preset) + + def populate_devices(self): + """Make the devices selectable.""" + devices = get_devices() + device_selection = self.get('device_selection') + for device in devices: + device_selection.append(device, device) + + def populate_presets(self): + """Show the available presets for the selected device.""" + device = self.selected_device + presets = get_presets(device) + self.get('preset_name_input').set_text('') + if len(presets) == 0: + presets = [create_preset(device)] + else: + logger.debug('Presets for "%s": %s', device, ', '.join(presets)) + preset_selection = self.get('preset_selection') + + preset_selection.handler_block_by_func(self.on_select_preset) + # otherwise the handler is called with None for each removed preset + preset_selection.remove_all() + preset_selection.handler_unblock_by_func(self.on_select_preset) + + for preset in presets: + preset_selection.append(preset, preset) + # and select the newest one (on the top) + preset_selection.set_active(0) + + def clear_mapping_table(self): + """Remove all rows from the mappings table.""" + key_list = self.get('key_list') + key_list.forall(key_list.remove) + + 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() + try: + self.save_config() + if new_name != '' and new_name != self.selected_preset: + rename_preset( + self.selected_device, + self.selected_preset, + new_name + ) + # 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}"' + ) + except PermissionError as e: + window.get('status_bar').push( + CTX_ERROR, + 'Error: Permission denied!' + ) + logger.error(str(e)) + + def on_delete_preset_clicked(self, button): + """Delete a preset from the file system.""" + delete_preset(self.selected_device, self.selected_preset) + self.populate_presets() + + def on_apply_preset_clicked(self, button): + """Apply a preset without saving changes.""" + logger.debug( + 'Applying preset "%s" for "%s"', + self.selected_preset, + 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.""" + device = dropdown.get_active_text() + + logger.debug('Selecting device "%s"', device) + + self.selected_device = device + self.selected_preset = None + + self.populate_presets() + GLib.idle_add( + lambda: keycode_reader.start_reading(self.selected_device) + ) + + def on_create_preset_clicked(self, button): + """Create a new preset and select it.""" + 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.save_config() + + def on_select_preset(self, dropdown): + """Show the mappings of the preset.""" + self.clear_mapping_table() + + preset = dropdown.get_active_text() + logger.debug('Selecting preset "%s"', preset) + + self.selected_preset = preset + mapping.load( + self.selected_device, + self.selected_preset + ) + + key_list = self.get('key_list') + for keycode, character in mapping: + single_key_mapping = Row( + window=self, + delete_callback=self.on_row_removed, + keycode=keycode, + character=character + ) + key_list.insert(single_key_mapping.get_widget(), -1) + + self.add_empty() + + def add_empty(self): + empty = Row( + window=self, + delete_callback=self.on_row_removed + ) + key_list = self.get('key_list') + key_list.insert(empty.get_widget(), -1) + + def on_row_removed(self, single_key_mapping): + """Stuff to do when a row was removed + + Parameters + ---------- + single_key_mapping : Row + """ + key_list = self.get('key_list') + # https://stackoverflow.com/a/30329591/4417769 + key_list.remove(single_key_mapping.get_widget().get_parent()) + # shrink the window down as much as possible, otherwise it + # will increase with each added mapping but won't go back when they + # are removed. + window = self.get('window') + window.resize(window.get_size()[0], 1) + + def save_config(self): + """Write changes to disk""" + 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 + ) + + create_setxkbmap_config( + self.selected_device, + self.selected_preset + ) + + +window = Window() diff --git a/keymapper/linux.py b/keymapper/linux.py index 584abac5..6b81274a 100644 --- a/keymapper/linux.py +++ b/keymapper/linux.py @@ -19,7 +19,7 @@ # along with key-mapper. If not, see . -"""Device stuff that is independent from the display server.""" +"""Device and evdev stuff that is independent from the display server.""" import subprocess @@ -95,6 +95,9 @@ class KeycodeReader: return newest_keycode +keycode_reader = KeycodeReader() + + def get_devices(): """Group devices and get relevant infos per group. diff --git a/keymapper/mapping.py b/keymapper/mapping.py new file mode 100644 index 00000000..35f968f3 --- /dev/null +++ b/keymapper/mapping.py @@ -0,0 +1,140 @@ +#!/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 . + + +"""TODO""" + + +import os +import re + +from keymapper.paths import get_home_path +from keymapper.logger import logger + + +class Mapping: + """Contains and manages mappings. + + The keycode is always unique, multiple keycodes may map to the same + character. + """ + def __init__(self): + self._mapping = {} + self.changed = False + + def __iter__(self): + """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.""" + # TODO put that logic out of the mapper to make mapper display server + # agnostic + 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) + result = re.findall(r'\n\s+?key <(.+?)>.+?\[\s+(\w+)', f.read()) + logger.debug('Found %d mappings in this preset', len(result)) + self._mapping = { + int(keycode): character + for keycode, character + in result + } + + self.changed = False + + def change(self, previous_keycode, new_keycode, character): + """Replace the mapping of a keycode with a different one. + + Return True on success. + + Parameters + ---------- + previous_keycode : int or None + If None, will not remove any previous mapping. + new_keycode : int + character : string + """ + try: + new_keycode = int(new_keycode) + except ValueError: + logger.error('Cannot use %s as keycode', new_keycode) + return False + + if previous_keycode is not None: + try: + previous_keycode = int(previous_keycode) + except ValueError: + logger.error('Cannot use %s as keycode', previous_keycode) + return False + + if new_keycode and character: + self._mapping[new_keycode] = str(character) + 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) + self.changed = True + return True + + return False + + def clear(self, keycode): + """Remove a keycode from the mapping. + + Parameters + ---------- + keycode : int + """ + if self._mapping.get(keycode) is not None: + del self._mapping[keycode] + self.changed = True + + def empty(self): + """Remove all mappings.""" + self._mapping = {} + self.changed = True + + def get(self, keycode): + """Read the character that is mapped to this keycode. + + Parameters + ---------- + keycode : int + """ + return self._mapping.get(keycode) + + +# one mapping object for the whole application +mapping = Mapping() diff --git a/keymapper/paths.py b/keymapper/paths.py index 77f54bf7..ad617ac0 100644 --- a/keymapper/paths.py +++ b/keymapper/paths.py @@ -19,10 +19,7 @@ # along with key-mapper. If not, see . -"""Path constants to be used. - -Is a module so that tests can modify them. -""" +"""Path constants to be used.""" import os diff --git a/keymapper/presets.py b/keymapper/presets.py index c868fc07..2b97d876 100644 --- a/keymapper/presets.py +++ b/keymapper/presets.py @@ -19,15 +19,14 @@ # along with key-mapper. If not, see . -"""Helperfunctions to find device ids, names, and to load configs.""" +"""Helperfunctions to find device ids, names, and to load presets.""" import os import time import glob -from keymapper.paths import get_home_path, SYMBOLS_PATH, CONFIG_PATH, \ - KEYCODES_PATH +from keymapper.paths import get_home_path, CONFIG_PATH from keymapper.logger import logger from keymapper.linux import get_devices @@ -131,7 +130,7 @@ def delete_preset(device, preset): os.remove(preset_path) device_path = get_home_path(device) - if len(os.listdir(device_path)) == 0: + if os.path.exists(device_path) and len(os.listdir(device_path)) == 0: logger.debug('Removing empty dir "%s"', device_path) os.remove(device_path) diff --git a/keymapper/x11/__init__.py b/keymapper/x11/__init__.py new file mode 100644 index 00000000..8d26ae46 --- /dev/null +++ b/keymapper/x11/__init__.py @@ -0,0 +1,19 @@ +#!/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 . diff --git a/tests/test.py b/tests/test.py index d3de51f1..2b959ace 100644 --- a/tests/test.py +++ b/tests/test.py @@ -30,7 +30,7 @@ import unittest from keymapper import paths paths.SYMBOLS_PATH = '/tmp/key-mapper-test/symbols' paths.CONFIG_PATH = '/tmp/key-mapper-test/.config' -paths.KEYCODES_PATH = '/tmp/key-mapper-test/keycodes' +paths.KEYCODES_PATH = '/tmp/key-mapper-test/keycodes/key-mapper' from keymapper import linux diff --git a/tests/testcases/integration.py b/tests/testcases/integration.py index d86a78f1..130fbbb7 100644 --- a/tests/testcases/integration.py +++ b/tests/testcases/integration.py @@ -32,7 +32,7 @@ import shutil gi.require_version('Gtk', '3.0') from gi.repository import Gtk -from keymapper.X import mapping +from keymapper.mapping import mapping from test import tmp @@ -129,9 +129,9 @@ class Integration(unittest.TestCase): self.assertEqual(self.window.selected_device, 'device 1') self.assertEqual(self.window.selected_preset, 'new preset') + # create another one 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') @@ -150,13 +150,13 @@ class Integration(unittest.TestCase): gtk_iteration() self.assertEqual(self.window.selected_preset, 'new preset') self.assertFalse(os.path.exists(f'{tmp}/symbols/device_1/abc_123')) + mapping.change(None, 10, '1') self.window.on_save_preset_clicked(None) + self.assertTrue(os.path.exists(f'{tmp}/keycodes/key-mapper')) 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'] diff --git a/tests/testcases/mapping.py b/tests/testcases/mapping.py index ea0d31a5..8ec0678f 100644 --- a/tests/testcases/mapping.py +++ b/tests/testcases/mapping.py @@ -21,7 +21,7 @@ import unittest -from keymapper.X import Mapping +from keymapper.mapping import Mapping class TestMapping(unittest.TestCase):