#!/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 sys import getpass 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 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 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 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() 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}"' ) 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( '-d', '--debug', action='store_true', dest='debug', help='Displays additional debug information', default=False ) options = parser.parse_args(sys.argv[1:]) update_verbosity(options.debug) log_info() window = Window() if getpass.getuser() != 'root' and 'unittest' not in sys.modules.keys(): # TODO add a polkit thing like # https://gitlab.manjaro.org/applications/pamac/-/tree/master/data/polkit logger.error('Needs to run with sudo') ErrorDialog( 'Error', 'Key Mapper needs administrator privileges to run properly.' ) Gtk.main()