diff --git a/bin/key-mapper-gtk b/bin/key-mapper-gtk index a4e22c2a..f3933fc8 100755 --- a/bin/key-mapper-gtk +++ b/bin/key-mapper-gtk @@ -32,9 +32,54 @@ gi.require_version('GLib', '2.0') from gi.repository import Gtk from keymapper.data import get_data_path +from keymapper.profiles import find_devices, get_presets, get_mappings from keymapper.logger import logger, update_verbosity, log_info +class SingleKeyMapping: + """A single, configurable key mapping.""" + box = None + + def __init__(self, delete_callback): + """Construct a row and add it to the list in the GUI.""" + self.delete_callback = delete_callback + self.put_together() + + def get_widget(self): + """Return the widget that wraps all the widgets of the row.""" + return self.box + + def put_together(self): + """Create all GTK widgets.""" + + delete_button = Gtk.Button() + destroy_icon = Gtk.Image.new_from_icon_name( + 'window-close', Gtk.IconSize.BUTTON + ) + delete_button.set_image(destroy_icon) + delete_button.connect('clicked', self.on_delete_button_clicked) + + key_code = Gtk.Entry() + key_code.set_width_chars(4) + + original_key = Gtk.Entry() + original_key.set_width_chars(4) + + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + box.pack_start(delete_button, expand=False, fill=False, padding=0) + box.pack_start(key_code, expand=True, fill=True, padding=0) + box.pack_start(original_key, expand=True, fill=True, padding=0) + box.set_margin_start(10) + box.set_margin_end(10) + box.show_all() + self.box = box + + def on_delete_button_clicked(self, button): + """Destroy the row and remove it from the config.""" + self.box.destroy() + self.delete_callback(self) + + class Window: """User Interface.""" def __init__(self): @@ -48,6 +93,8 @@ class Window: window.show() self.window = window + self.populate_devices() + def get(self, name): """Get a widget from the window""" return self.builder.get_object(name) @@ -56,6 +103,38 @@ class Window: """Safely close the application.""" Gtk.main_quit() + def populate_devices(self): + """Make the devices selectable.""" + devices = find_devices() + device_selection = self.get('device_selection') + for (id, device) in devices: + device_selection.append(device, device) + + def populate_profiles(self): + """Show the available profiles for the selected device.""" + + + def on_add_key_clicked(self, button): + """Add a mapping to the list of mappings.""" + single_key_mapping = SingleKeyMapping(self.on_row_removed) + self.get('key_list').pack_start( + single_key_mapping.get_widget(), + expand=False, fill=False, padding=0 + ) + + def on_row_removed(self, mapping): + """Stuff to do when a row was removed + + Parameters + ---------- + mapping : SingleKeyMapping + """ + # 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) + if __name__ == '__main__': parser = ArgumentParser() diff --git a/data/key-mapper.glade b/data/key-mapper.glade index e670bce0..14c83932 100644 --- a/data/key-mapper.glade +++ b/data/key-mapper.glade @@ -11,43 +11,18 @@ False vertical - + True False 10 - vertical 10 - + True False - 10 - - - 50 - True - False - Device - 0 - - - False - True - 0 - - - - - 200 - True - False - - - True - True - 1 - - + Device + 10 + 0 False @@ -55,6 +30,43 @@ 0 + + + 400 + True + False + + + True + True + 1 + + + + + False + True + 0 + + + + + True + False + + + False + True + 1 + + + + + True + False + 10 + vertical + 10 True @@ -66,6 +78,7 @@ True False Profile + 10 0 @@ -86,6 +99,20 @@ 1 + + + Create + 80 + True + True + True + + + False + True + 2 + + False @@ -100,21 +127,33 @@ 10 - 50 True False - Mapping + Rename + 10 0 - True + False True 0 - - Add + + True + True + + + True + True + 1 + + + + + Save + 80 True True True @@ -122,7 +161,7 @@ False True - 1 + 2 @@ -136,7 +175,7 @@ False True - 0 + 2 @@ -147,14 +186,57 @@ False True - 1 + 3 + + + + + True + False + 10 + + + 50 + True + False + Mapping + 0 + + + True + True + 0 + + + + + Add + 80 + True + True + True + + + + False + True + 1 + + + + + False + True + 4 True False + 10 vertical + 10 @@ -162,7 +244,7 @@ True True - 2 + 5 diff --git a/keymapper/config.py b/keymapper/config.py index 03814e06..fae3a5fd 100644 --- a/keymapper/config.py +++ b/keymapper/config.py @@ -78,6 +78,8 @@ class Config: Parameters ---------- + device : string + preset : string path : string or None If none, will default to '~/.config/key-mapper/'. In that directory, a folder for the device and a file for diff --git a/keymapper/profiles.py b/keymapper/profiles.py new file mode 100644 index 00000000..dac798a9 --- /dev/null +++ b/keymapper/profiles.py @@ -0,0 +1,76 @@ +#!/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 . + + +"""Helperfunctions to find device ids, names, and to load configs.""" + + +import os +import subprocess +from keymapper.logger import logger + + +def get_xinput_list(type): + """Run xinput and get the result as list. + + Parameters + ---------- + type : string + Ine of 'id' or 'name' + """ + output = subprocess.check_output(['xinput', 'list', f'--{type}-only']) + return [line for line in output.decode().split('\n') if line != ''] + + +def find_devices(): + """Get a list of (id, name) for each input device.""" + # `xinput list` + ids = get_xinput_list('id') + names = get_xinput_list('name') + + # names contains duplicates and "Virtual"-somethings, filter those + known_names = [] + result = [] + for (id, name) in zip(ids, names): + if name not in known_names and not name.startswith('Virtual'): + known_names.append(name) + result.append((id, name)) + return result + + +def get_presets(device): + """Get all configured presets for the device. + + Parameters + ---------- + device : string + """ + pass + + +def get_mappings(device, preset): + """Get all configured buttons of the preset. + + Parameters + ---------- + device : string + preset : string + """ + pass diff --git a/keymapper/devices.py b/tests/fakes.py similarity index 57% rename from keymapper/devices.py rename to tests/fakes.py index 34632c4d..a0f63ae7 100644 --- a/keymapper/devices.py +++ b/tests/fakes.py @@ -19,23 +19,28 @@ # along with key-mapper. If not, see . -"""Helperfunctions to find device ids, names, and to load configs.""" +"""Patch stuff to get reproducible tests.""" -from keymapper.logger import logger +from unittest.mock import patch -def find_devices(): - """Get a list of (id, name) for each input device.""" - # `xinput list` - pass +fake_config_path = '/tmp/keymapper-test-config' -def get_presets(device): - """Get all configured presets for the device.""" - pass +class UseFakes: + """Provides fake functionality for alsaaudio and some services.""" + def __init__(self): + self.patches = [] + def patch(self): + """Replace the functions with various fakes.""" + # self.patches.append(patch.object(keymapper, 'foo', self.foo)) + for p in self.patches: + p.__enter__() -def get_mappings(device, preset): - """Get all configured buttons of the preset.""" - pass + def restore(self): + """Restore functionality.""" + for p in self.patches: + p.__exit__(None, None, None) + self.patches = [] diff --git a/tests/testcases/integration.py b/tests/testcases/integration.py new file mode 100644 index 00000000..80775f25 --- /dev/null +++ b/tests/testcases/integration.py @@ -0,0 +1,98 @@ +#!/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 . + + +import os +import sys +import unittest +from unittest.mock import patch +from importlib.util import spec_from_loader, module_from_spec +from importlib.machinery import SourceFileLoader + +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +from fakes import UseFakes, fake_config_path +from keymapper.config import get_config + + +def gtk_iteration(): + """Iterate while events are pending.""" + while Gtk.events_pending(): + Gtk.main_iteration() + + +def launch(argv=None, bin_path='bin/key-mapper-gtk'): + """Start alsacontrol-gtk with the command line argument array argv.""" + print('\nLaunching UI') + if not argv: + argv = ['-d'] + + with patch.object(sys, 'argv', [''] + [str(arg) for arg in argv]): + loader = SourceFileLoader('__main__', bin_path) + spec = spec_from_loader('__main__', loader) + module = module_from_spec(spec) + spec.loader.exec_module(module) + + gtk_iteration() + + return module.window + + +class Integration(unittest.TestCase): + """For tests that use the window. + + Try to modify the configuration and .asoundrc only by calling + functions of the window. + """ + @classmethod + def setUpClass(cls): + # iterate a few times when Gtk.main() is called, but don't block + # there and just continue to the tests while the UI becomes + # unresponsive + Gtk.main = gtk_iteration + + # doesn't do much except avoid some Gtk assertion error, whatever: + Gtk.main_quit = lambda: None + + def setUp(self): + self.fakes = UseFakes() + self.fakes.patch() + self.window = launch() + + def tearDown(self): + self.window.on_close() + self.window.window.destroy() + gtk_iteration() + self.fakes.restore() + if os.path.exists(fake_config_path): + os.remove(fake_config_path) + config = get_config() + config.create_config_file() + config.load_config() + + def test_can_start(self): + self.assertIsNotNone(self.window) + self.assertTrue(self.window.window.get_visible()) + + +if __name__ == "__main__": + unittest.main()