input-remapper/bin/key-mapper-gtk

423 lines
13 KiB
Plaintext
Raw Normal View History

2020-10-26 22:45:22 +00:00
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
2020-10-26 22:45:22 +00:00
# Copyright (C) 2020 sezanzeb <proxima@hip70890b.de>
#
2020-10-31 13:02:59 +00:00
# This file is part of key-mapper.
2020-10-26 22:45:22 +00:00
#
2020-10-31 13:02:59 +00:00
# key-mapper is free software: you can redistribute it and/or modify
2020-10-26 22:45:22 +00:00
# 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.
#
2020-10-31 13:02:59 +00:00
# key-mapper is distributed in the hope that it will be useful,
2020-10-26 22:45:22 +00:00
# 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
2020-10-31 13:02:59 +00:00
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
2020-10-26 22:45:22 +00:00
"""User Interface."""
import sys
from argparse import ArgumentParser
import gi
gi.require_version('Gtk', '3.0')
gi.require_version('GLib', '2.0')
2020-11-05 00:24:56 +00:00
from gi.repository import Gtk, Gdk, GLib
2020-10-26 22:45:22 +00:00
2020-10-31 14:03:31 +00:00
from keymapper.data import get_data_path
from keymapper.X import create_setxkbmap_config, apply_preset, \
2020-11-08 17:51:35 +00:00
create_preset, mapping
2020-11-02 22:52:58 +00:00
from keymapper.presets import get_presets, find_newest_preset, \
delete_preset, rename_preset
from keymapper.logger import logger, update_verbosity, log_info
2020-11-05 00:24:56 +00:00
from keymapper.linux import get_devices, KeycodeReader
2020-10-26 22:45:22 +00:00
2020-10-31 16:43:21 +00:00
window = None
2020-11-03 22:41:05 +00:00
# TODO check for sudo rights
2020-11-05 00:24:56 +00:00
def gtk_iteration():
"""Iterate while events are pending."""
while Gtk.events_pending():
Gtk.main_iteration()
keycode_reader = KeycodeReader()
2020-11-08 17:51:35 +00:00
CTX_SAVE = 0
CTX_APPLY = 1
CTX_KEYCODE = 2
2020-10-31 16:00:02 +00:00
class SingleKeyMapping:
"""A single, configurable key mapping."""
def __init__(self, device, delete_callback, keycode=None, character=None):
"""Construct a row widget."""
self.widget = None
2020-11-05 00:24:56 +00:00
self.device = device
2020-10-31 16:00:02 +00:00
self.delete_callback = delete_callback
self.put_together(keycode, character)
2020-10-31 16:00:02 +00:00
def get_widget(self):
2020-10-31 16:00:02 +00:00
"""Return the widget that wraps all the widgets of the row."""
return self.widget
2020-10-31 16:00:02 +00:00
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):
2020-11-07 23:54:19 +00:00
"""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()
2020-11-07 23:54:19 +00:00
def iterate():
self.check_newest_keycode()
2020-11-08 18:50:30 +00:00
return self.keycode.is_focus() and window.window.is_active()
2020-11-07 23:54:19 +00:00
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()
2020-11-07 23:54:19 +00:00
# no input
if new_keycode is None:
return
2020-11-07 23:54:19 +00:00
# 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:
2020-11-08 17:51:35 +00:00
msg = f'Keycode {new_keycode} is already mapped'
logger.info(msg)
window.get('status_bar').push(CTX_KEYCODE, msg)
2020-11-07 23:54:19 +00:00
return
# it's legal to display the keycode
2020-11-08 17:51:35 +00:00
window.get('status_bar').remove_all(CTX_KEYCODE)
2020-11-07 23:54:19 +00:00
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):
2020-10-31 16:00:02 +00:00
"""Create all GTK widgets."""
2020-11-03 22:41:05 +00:00
delete_button = Gtk.EventBox()
delete_button.add(Gtk.Image.new_from_icon_name(
2020-11-05 00:24:56 +00:00
'window-close',
Gtk.IconSize.BUTTON
2020-11-03 22:41:05 +00:00
))
delete_button.connect(
'button-press-event',
self.on_delete_button_clicked
)
2020-11-03 22:41:05 +00:00
delete_button.set_margin_start(5)
delete_button.set_margin_end(5)
2020-10-31 16:00:02 +00:00
keycode_input = Gtk.ToggleButton()
if keycode is not None:
keycode_input.set_label(str(keycode))
keycode_input.connect(
2020-11-05 00:24:56 +00:00
'focus-in-event',
self.start_watching_keycodes
2020-11-05 00:24:56 +00:00
)
# make the togglebutton go back to its normal state when doing
# something else in the UI
keycode_input.connect(
2020-11-05 00:24:56 +00:00
'focus-out-event',
lambda *args: keycode_input.set_active(False)
2020-11-05 00:24:56 +00:00
)
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)
2020-11-07 23:54:19 +00:00
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)
2020-10-31 16:00:02 +00:00
row.show_all()
# in order to get this object when iterating over the listbox
row.logic = self
2020-10-31 16:00:02 +00:00
self.widget = row
self.character_input = character_input
self.keycode = keycode_input
2020-10-31 16:00:02 +00:00
2020-11-03 22:41:05 +00:00
def on_delete_button_clicked(self, *args):
2020-10-31 16:00:02 +00:00
"""Destroy the row and remove it from the config."""
2020-11-08 17:51:35 +00:00
keycode = self.get_keycode()
if keycode is not None:
mapping.clear(keycode)
2020-10-31 16:00:02 +00:00
self.delete_callback(self)
2020-10-26 22:45:22 +00:00
class Window:
"""User Interface."""
def __init__(self):
self.selected_device = None
self.selected_preset = None
2020-11-05 00:24:56 +00:00
2020-11-08 17:51:35 +00:00
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
)
2020-11-02 21:06:05 +00:00
gladefile = get_data_path('key-mapper.glade')
2020-10-26 22:45:22 +00:00
builder = Gtk.Builder()
builder.add_from_file(gladefile)
builder.connect_signals(self)
self.builder = builder
2020-11-03 22:41:05 +00:00
window = self.get('window')
2020-10-26 22:45:22 +00:00
window.show()
self.window = window
2020-10-31 16:00:02 +00:00
self.populate_devices()
2020-10-31 17:48:03 +00:00
2020-11-02 22:52:58 +00:00
self.select_newest_preset()
2020-10-31 16:00:02 +00:00
2020-11-08 17:51:35 +00:00
GLib.timeout_add(100, self.check_add_row)
2020-11-03 22:41:05 +00:00
def get(self, name):
"""Get a widget from the window"""
return self.builder.get_object(name)
2020-10-31 14:03:31 +00:00
def on_close(self, *_):
"""Safely close the application."""
Gtk.main_quit()
2020-11-08 17:51:35 +00:00
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
2020-11-02 22:52:58 +00:00
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)
2020-10-31 16:00:02 +00:00
def populate_devices(self):
"""Make the devices selectable."""
2020-11-02 18:47:31 +00:00
devices = get_devices()
2020-10-31 16:00:02 +00:00
device_selection = self.get('device_selection')
2020-11-01 19:19:50 +00:00
for device in devices:
2020-10-31 16:00:02 +00:00
device_selection.append(device, device)
def populate_presets(self):
2020-10-31 17:48:03 +00:00
"""Show the available presets for the selected device."""
device = self.selected_device
presets = get_presets(device)
2020-11-02 22:52:58 +00:00
self.get('preset_name_input').set_text('')
if len(presets) == 0:
2020-11-02 22:52:58 +00:00
presets = [create_preset(device)]
else:
logger.debug('Presets for "%s": %s', device, ', '.join(presets))
2020-10-31 17:48:03 +00:00
preset_selection = self.get('preset_selection')
2020-11-02 19:55:17 +00:00
preset_selection.handler_block_by_func(self.on_select_preset)
# otherwise the handler is called with None for each removed preset
2020-10-31 19:19:46 +00:00
preset_selection.remove_all()
2020-11-02 19:55:17 +00:00
preset_selection.handler_unblock_by_func(self.on_select_preset)
2020-10-31 17:48:03 +00:00
for preset in presets:
preset_selection.append(preset, preset)
2020-10-31 19:19:46 +00:00
# and select the newest one (on the top)
preset_selection.set_active(0)
2020-11-02 23:43:43 +00:00
def clear_mapping_table(self):
"""Remove all rows from the mappings table."""
key_list = self.get('key_list')
key_list.forall(key_list.remove)
2020-11-02 23:43:43 +00:00
2020-11-02 22:52:58 +00:00
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()
2020-11-08 17:51:35 +00:00
self.save_config()
2020-11-02 22:52:58 +00:00
if new_name != '' and new_name != self.selected_preset:
rename_preset(self.selected_device, self.selected_preset, new_name)
2020-11-08 17:51:35 +00:00
# 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}"'
)
2020-11-02 22:52:58 +00:00
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()
2020-11-02 20:57:28 +00:00
def on_apply_preset_clicked(self, button):
2020-11-02 22:52:58 +00:00
"""Apply a preset without saving changes."""
2020-11-02 20:57:28 +00:00
logger.debug(
'Applying preset "%s" for "%s"',
self.selected_preset,
self.selected_device
)
apply_preset(self.selected_device, self.selected_preset)
2020-11-08 17:51:35 +00:00
self.get('status_bar').push(
CTX_APPLY,
f'Applied "{self.selected_preset}"'
)
2020-11-02 20:57:28 +00:00
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)
2020-10-31 17:48:03 +00:00
self.selected_device = device
self.selected_preset = None
self.populate_presets()
2020-11-05 00:24:56 +00:00
GLib.idle_add(
lambda: keycode_reader.start_reading(self.selected_device)
2020-11-05 00:24:56 +00:00
)
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)
2020-10-31 18:12:27 +00:00
self.get('preset_selection').set_active_id(new_preset)
2020-11-07 23:54:19 +00:00
self.save_config()
2020-10-31 17:48:03 +00:00
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)
2020-10-31 16:00:02 +00:00
self.selected_preset = preset
mapping.load(
self.selected_device,
self.selected_preset
)
2020-10-31 16:43:21 +00:00
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):
2020-11-05 00:24:56 +00:00
empty = SingleKeyMapping(
device=self.selected_device,
delete_callback=self.on_row_removed
2020-11-05 00:24:56 +00:00
)
key_list = self.get('key_list')
key_list.insert(empty.get_widget(), -1)
2020-10-31 16:00:02 +00:00
def on_row_removed(self, single_key_mapping):
2020-10-31 16:00:02 +00:00
"""Stuff to do when a row was removed
Parameters
----------
single_key_mapping : SingleKeyMapping
2020-10-31 16:00:02 +00:00
"""
key_list = self.get('key_list')
# https://stackoverflow.com/a/30329591/4417769
key_list.remove(single_key_mapping.get_widget().get_parent())
2020-10-31 16:00:02 +00:00
# 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)
2020-10-26 22:45:22 +00:00
2020-11-07 23:54:19 +00:00
def save_config(self):
2020-11-02 22:52:58 +00:00
"""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,
2020-11-08 18:50:30 +00:00
self.selected_preset
)
2020-10-31 17:48:03 +00:00
2020-10-26 22:45:22 +00:00
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()
2020-10-31 16:43:21 +00:00
window = Window()
2020-10-26 22:45:22 +00:00
Gtk.main()