From 847d8fcf3a050d21aeec24c00fd1b7c01ce80e10 Mon Sep 17 00:00:00 2001 From: Jente Hidskes Date: Wed, 19 Jul 2017 12:09:06 +0200 Subject: [PATCH] ButtonDialog: add key mapping stack page ratbag's key mapping signature is forcing us to treat modifiers different from regular keys. To detect them, we need several workarounds over Gdk.EventKey which makes the code uglier than it could be. Perhaps we should just skip this altogether and implement macros only; since they don't differentiate between modifiers and regular keys all these workaround can then be removed while providing the same functionality. --- data/enter-keyboard-shortcut.svg | 245 +++++++++++++++++++++++++++++++ data/piper.gresource.xml | 1 + data/ui/ButtonDialog.ui | 226 +++++++++++++++++++++------- piper/buttondialog.py | 138 ++++++++++++++++- piper/buttonspage.py | 2 + piper/keystroke.py | 172 ++++++++++++++++++++++ 6 files changed, 734 insertions(+), 50 deletions(-) create mode 100644 data/enter-keyboard-shortcut.svg create mode 100644 piper/keystroke.py diff --git a/data/enter-keyboard-shortcut.svg b/data/enter-keyboard-shortcut.svg new file mode 100644 index 0000000..b7ce2e4 --- /dev/null +++ b/data/enter-keyboard-shortcut.svg @@ -0,0 +1,245 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/data/piper.gresource.xml b/data/piper.gresource.xml index d64585e..5ae7f2a 100644 --- a/data/piper.gresource.xml +++ b/data/piper.gresource.xml @@ -2,6 +2,7 @@ 404.svg + enter-keyboard-shortcut.svg AboutDialog.ui ui/ButtonDialog.ui diff --git a/data/ui/ButtonDialog.ui b/data/ui/ButtonDialog.ui index 7c3aabd..f830185 100644 --- a/data/ui/ButtonDialog.ui +++ b/data/ui/ButtonDialog.ui @@ -41,77 +41,205 @@ False slide-left-right - + False - True - vertical - 6 - - - True - False - Button- and key mappings allow you to assign to a physical mouse button another -logical mouse button or a sequence of keypresses. This way you may for example -make the right mouse button perform a left click, or execute the keystroke Ctrl+R with -a click on the right mouse button. - center - True - False - - - True - True - 0 - - True False - - - True - False - Assign another logical mouse button to this button - end - True - - - - False - - - - - False - True - end - 0 - - + True + vertical + 6 True False - start - Assign a button mapping: + Button- and key mappings allow you to assign to a physical mouse button another +logical mouse button or a sequence of keypresses. This way you may for example +make the right mouse button perform a left click, or execute the keystroke Ctrl+R with +a click on the right mouse button. + center + True False + + True + True + 0 + + + + + True + False + + + True + False + Assign another logical mouse button to this button + end + True + + + + False + + + + + False + True + end + 0 + + + + + True + False + start + Assign a button mapping: + False + + + False + True + 1 + + + False True 1 + + + True + False + + + True + True + True + Assign a keystroke to this button + end + none + + + + True + False + end + True + None + + + + + False + True + end + 0 + + + + + True + False + start + Assign a key stroke: + False + + + False + True + 1 + + + + + False + True + 2 + + - False - True - 1 + overview - + + True + False + vertical + 18 + + + True + False + Enter a new key sequence for the mapping, starting with zero or more modifiers and finishing with one regular key. + center + True + word-char + 15 + 20 + False + + + False + True + 0 + + + + + True + False + center + True + + + False + True + 1 + + + + + True + False + /org/freedesktop/Piper/enter-keyboard-shortcut.svg + + + False + True + 2 + + + + + True + False + Press Backspace to reset the key mapping. + True + + + + False + True + 3 + + + + + capture + 1 + diff --git a/piper/buttondialog.py b/piper/buttondialog.py index 1dcff7a..fafad58 100644 --- a/piper/buttondialog.py +++ b/piper/buttondialog.py @@ -14,14 +14,17 @@ # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +import sys + from gettext import gettext as _ from .gi_composites import GtkTemplate from .ratbagd import RatbagdButton +from .keystroke import KeyStroke import gi gi.require_version("Gtk", "3.0") -from gi.repository import GObject, Gtk +from gi.repository import Gdk, GObject, Gtk @GtkTemplate(ui="/org/freedesktop/Piper/ui/ButtonDialog.ui") @@ -38,8 +41,27 @@ class ButtonDialog(Gtk.Dialog): RatbagdButton.ACTION_TYPE_MACRO: "macro", } + _MODIFIERS = [ + Gdk.KEY_Shift_L, + Gdk.KEY_Shift_R, + Gdk.KEY_Shift_Lock, + Gdk.KEY_Hyper_L, + Gdk.KEY_Hyper_R, + Gdk.KEY_Meta_L, + Gdk.KEY_Meta_R, + Gdk.KEY_Control_L, + Gdk.KEY_Control_R, + Gdk.KEY_Super_L, + Gdk.KEY_Super_R, + Gdk.KEY_Alt_L, + Gdk.KEY_Alt_R, + ] + stack = GtkTemplate.Child() combo_mapping = GtkTemplate.Child() + stack_mapping = GtkTemplate.Child() + label_keystroke = GtkTemplate.Child() + label_preview = GtkTemplate.Child() def __init__(self, ratbagd_button, buttons, *args, **kwargs): """Instantiates a new ButtonDialog. @@ -49,9 +71,12 @@ class ButtonDialog(Gtk.Dialog): """ Gtk.Dialog.__init__(self, *args, **kwargs) self.init_template() + self._grab_pointer = None + self._keystroke = KeyStroke() self._button = ratbagd_button self._action_type = self._button.action_type self._button_mapping = ratbagd_button.mapping + self._key_mapping = ratbagd_button.key self._init_mapping_page(buttons) self._activate_current_page() @@ -75,6 +100,14 @@ class ButtonDialog(Gtk.Dialog): if self._button_mapping > 0 and button == self._button: self.combo_mapping.set_active_id(key) + self._keystroke.connect("keystroke-set", self._on_keystroke_set) + self._keystroke.connect("keystroke-cleared", self._on_keystroke_set) + self._keystroke.bind_property("accelerator", self.label_keystroke, "accelerator") + self._keystroke.bind_property("accelerator", self.label_preview, "accelerator") + if self._button.type == RatbagdButton.ACTION_TYPE_KEY: + keys = self._button.key + self._keystroke.set_from_evdev(keys[0], keys[1:]) + def _get_button_key_and_name(self, button): if button.index in RatbagdButton.BUTTON_DESCRIPTION: name = RatbagdButton.BUTTON_DESCRIPTION[button.index] @@ -82,6 +115,95 @@ class ButtonDialog(Gtk.Dialog): name = _("Button {} click").format(button.index) return str(button.index + 1), name # Logical buttons are 1-indexed. + def _grab_seat(self): + # Grabs the keyboard seat. Returns True on success, False on failure. + # Gratefully copied from GNOME Control Center's keyboard panel. + window = self.get_window() + if window is None: + return False + display = window.get_display() + seats = display.list_seats() + if len(seats) == 0: + return False + device = seats[0].get_keyboard() + if device is None: + return False + if device.get_source == Gdk.InputSource.KEYBOARD: + pointer = device.get_associated_device() + if pointer is None: + return False + else: + pointer = device + status = pointer.get_seat().grab(window, Gdk.SeatCapabilities.KEYBOARD, + False, None, None, None, None) + if status != Gdk.GrabStatus.SUCCESS: + return False + self._grab_pointer = pointer + self.grab_add() + return True + + def _release_grab(self): + # Releases a previously grabbed keyboard seat, if any. + if self._grab_pointer is None: + return + self._grab_pointer.get_seat().ungrab() + self._grab_pointer = None + self.grab_remove() + + def do_key_press_event(self, event): + # Overrides Gtk.Widget's standard key press event callback, so we can + # capture the pressed buttons in capture mode. Gratefully copied from + # GNOME Control Center's keyboard panel. + # Don't process key events when we're not in capture mode. + if self.stack_mapping.get_visible_child_name() == "overview": + return Gtk.Widget.do_key_press_event(self, event) + + # TODO: remove this workaround when libratbag removes its keycode + # contraints. When that happens, we just cache all keypresses in the + # order they arrive and set the keystroke upon Return. + # GdkEventKey.is_modified isn't exposed through PyGObject (see + # https://bugzilla.gnome.org/show_bug.cgi?id=752784), so we have to + # approximate its behaviour ourselves. This selection is from Gtk's + # default mod mask and should be fine for now for most use cases. + event.is_modifier = event.keyval in self._MODIFIERS + + # We only want to bind keystrokes using the default modifiers, so that + # our workaround above and the one in KeyStroke._update_accelerator() + # work. + event.state &= Gtk.accelerator_get_default_mod_mask() + + # Put shift back if it changed the case of the key, not otherwise. + keyval_lower = Gdk.keyval_to_lower(event.keyval) + if keyval_lower != event.keyval: + event.state |= Gdk.ModifierType.SHIFT_MASK + event.keyval = keyval_lower + + # Normalize tab. + if event.keyval == Gdk.KEY_ISO_Left_Tab: + event.keyval = Gdk.KEY_Tab + + # HACK: we don't want to use SysRq as a keybinding, but we do want + # Al+Print, so we avoid translating Alt+Print to SysRq. + if event.keyval == Gdk.KEY_Sys_Req and (event.state & Gdk.ModifierType.MOD1_MASK): + event.keyval = Gdk.KEY_Print + + # Backspace clears the current keystroke. + if not event.is_modifier and event.state == 0 and event.keyval == Gdk.KEY_BackSpace: + self._keystroke.clear() + return Gdk.EVENT_STOP + + # Anything else we process as a regular key event. + self._keystroke.process_event(event) + + return Gdk.EVENT_STOP + + def _on_keystroke_set(self, keystroke): + # A keystroke has been set or cleared; update accordingly. + self._action_type = RatbagdButton.ACTION_TYPE_KEY + self._key_mapping = self._keystroke.get_keys() + self.stack_mapping.set_visible_child_name("overview") + self._release_grab() + @GtkTemplate.Callback def _on_mapping_changed(self, combo): tree_iter = combo.get_active_iter() @@ -91,6 +213,16 @@ class ButtonDialog(Gtk.Dialog): mapping = int(model[tree_iter][1]) if mapping != self._button_mapping: self._button_mapping = mapping + self._action_type = RatbagdButton.ACTION_TYPE_BUTTON + + @GtkTemplate.Callback + def _on_capture_keystroke_clicked(self, button): + # Switches to the capture stack page and grabs the keyboard seat to + # capture all key presses. + self.stack_mapping.set_visible_child_name("capture") + if self._grab_seat() is not True: + print("Unable to grab keyboard, can't set keystroke", file=sys.stderr) + self.stack_mapping.set_visible_child_name("overview") @GObject.Property def action_type(self): @@ -99,3 +231,7 @@ class ButtonDialog(Gtk.Dialog): @GObject.Property def button_mapping(self): return self._button_mapping + + @GObject.Property + def key_mapping(self): + return self._key_mapping diff --git a/piper/buttonspage.py b/piper/buttonspage.py index 2b61c08..143aed9 100644 --- a/piper/buttonspage.py +++ b/piper/buttonspage.py @@ -70,6 +70,8 @@ class ButtonsPage(Gtk.Box): if response == Gtk.ResponseType.APPLY: if dialog.action_type == RatbagdButton.ACTION_TYPE_BUTTON: ratbagd_button.mapping = dialog.button_mapping + elif dialog.action_type == RatbagdButton.ACTION_TYPE_KEY: + ratbagd_button.key = dialog.key_mapping dialog.destroy() def _find_active_profile(self): diff --git a/piper/keystroke.py b/piper/keystroke.py new file mode 100644 index 0000000..0221704 --- /dev/null +++ b/piper/keystroke.py @@ -0,0 +1,172 @@ +# Copyright (C) 2017 Jente Hidskes +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +import sys + +import gi +gi.require_version("Gtk", "3.0") +from gi.repository import Gdk, GObject, Gtk + + +# TODO: this file needs to be checked for its Wayland support. +class KeyStroke(GObject.Object): + """The KeyStroke object represents a keyboard shortcut as pressed by the + user in the key mapping's capture mode. Note that internally it uses + keycodes (and keyvalues) as defined by Gdk, not evdev. It does have the + ability to import from and export as evdev keycodes, as required by + libratbagd. See the get_keys() and set_from_evdev() methods.""" + + # Gdk uses an offset of 8 from the keycodes defined in linux/input.h as + # used by evdev. + _XORG_KEYCODE_OFFSET = 8 + + __gsignals__ = { + 'keystroke-set': (GObject.SIGNAL_RUN_FIRST, None, ()), + 'keystroke-cleared': (GObject.SIGNAL_RUN_FIRST, None, ()), + } + + def __init__(self, **kwargs): + """Intantiates a new KeyStroke.""" + GObject.Object.__init__(self, **kwargs) + self._accelerator = "" + self._keycode = 0 + self._keyval = 0 + self._modifiers = [] + + @GObject.Property(type=str) + def accelerator(self): + """The accelerator of the current keystroke, as generated by + Gtk.accelerator_name(keyval, mask).""" + return self._accelerator + + def process_event(self, event): + """Processes the given key event to update the current keystroke. + + @param event The event to process, as Gdk.EventKey. + """ + if not self._check_key(event.hardware_keycode, event.keyval): + return + if event.is_modifier: + if (event.hardware_keycode, event.keyval) not in self._modifiers: + self._modifiers.append((event.hardware_keycode, event.keyval)) + else: + self._keycode = event.hardware_keycode + self._keyval = event.keyval + # Since keystrokes may only contain a single regular keycode, we + # accept as soon as the user enters one. TODO: we should assign the + # modifiers and keycode+keyvalue to self._cur_* so that we can add + # cancel functionality to the button dialog on Escape. + self.emit("keystroke-set") + self._update_accelerator() + + def clear(self): + """Clears the current keystroke.""" + self._keycode = 0 + self._keyval = 0 + self._modifiers = [] + self._update_accelerator() + self.emit("keystroke-cleared") + + def set_from_evdev(self, keycode, modifiers): + """Converts the given evdev keycodes to their Gdk counterparts and sets + them as the current keystroke. + + @param keycode The evdev keycode, as int. + @param modifiers A list of modifier keycodes, as [int]. + """ + keycode += self._XORG_KEYCODE_OFFSET + keyval = self._keycode_to_keyval(keycode) + if not self._check_key(keycode, keyval): + return + self._keycode = keycode + self._keyval = keyval + for modifier in modifiers: + keycode = modifier + self._XORG_KEYCODE_OFFSET + keyval = self._keycode_to_keyval(keycode) + if not self._check_key(keycode, keyval): + continue + if (keycode, keyval) not in self._modifiers: + self._modifiers.append((keycode, keyval)) + self._update_accelerator() + + def _keycode_to_keyval(self, keycode): + # Attempts to retrieve a Gdk keyval belonging to a Gdk keycode. In case + # none can be found, 0 is returned. Note that there is some inaccuracy + # here in that the keycode can correspond to several keyvals (due to + # levels and groups) and that we return the keyval belonging to level + # and group 0, if any. + keymap = Gdk.Keymap.get_default() + ok, keys, keyvals = keymap.get_entries_for_keycode(keycode) + if ok: + for key, keyval in zip(keys, keyvals): + if key.level == 0 and key.group == 0: + return keyval + print("Could not translate keycode to keyval, using 0", file=sys.stderr) + # This default will be skipped when printing the accelerator, and won't + # pass self._check_key in which case it will be skipped altogether. + return 0 + + def get_keys(self): + """Returns a list of keycodes with the first being the pressed key and + the rest modifiers, if any. These keycodes map to those as defined by + evdev as opposed to those defined by Gdk, thus the returned list is + directly usable by libratbag.""" + if self._keycode > self._XORG_KEYCODE_OFFSET: + ret = [self._keycode - self._XORG_KEYCODE_OFFSET] + else: + # TODO: make ratbag reset the key mapping on an empty list or + # something like that and return that value here instead. Currently + # this returns an unmappable key. + ret = [self._keycode] + return ret + [keycode - self._XORG_KEYCODE_OFFSET for keycode, _ in self._modifiers] + + def _check_key(self, keycode, keyval): + # Checks if the given keycode and keyval are valid through a simply + # range check. + if not self._XORG_KEYCODE_OFFSET <= keycode <= 255: + print("Keycode is not within the valid range.", file=sys.stderr) + return False + elif not Gdk.KEY_space <= keyval <= Gdk.KEY_AudioMicMute: + print("Keyval is not within the valid range.", file=sys.stderr) + return False + return True + + def _update_accelerator(self): + # Updates the accelerator property, so that any GObject bindings to + # Gtk.ShortcutLabels' accelerator property are updated automatically. + mask = Gdk.ModifierType(0) + for (_, keyval) in self._modifiers: + # The following are GTK+'s default modifiers that are allowed + # through in ButtonDialog.on_key_press_event, hence we check for + # these modifiers only -- it is unlikely we will find others. If + # this needs to be made more accurate, we can iterate over the mask + # returned by Gtk.accelerator_get_default_mod_mask(). + # The default mod mask depends on the Gdk backend in use, but will + # typically include Control, Shift, Alt, Super, Hyper and Meta. + if keyval == Gdk.KEY_Shift_L or keyval == Gdk.KEY_Shift_R or keyval == Gdk.KEY_Shift_Lock: + mask |= Gdk.ModifierType.SHIFT_MASK + elif keyval == Gdk.KEY_Hyper_L or keyval == Gdk.KEY_Hyper_R: + mask |= Gdk.ModifierType.HYPER_MASK + elif keyval == Gdk.KEY_Meta_L or keyval == Gdk.KEY_Meta_R: + mask |= Gdk.ModifierType.META_MASK + elif keyval == Gdk.KEY_Control_L or keyval == Gdk.KEY_Control_R: + mask |= Gdk.ModifierType.CONTROL_MASK + elif keyval == Gdk.KEY_Super_L or keyval == Gdk.KEY_Super_R: + mask |= Gdk.ModifierType.SUPER_MASK + elif keyval == Gdk.KEY_Alt_L or keyval == Gdk.KEY_Alt_R: + mask |= Gdk.ModifierType.MOD1_MASK + self._accelerator = Gtk.accelerator_name(self._keyval, mask) + self.notify("accelerator")