refactoring, restructuring

xkb
sezanzeb 4 years ago committed by sezanzeb
parent f15f599b5c
commit a402732bc4

@ -19,8 +19,6 @@
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""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(

@ -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

@ -19,7 +19,7 @@
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""Query settings."""
"""Get stuff from /usr/share/key-mapper, depending on the prefix."""
import os

@ -0,0 +1,50 @@
#!/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/>.
"""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()

@ -0,0 +1,173 @@
#!/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/>.
"""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)

@ -0,0 +1,276 @@
#!/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 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()

@ -19,7 +19,7 @@
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""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.

@ -0,0 +1,140 @@
#!/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/>.
"""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()

@ -19,10 +19,7 @@
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""Path constants to be used.
Is a module so that tests can modify them.
"""
"""Path constants to be used."""
import os

@ -19,15 +19,14 @@
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""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)

@ -0,0 +1,19 @@
#!/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/>.

@ -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

@ -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']

@ -21,7 +21,7 @@
import unittest
from keymapper.X import Mapping
from keymapper.mapping import Mapping
class TestMapping(unittest.TestCase):

Loading…
Cancel
Save