You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
input-remapper/bin/key-mapper-gtk

393 lines
12 KiB
Python

#!/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
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
# TODO check for sudo rights
# TODO NUM1 doesnt work anymore
def gtk_iteration():
"""Iterate while events are pending."""
while Gtk.events_pending():
Gtk.main_iteration()
keycode_reader = KeycodeReader()
mapping = Mapping()
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()
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:
logger.info('Keycode %s is already mapped', new_keycode)
return
# it's legal to display the 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."""
mapping.clear()
self.delete_callback(self)
class Window:
"""User Interface."""
def __init__(self):
self.selected_device = None
self.selected_preset = None
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()
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
)
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 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()
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()
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)
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,
mapping
)
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()
Gtk.main()