input-remapper/bin/key-mapper-gtk
2020-11-08 21:12:59 +01:00

464 lines
15 KiB
Python
Executable File

#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
# Copyright (C) 2020 sezanzeb <proxima@hip70890b.de>
#
# 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 <https://www.gnu.org/licenses/>.
"""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
CTX_ERROR = 3
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(
'-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()