#109 New mapping editor with multiline input and improved autocompletion

pull/257/head
sezanzeb 2 years ago
parent 76c3cadcfa
commit 47bcefa7f3

@ -2,7 +2,7 @@ Package: input-remapper
Version: 1.3.0
Architecture: all
Maintainer: Sezanzeb <proxima@sezanzeb.de>
Depends: build-essential, libpython3-dev, libdbus-1-dev, python3, python3-setuptools, python3-evdev, python3-pydbus, python3-gi, gettext, python3-cairo, libgtk-3-0
Depends: build-essential, libpython3-dev, libdbus-1-dev, python3, python3-setuptools, python3-evdev, python3-pydbus, python3-gi, gettext, python3-cairo, libgtk-3-0, libgtksourceview-4-0
Description: A tool to change the mapping of your input device buttons
Replaces: python3-key-mapper, key-mapper
Conflicts: python3-key-mapper, key-mapper

@ -38,6 +38,8 @@ input-remapper is now part of [Debian Unstable](https://packages.debian.org/sid/
##### pip
Dependencies from your distros repo: `gtksourceview4`
```bash
sudo pip uninstall key-mapper
sudo pip install --no-binary :all: git+https://github.com/sezanzeb/input-remapper.git

@ -33,6 +33,7 @@ from argparse import ArgumentParser
import gi
gi.require_version('Gtk', '3.0')
gi.require_version('GLib', '2.0')
gi.require_version('GtkSource', '4')
from gi.repository import Gtk
APP_NAME = 'input-remapper'
@ -65,21 +66,20 @@ if __name__ == '__main__':
logger.debug('Using locale directory: {}'.format(LOCALE_DIR))
# import input-remapper stuff after setting the log verbosity
from inputremapper.gui.window import Window
from inputremapper.gui.user_interface import UserInterface
from inputremapper.daemon import Daemon
from inputremapper.daemon import config
config.load_config()
window = Window()
user_interface = UserInterface()
def stop():
if isinstance(window.dbus, Daemon):
# it created its own temporary daemon inside the process
# because none was running
window.dbus.stop_all()
if isinstance(user_interface.dbus, Daemon):
# have fun debugging completely unrelated tests if you remove this
user_interface.dbus.stop_all()
window.on_close()
user_interface.on_close()
atexit.register(stop)

@ -18,7 +18,11 @@
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
print('key-mapper-control is deprecated, please use input-remapper-control instead')
print(
"\033[31m"
"key-mapper-control is deprecated, please use input-remapper-control instead"
"\033[0m"
)
from importlib.util import spec_from_loader, module_from_spec
from importlib.machinery import SourceFileLoader

@ -18,7 +18,11 @@
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
print('key-mapper-gtk is deprecated, please use input-remapper-gtk instead')
print(
"\033[31m"
"key-mapper-gtk is deprecated, please use input-remapper-gtk instead"
"\033[0m"
)
from importlib.util import spec_from_loader, module_from_spec
from importlib.machinery import SourceFileLoader

@ -18,7 +18,11 @@
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
print('key-mapper-service is deprecated, please use input-remapper-service instead')
print(
"\033[31m"
"key-mapper-service is deprecated, please use input-remapper-service instead"
"\033[0m"
)
from importlib.util import spec_from_loader, module_from_spec
from importlib.machinery import SourceFileLoader

@ -1,6 +1,6 @@
[Desktop Entry]
Type=Application
Name=input-remapper
Name=Input Remapper
Icon=/usr/share/input-remapper/input-remapper.svg
Exec=input-remapper-gtk
Terminal=false

@ -2,6 +2,7 @@
<!-- Generated with glade 3.38.2 -->
<interface>
<requires lib="gtk+" version="3.22"/>
<requires lib="gtksourceview" version="4.0"/>
<object class="GtkImage" id="about-icon">
<property name="visible">True</property>
<property name="can-focus">False</property>
@ -10,27 +11,120 @@
<object class="GtkImage" id="check-icon">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-end">2</property>
<property name="icon-name">dialog-ok</property>
</object>
<object class="GtkImage" id="copy-icon">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-end">2</property>
<property name="icon-name">edit-copy</property>
</object>
<object class="GtkImage" id="delete-icon">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-end">2</property>
<property name="icon-name">edit-delete</property>
</object>
<object class="GtkImage" id="gtk-delete-icon1">
<object class="GtkImage" id="delete-icon-1">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">gtk-delete</property>
</object>
<object class="GtkMessageDialog" id="confirm-delete">
<property name="can-focus">False</property>
<property name="type-hint">dialog</property>
<child internal-child="vbox">
<object class="GtkBox" id="error_dialog_lkdjfa">
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">1</property>
<property name="baseline-position">top</property>
<child internal-child="action_area">
<object class="GtkButtonBox">
<property name="can-focus">False</property>
<property name="homogeneous">True</property>
<property name="layout-style">expand</property>
<child>
<object class="GtkButton" id="button1">
<property name="label">Delete</property>
<property name="use-action-appearance">False</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="image">delete-icon-1</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="button2">
<property name="label" translatable="yes">Cancel</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-default">True</property>
<property name="has-default">True</property>
<property name="receives-default">True</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="confirm-delete-label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">50</property>
<property name="margin-end">50</property>
<property name="margin-top">32</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
</child>
<action-widgets>
<action-widget response="-3">button1</action-widget>
<action-widget response="-6">button2</action-widget>
</action-widgets>
</object>
<object class="GtkImage" id="edit_icon">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">input-keyboard</property>
</object>
<object class="GtkImage" id="gtk-redo-icon">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="stock">gtk-redo</property>
<property name="margin-end">2</property>
<property name="icon-name">edit-undo</property>
</object>
<object class="GtkImage" id="icon-delete-row">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-end">2</property>
<property name="icon-name">window-close</property>
</object>
<object class="GtkImage" id="image1">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-end">2</property>
<property name="icon-name">object-rotate-right</property>
</object>
<object class="GtkAdjustment" id="mouse_speed_adjustment">
<property name="lower">2</property>
@ -41,6 +135,7 @@
<object class="GtkImage" id="new-icon">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-end">2</property>
<property name="icon-name">document-new</property>
</object>
<object class="GtkImage" id="save-icon">
@ -49,13 +144,15 @@
<property name="icon-name">document-save</property>
</object>
<object class="GtkWindow" id="window">
<property name="width-request">750</property>
<property name="width-request">800</property>
<property name="can-focus">False</property>
<property name="title" translatable="yes">Input Remapper</property>
<property name="default-width">1000</property>
<property name="default-height">450</property>
<property name="icon">input-remapper.svg</property>
<signal name="delete-event" handler="on_close" swapped="no"/>
<signal name="key-press-event" handler="key_press" swapped="no"/>
<signal name="key-release-event" handler="key_release" swapped="no"/>
<signal name="key-press-event" handler="on_key_press" swapped="no"/>
<signal name="key-release-event" handler="on_key_release" swapped="no"/>
<child>
<object class="GtkBox" id="vertical-wrapper">
<property name="visible">True</property>
@ -117,12 +214,12 @@
</child>
<child>
<object class="GtkButton" id="apply_system_layout">
<property name="label" translatable="yes">Restore Defaults</property>
<property name="label" translatable="yes">Stop Injection</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Shortcut: ctrl + del
To give your keys back their original mapping.</property>
Gives your keys back their original function</property>
<property name="halign">end</property>
<property name="image">gtk-redo-icon</property>
<property name="always-show-image">True</property>
@ -139,6 +236,7 @@ To give your keys back their original mapping.</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Help</property>
<property name="halign">end</property>
<property name="image">about-icon</property>
<property name="always-show-image">True</property>
@ -147,7 +245,7 @@ To give your keys back their original mapping.</property>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">3</property>
<property name="position">5</property>
</packing>
</child>
</object>
@ -211,7 +309,7 @@ To give your keys back their original mapping.</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Don't hold down any keys while the injection starts.</property>
<property name="tooltip-text" translatable="yes">Start injecting. Don't hold down any keys while the injection starts</property>
<property name="image">check-icon</property>
<property name="relief">none</property>
<property name="always-show-image">True</property>
@ -230,6 +328,7 @@ To give your keys back their original mapping.</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Duplicate this preset</property>
<property name="image">copy-icon</property>
<property name="relief">none</property>
<property name="always-show-image">True</property>
@ -248,6 +347,7 @@ To give your keys back their original mapping.</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Create a new preset</property>
<property name="image">new-icon</property>
<property name="relief">none</property>
<property name="always-show-image">True</property>
@ -267,6 +367,7 @@ To give your keys back their original mapping.</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Delete this preset</property>
<property name="image">delete-icon</property>
<property name="relief">none</property>
<property name="always-show-image">True</property>
@ -395,11 +496,11 @@ To give your keys back their original mapping.</property>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="tooltip-text" translatable="yes">To automatically apply the preset after your login or when it connects.</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="tooltip-text" translatable="yes">To automatically apply the preset after your login or when it connects.</property>
<property name="label" translatable="yes">Autoload</property>
<property name="xalign">0</property>
</object>
@ -695,18 +796,31 @@ To give your keys back their original mapping.</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkBox">
<object class="GtkBox" id="editor">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkLabel">
<property name="width-request">140</property>
<object class="GtkScrolledWindow">
<property name="width-request">160</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="tooltip-text" translatable="yes">Click on a cell below and hit a key on your device. Click the "Restore Defaults" button beforehand.</property>
<property name="margin-top">5</property>
<property name="margin-bottom">5</property>
<property name="label" translatable="yes">Key</property>
<property name="can-focus">True</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="shadow-type">none</property>
<child>
<object class="GtkListBox" id="selection_label_listbox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="selection-mode">browse</property>
<style>
<class name="editor-key-list"/>
</style>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
@ -715,74 +829,130 @@ To give your keys back their original mapping.</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-top">5</property>
<property name="margin-bottom">5</property>
<property name="label" translatable="yes">Mapping</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="width-request">50</property>
<object class="GtkSeparator">
<property name="visible">True</property>
<property name="can-focus">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
<property name="position">1</property>
</packing>
</child>
<style>
<class name="table-header"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkSeparator">
<property name="visible">True</property>
<property name="can-focus">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="visible">True</property>
<property name="can-focus">True</property>
<child>
<object class="GtkViewport">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">18</property>
<property name="margin-end">18</property>
<property name="margin-top">18</property>
<property name="margin-bottom">18</property>
<property name="orientation">vertical</property>
<property name="spacing">18</property>
<child>
<object class="GtkListBox" id="key_list">
<object class="GtkBox" id="editor-controls">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="selection-mode">none</property>
<property name="spacing">12</property>
<child>
<object class="GtkToggleButton" id="key_recording_toggle">
<property name="label" translatable="yes">Change Key</property>
<property name="width-request">100</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Record a button of your device that should be remapped</property>
<property name="image">image1</property>
<property name="relief">none</property>
<property name="always-show-image">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="delete-mapping">
<property name="label" translatable="yes">Delete</property>
<property name="width-request">80</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Delete this entry</property>
<property name="image">icon-delete-row</property>
<property name="relief">none</property>
<property name="always-show-image">True</property>
<style>
<class name="delete-mapping-button"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow" id="code_editor_container">
<property name="visible">True</property>
<property name="can-focus">True</property>
<child>
<object class="GtkSourceView" id="code_editor">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="valign">start</property>
<property name="resize-mode">immediate</property>
<property name="wrap-mode">word</property>
<property name="left-margin">10</property>
<property name="right-margin">10</property>
<property name="top-margin">10</property>
<property name="bottom-margin">10</property>
<property name="monospace">True</property>
<property name="tab-width">2</property>
<property name="auto-indent">True</property>
<style>
<class name="code-editor-text-view"/>
</style>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
<property name="position">0</property>
</packing>
</child>
</object>
@ -1088,236 +1258,4 @@ Macros allow multiple characters to be written with a single key-press. Informat
</object>
</child>
</object>
<object class="GtkDialog" id="confirm-delete">
<property name="can-focus">False</property>
<property name="border-width">4</property>
<property name="title" translatable="yes">Input Remapper</property>
<property name="modal">True</property>
<property name="icon">input-remapper.svg</property>
<property name="type-hint">dialog</property>
<property name="urgency-hint">True</property>
<property name="transient-for">window</property>
<property name="attached-to">window</property>
<child internal-child="vbox">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child internal-child="action_area">
<object class="GtkButtonBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">end</property>
<property name="margin-top">6</property>
<property name="layout-style">end</property>
<child>
<object class="GtkButton" id="go_ahead1">
<property name="label" translatable="yes">Go Back</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="go_back1">
<property name="label">Delete</property>
<property name="use-action-appearance">False</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-default">True</property>
<property name="receives-default">False</property>
<property name="image">gtk-delete-icon1</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="pack-type">end</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkImage" id="error-image2">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-right">6</property>
<property name="margin-end">6</property>
<property name="yalign">0</property>
<property name="icon-name">dialog-warning</property>
<property name="icon_size">6</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="confirm-delete-label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-left">6</property>
<property name="margin-right">6</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="ypad">6</property>
<property name="use-markup">True</property>
<property name="xalign">0</property>
<property name="yalign">0.5</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
</child>
<action-widgets>
<action-widget response="-6">go_ahead1</action-widget>
<action-widget response="-3">go_back1</action-widget>
</action-widgets>
</object>
<object class="GtkDialog" id="error_dialog">
<property name="can-focus">False</property>
<property name="border-width">4</property>
<property name="title" translatable="yes">Input Remapper</property>
<property name="modal">True</property>
<property name="icon">input-remapper.svg</property>
<property name="type-hint">dialog</property>
<property name="urgency-hint">True</property>
<property name="transient-for">window</property>
<property name="attached-to">window</property>
<child internal-child="vbox">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child internal-child="action_area">
<object class="GtkButtonBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">center</property>
<property name="margin-top">6</property>
<property name="layout-style">end</property>
<child>
<object class="GtkButton" id="close_error_dialog">
<property name="label">gtk-close</property>
<property name="use-action-appearance">False</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-default">True</property>
<property name="receives-default">False</property>
<property name="use-stock">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="pack-type">end</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkImage" id="error-image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-end">6</property>
<property name="yalign">0</property>
<property name="icon-name">dialog-error</property>
<property name="icon_size">6</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-end">6</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkLabel" id="primary_error_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="ypad">6</property>
<property name="use-markup">True</property>
<property name="xalign">0</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="secondary_error_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="ypad">6</property>
<property name="use-markup">True</property>
<property name="xalign">0</property>
<property name="yalign">0</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
</child>
<action-widgets>
<action-widget response="-7">close_error_dialog</action-widget>
</action-widgets>
</object>
</interface>

@ -29,7 +29,7 @@ list entry {
box-shadow: none;
}
list button:not(:focus) {
list.basic-editor button:not(:focus) {
border-color: transparent;
background: transparent;
box-shadow: none;
@ -39,8 +39,39 @@ list button {
border-color: transparent;
}
.transparent {
background: transparent;
}
.code-editor-text-view > * {
border-radius: 2px;
}
.copyright {
font-size: 7pt;
}
/* @theme_bg_color, @theme_fg_color */
.editor-key-list label {
padding: 11px;
}
.autocompletion label {
padding: 11px;
}
.autocompletion {
padding: 0px;
box-shadow: none;
}
.no-border {
border: 0px;
box-shadow: none;
}
.code-editor-text-view.multiline {
/* extra space between text editor and line numbers */
padding-left: 18px;
}
/* @theme_bg_color, @theme_fg_color */

@ -207,6 +207,8 @@ class GlobalConfig(ConfigBase):
logger.info('Not injecting for "%s" automatically anmore', group_key)
self.remove(["autoload", group_key])
self._save_config()
def iterate_autoload_presets(self):
"""Get tuples of (device, preset)."""
return self._config.get("autoload", {}).items()
@ -237,7 +239,7 @@ class GlobalConfig(ConfigBase):
logger.debug('Config "%s" doesn\'t exist yet', self.path)
self.clear_config()
self._config = copy.deepcopy(INITIAL_CONFIG)
self.save_config()
self._save_config()
return
with open(self.path, "r") as file:
@ -253,7 +255,7 @@ class GlobalConfig(ConfigBase):
# uses the default configuration when the config object
# is empty automatically
def save_config(self):
def _save_config(self):
"""Save the config to the file system."""
if USER == "root":
logger.debug("Skipping config file creation for the root user")
@ -266,5 +268,6 @@ class GlobalConfig(ConfigBase):
logger.info("Saved config to %s", self.path)
file.write("\n")
migrate()
config = GlobalConfig()

@ -338,9 +338,8 @@ class Daemon:
return
if not isinstance(preset, str):
# might be broken due to a previous bug
config.remove(["autoload", group.key])
config.save_config()
# maybe another dict or something, who knows. Broken config
logger.error("Expected a string for autoload, but got %s", preset)
return
logger.info('Autoloading for "%s"', group.key)

@ -0,0 +1,407 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
# input-remapper 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.
#
# input-remapper 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 input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Autocompletion for the editor."""
import re
from gi.repository import Gdk, Gtk, GLib, GObject, GtkSource
from inputremapper.system_mapping import system_mapping
from inputremapper.injection.macros.parse import (
FUNCTIONS,
get_macro_argument_names,
remove_comments,
)
from inputremapper.logger import logger
# no shorthand names
FUNCTION_NAMES = [name for name in FUNCTIONS.keys() if len(name) > 1]
# no deprecated functions
FUNCTION_NAMES.remove("ifeq")
def _get_left_text(iter):
buffer = iter.get_buffer()
result = buffer.get_text(buffer.get_start_iter(), iter, True)
result = remove_comments(result)
result = result.replace("\n", " ")
return result.lower()
# regex to search for the beginning of a...
PARAMETER = r".*?[(,=+]\s*"
FUNCTION_CHAIN = r".*?\)\s*\.\s*"
def get_incomplete_function_name(iter):
"""Get the word that is written left to the TextIter."""
left_text = _get_left_text(iter)
# match foo in:
# bar().foo
# bar()\n.foo
# bar().\nfoo
# bar(\nfoo
# bar(\nqux=foo
# bar(KEY_A,\nfoo
# foo
match = re.match(rf"(?:{FUNCTION_CHAIN}|{PARAMETER}|^)(\w+)$", left_text)
if match is None:
return ""
return match[1]
def get_incomplete_parameter(iter):
"""Get the parameter that is written left to the TextIter."""
left_text = _get_left_text(iter)
# match foo in:
# bar(foo
# bar(a=foo
# bar(qux, foo
# foo
# bar + foo
match = re.match(rf"(?:{PARAMETER}|^)(\w+)$", left_text)
print("get_incomplete_parameter", left_text, match)
if match is None:
return None
return match[1]
def propose_symbols(text_iter):
"""Find key names that match the input at the cursor."""
incomplete_name = get_incomplete_parameter(text_iter)
if incomplete_name is None or len(incomplete_name) <= 1:
return []
incomplete_name = incomplete_name.lower()
return [
(name, name)
for name in list(system_mapping.list_names())
if incomplete_name in name.lower() and incomplete_name != name.lower()
]
def propose_function_names(text_iter):
"""Find function names that match the input at the cursor."""
incomplete_name = get_incomplete_function_name(text_iter)
if incomplete_name is None or len(incomplete_name) <= 1:
return []
incomplete_name = incomplete_name.lower()
return [
(name, f"{name}({', '.join(get_macro_argument_names(FUNCTIONS[name]))})")
for name in FUNCTION_NAMES
if incomplete_name in name.lower() and incomplete_name != name.lower()
]
debounces = {}
def debounce(func):
"""Debounce a function call to improve performance."""
def clear_debounce(self, *args):
debounces[func.__name__] = None
return func(self, *args)
def wrapped(self, *args):
if debounces.get(func.__name__) is not None:
GLib.source_remove(debounces[func.__name__])
timeout = self.debounce_timeout
debounces[func.__name__] = GLib.timeout_add(
timeout, lambda: clear_debounce(self, *args)
)
return wrapped
class SuggestionLabel(Gtk.Label):
"""A label with some extra internal information."""
__gtype_name__ = "SuggestionLabel"
def __init__(self, display_name, suggestion):
super().__init__(label=display_name)
self.suggestion = suggestion
class Autocompletion(Gtk.Popover):
"""Provide keyboard-controllable beautiful autocompletions.
The one provided via source_view.get_completion() is not very appealing
"""
__gtype_name__ = "Autocompletion"
def __init__(self, text_input):
"""Create an autocompletion popover.
It will remain hidden until there is something to autocomplete.
Parameters
----------
text_input : Gtk.SourceView | Gtk.TextView
The widget that contains the text that should be autocompleted
"""
super().__init__(
# Don't switch the focus to the popover when it shows
modal=False,
# Always show the popover below the cursor, don't move it to a different
# position based on the location within the window
constrain_to=Gtk.PopoverConstraint.NONE,
)
self.debounce_timeout = 100
self.text_input = text_input
self.scrolled_window = Gtk.ScrolledWindow(
min_content_width=200,
max_content_height=200,
propagate_natural_width=True,
propagate_natural_height=True,
)
self.list_box = Gtk.ListBox()
self.list_box.get_style_context().add_class("transparent")
self.scrolled_window.add(self.list_box)
# row-activated is on-click,
# row-selected is when scrolling through it
self.list_box.connect(
"row-activated",
self._on_suggestion_clicked,
)
self.add(self.scrolled_window)
self.get_style_context().add_class("autocompletion")
self.set_position(Gtk.PositionType.BOTTOM)
text_input.connect("key-press-event", self.navigate)
# add some delay, so that pressing the button in the completion works before
# the popover is hidden due to focus-out-event
text_input.connect("focus-out-event", self.on_text_input_unfocus)
text_input.get_buffer().connect("changed", self.update)
self.set_position(Gtk.PositionType.BOTTOM)
self.visible = False
self.show_all()
self.popdown() # hidden by default. this needs to happen after show_all!
def on_text_input_unfocus(self, *_):
"""The code editor was unfocused."""
GLib.timeout_add(100, self.popdown)
# "(input-remapper-gtk:97611): Gtk-WARNING **: 16:33:56.464: GtkTextView -
# did not receive focus-out-event. If you connect a handler to this signal,
# it must return FALSE so the text view gets the event as well"
return False
def navigate(self, _, event):
"""Using the keyboard to select an autocompletion suggestion."""
if not self.visible:
return
if event.keyval == Gdk.KEY_Escape:
self.popdown()
return
selected_row = self.list_box.get_selected_row()
if event.keyval not in [Gdk.KEY_Down, Gdk.KEY_Up, Gdk.KEY_Return]:
# not one of the keys that controls the autocompletion. Deselect
# the row but keep it open
self.list_box.select_row(None)
return
if event.keyval == Gdk.KEY_Return:
if selected_row is None:
# nothing selected, forward the event to the text editor
return
# a row is selected and should be used for autocompletion
self.list_box.emit("row-activated", selected_row)
return Gdk.EVENT_STOP
num_rows = len(self.list_box.get_children())
if selected_row is None:
# select the first row
if event.keyval == Gdk.KEY_Down:
new_selected_row = self.list_box.get_row_at_index(0)
if event.keyval == Gdk.KEY_Up:
new_selected_row = self.list_box.get_row_at_index(num_rows - 1)
else:
# select the next row
selected_index = selected_row.get_index()
new_index = selected_index
if event.keyval == Gdk.KEY_Down:
new_index += 1
if event.keyval == Gdk.KEY_Up:
new_index -= 1
if new_index < 0:
new_index = num_rows - 1
if new_index > num_rows - 1:
new_index = 0
new_selected_row = self.list_box.get_row_at_index(new_index)
self.list_box.select_row(new_selected_row)
self._scroll_to_row(new_selected_row)
# don't change editor contents
return Gdk.EVENT_STOP
def _scroll_to_row(self, row):
"""Scroll up or down so that the row is visible."""
# unfortunately, it seems that without focusing the row it won't happen
# automatically (or whatever the reason for this is, just a wild guess)
# (the focus should not leave the code editor, so that continuing
# to write code is possible), so here is a custom solution.
row_height = row.get_allocation().height
if row:
y_offset = row.translate_coordinates(self.list_box, 0, 0)[1]
height = self.scrolled_window.get_max_content_height()
current_y_scroll = self.scrolled_window.get_vadjustment().get_value()
vadjustment = self.scrolled_window.get_vadjustment()
if y_offset > current_y_scroll + (height - row_height):
vadjustment.set_value(y_offset - (height - row_height))
if y_offset < current_y_scroll:
# scroll up because the element is not visible anymore
vadjustment.set_value(y_offset)
def _get_text_iter_at_cursor(self):
"""Get Gtk.TextIter at the current text cursor location."""
cursor = self.text_input.get_cursor_locations()[0]
return self.text_input.get_iter_at_location(cursor.x, cursor.y)[1]
def popup(self):
self.visible = True
super().popup()
def popdown(self):
self.visible = False
super().popdown()
@debounce
def update(self, *_):
"""Find new autocompletion suggestions and display them. Hide if none."""
if not self.text_input.is_focus():
self.popdown()
return
self.list_box.forall(self.list_box.remove)
# move the autocompletion to the text cursor
cursor = self.text_input.get_cursor_locations()[0]
# convert it to window coords, because the cursor values will be very large
# when the TextView is in a scrolled down ScrolledWindow.
window_coords = self.text_input.buffer_to_window_coords(
Gtk.TextWindowType.TEXT, cursor.x, cursor.y
)
cursor.x = window_coords.window_x
cursor.y = window_coords.window_y
cursor.y += 12
if self.text_input.get_show_line_numbers():
cursor.x += 25
self.set_pointing_to(cursor)
text_iter = self._get_text_iter_at_cursor()
suggested_names = propose_function_names(text_iter)
suggested_names += propose_symbols(text_iter)
if len(suggested_names) == 0:
self.popdown()
return
self.popup() # ffs was this hard to find
# add visible autocompletion entries
for suggestion, display_name in suggested_names:
label = SuggestionLabel(display_name, suggestion)
self.list_box.insert(label, -1)
label.show_all()
def _on_suggestion_clicked(self, _, selected_row):
"""An autocompletion suggestion was selected and should be inserted."""
selected_label = selected_row.get_children()[0]
suggestion = selected_label.suggestion
buffer = self.text_input.get_buffer()
# make sure to replace the complete unfinished word. Look to the right and
# remove whatever there is
cursor_iter = self._get_text_iter_at_cursor()
right = buffer.get_text(cursor_iter, buffer.get_end_iter(), True)
match = re.match(r"^(\w+)", right)
right = match[1] if match else ""
Gtk.TextView.do_delete_from_cursor(
self.text_input, Gtk.DeleteType.CHARS, len(right)
)
# do the same to the left
cursor_iter = self._get_text_iter_at_cursor()
left = buffer.get_text(buffer.get_start_iter(), cursor_iter, True)
match = re.match(r".*?(\w+)$", re.sub("\n", " ", left))
left = match[1] if match else ""
Gtk.TextView.do_delete_from_cursor(
self.text_input, Gtk.DeleteType.CHARS, -len(left)
)
# insert the autocompletion
Gtk.TextView.do_insert_at_cursor(self.text_input, suggestion)
self.emit("suggestion-inserted")
GObject.signal_new(
"suggestion-inserted", Autocompletion, GObject.SignalFlags.RUN_FIRST, None, []
)

@ -0,0 +1,566 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
# input-remapper 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.
#
# input-remapper 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 input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""The editor with multiline code input, recording toggle and autocompletion."""
import re
from gi.repository import Gtk, GLib, GtkSource, Gdk
from inputremapper.gui.editor.autocompletion import Autocompletion
from inputremapper.system_mapping import system_mapping
from inputremapper.gui.custom_mapping import custom_mapping
from inputremapper.key import Key
from inputremapper.logger import logger
from inputremapper.gui.reader import reader
from inputremapper.gui.utils import CTX_KEYCODE, CTX_WARNING
class SelectionLabel(Gtk.ListBoxRow):
"""One label per mapping in the preset.
This wrapper serves as a storage for the information the inherited label represents.
"""
__gtype_name__ = "SelectionLabel"
def __init__(self):
super().__init__()
self.key = None
self.symbol = ""
label = Gtk.Label()
# Make the child label widget break lines, important for
# long combinations
label.set_line_wrap(True)
label.set_line_wrap_mode(2)
label.set_justify(Gtk.Justification.CENTER)
self.label = label
self.add(label)
self.show_all()
def set_key(self, key):
"""Set the key this button represents
Parameters
----------
key : Key
"""
self.key = key
if key:
self.label.set_label(key.beautify())
else:
self.label.set_label("new entry")
def get_key(self):
return self.key
def set_label(self, label):
return self.label.set_label(label)
def get_label(self):
return self.label.get_label()
def __str__(self):
return f"SelectionLabel({str(self.key)})"
def __repr__(self):
return self.__str__()
def ensure_everything_saved(func):
"""Make sure the editor has written its changes to custom_mapping and save."""
def wrapped(self, *args, **kwargs):
if self.user_interface.preset_name:
self.gather_changes_and_save()
return func(self, *args, **kwargs)
return wrapped
SET_KEY_FIRST = "Set the key first"
class Editor:
"""Maintains the widgets of the editor."""
def __init__(self, user_interface):
self.user_interface = user_interface
self.autocompletion = None
self._setup_source_view()
self._setup_recording_toggle()
self.window = self.get("window")
self.timeout = GLib.timeout_add(100, self.check_add_new_key)
self.active_selection_label = None
selection_label_listbox = self.get("selection_label_listbox")
selection_label_listbox.connect("row-selected", self.on_mapping_selected)
self.device = user_interface.group
# keys were not pressed yet
self._input_has_arrived = False
toggle = self.get_recording_toggle()
toggle.connect("focus-out-event", self._reset_keycode_consumption)
toggle.connect("focus-out-event", lambda *_: toggle.set_active(False))
toggle.connect("focus-in-event", self._on_recording_toggle_focus)
# Don't leave the input when using arrow keys or tab. wait for the
# window to consume the keycode from the reader. I.e. a tab input should
# be recorded, instead of causing the recording to stop.
toggle.connect("key-press-event", lambda *args: Gdk.EVENT_STOP)
text_input = self.get_text_input()
text_input.connect("focus-out-event", self.on_text_input_unfocus)
delete_button = self.get_delete_button()
delete_button.connect("clicked", self._on_delete_button_clicked)
@ensure_everything_saved
def on_text_input_unfocus(self, *_):
"""When unfocusing the text it saves.
Input Remapper doesn't save the editor on change, because that would cause
an incredible amount of logs for every single input. The custom_mapping would
need to be changed, which causes two logs, then it has to be saved
to disk which is another two log messages. So every time a single character
is typed it writes 4 lines.
Instead, it will save the preset when it is really needed, i.e. when a button
that requires a saved preset is pressed. For this there exists the
@ensure_everything_saved decorator.
To avoid maybe forgetting to add this decorator somewhere, it will also save
when unfocusing the text input.
If the scroll wheel is used to interact with gtk widgets it won't unfocus,
so this focus-out handler is not the solution to everything as well.
One could debounce saving on text-change to avoid those logs, but that just
sounds like a huge source of race conditions and is also hard to test.
"""
pass
def clear(self):
"""Clear all inputs, labels, etc. Reset the state.
This is really important to do before loading a different preset.
Otherwise the inputs will be read and then saved into the next preset.
"""
if self.active_selection_label:
self.set_key(None)
self.set_symbol_input_text("")
self.disable_symbol_input()
self._reset_keycode_consumption()
selection_label_listbox = self.get("selection_label_listbox")
selection_label_listbox.forall(selection_label_listbox.remove)
self.add_empty()
selection_label_listbox.select_row(selection_label_listbox.get_children()[0])
def _setup_recording_toggle(self):
"""Prepare the toggle button for recording key inputs."""
toggle = self.get("key_recording_toggle")
toggle.connect(
"focus-out-event",
self._show_change_key,
)
toggle.connect(
"focus-in-event",
self._show_press_key,
)
toggle.connect(
"clicked",
lambda _: (
self._show_press_key()
if toggle.get_active()
else self._show_change_key()
),
)
def _show_press_key(self, *_):
"""Show user friendly instructions."""
self.get("key_recording_toggle").set_label("Press Key")
def _show_change_key(self, *_):
"""Show user friendly instructions."""
self.get("key_recording_toggle").set_label("Change Key")
def _setup_source_view(self):
"""Prepare the code editor."""
source_view = self.get("code_editor")
# without this the wrapping ScrolledWindow acts weird when new lines are added,
# not offering enough space to the text editor so the whole thing is suddenly
# scrollable by a few pixels.
# Found this after making blind guesses with settings in glade, and then
# actually looking at the snaphot preview! In glades editor this didn have an
# effect.
source_view.set_resize_mode(Gtk.ResizeMode.IMMEDIATE)
source_view.get_buffer().connect("changed", self.show_line_numbers_if_multiline)
# Syntax Highlighting
# Thanks to https://github.com/wolfthefallen/py-GtkSourceCompletion-example
# language_manager = GtkSource.LanguageManager()
# fun fact: without saving LanguageManager into its own variable it doesn't work
# python = language_manager.get_language("python")
# source_view.get_buffer().set_language(python)
# TODO there are some similarities with python, but overall it's quite useless.
# commented out until there is proper highlighting for input-remappers syntax.
autocompletion = Autocompletion(source_view)
autocompletion.set_relative_to(self.get("code_editor_container"))
autocompletion.connect("suggestion-inserted", self.gather_changes_and_save)
self.autocompletion = autocompletion
def show_line_numbers_if_multiline(self, *_):
"""Show line numbers if a macro is being edited."""
code_editor = self.get("code_editor")
symbol = self.get_symbol_input_text() or ""
if "\n" in symbol:
code_editor.set_show_line_numbers(True)
code_editor.set_monospace(True)
code_editor.get_style_context().add_class("multiline")
else:
code_editor.set_show_line_numbers(False)
code_editor.set_monospace(False)
code_editor.get_style_context().remove_class("multiline")
def get_delete_button(self):
return self.get("delete-mapping")
def check_add_new_key(self):
"""If needed, add a new empty mapping to the list for the user to configure."""
selection_label_listbox = self.get("selection_label_listbox")
selection_label_listbox = selection_label_listbox.get_children()
for selection_label in selection_label_listbox:
if selection_label.get_key() is None:
# unfinished row found
break
else:
self.add_empty()
return True
def disable_symbol_input(self):
"""Display help information and dont allow entering a symbol yet.
Without this, maybe a user enters a symbol or writes a macro, switches
presets accidentally before configuring the key and then it's gone. It can
only be saved to the preset if a key is configured. This avoids that pitfall.
"""
text_input = self.get_text_input()
text_input.set_sensitive(False)
text_input.set_opacity(0.5)
if self.get_symbol_input_text() == "":
# don't overwrite user input
self.set_symbol_input_text(SET_KEY_FIRST)
def enable_symbol_input(self):
"""Don't display help information anymore and allow changing the symbol."""
text_input = self.get_text_input()
text_input.set_sensitive(True)
text_input.set_opacity(1)
buffer = text_input.get_buffer()
symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True)
if symbol == SET_KEY_FIRST:
# don't overwrite user input
self.set_symbol_input_text("")
@ensure_everything_saved
def on_mapping_selected(self, _=None, selection_label=None):
"""One of the buttons in the left "key" column was clicked.
Load the information from that mapping entry into the editor.
"""
self.active_selection_label = selection_label
if selection_label is None:
return
key = selection_label.key
self.set_key(key)
if key is None:
self.set_symbol_input_text("")
self.disable_symbol_input()
# symbol input disabled until a key is configured
else:
self.set_symbol_input_text(custom_mapping.get_symbol(key))
self.enable_symbol_input()
self.get("window").set_focus(self.get_text_input())
def add_empty(self):
"""Add one empty row for a single mapped key."""
selection_label_listbox = self.get("selection_label_listbox")
mapping_selection = SelectionLabel()
mapping_selection.set_label("new entry")
mapping_selection.show_all()
selection_label_listbox.insert(mapping_selection, -1)
@ensure_everything_saved
def load_custom_mapping(self):
"""Display the entries in custom_mapping."""
self.set_symbol_input_text("")
selection_label_listbox = self.get("selection_label_listbox")
selection_label_listbox.forall(selection_label_listbox.remove)
for key, output in custom_mapping:
selection_label = SelectionLabel()
selection_label.set_key(key)
selection_label_listbox.insert(selection_label, -1)
self.check_add_new_key()
# select the first entry
selection_labels = selection_label_listbox.get_children()
if len(selection_labels) == 0:
self.add_empty()
selection_labels = selection_label_listbox.get_children()
selection_label_listbox.select_row(selection_labels[0])
def get_recording_toggle(self):
return self.get("key_recording_toggle")
def get_text_input(self):
return self.get("code_editor")
def get_key(self):
"""Get the Key object from the left column.
Or None if no code is mapped on this row.
"""
if self.active_selection_label is None:
return None
return self.active_selection_label.key
def set_symbol_input_text(self, symbol):
self.get("code_editor").get_buffer().set_text(symbol or "")
# move cursor location to the beginning, like any code editor does
Gtk.TextView.do_move_cursor(
self.get("code_editor"),
Gtk.MovementStep.BUFFER_ENDS,
-1,
False,
)
def get_symbol_input_text(self):
"""Get the assigned symbol from the text input.
This might not be stored in custom_mapping yet, and might therefore also not
be part of the preset json file yet.
If there is no symbol, this returns None. This is important for some other
logic down the road in custom_mapping or something.
"""
buffer = self.get("code_editor").get_buffer()
symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True)
if symbol == SET_KEY_FIRST:
# not configured yet
return ""
return symbol
def set_key(self, key):
"""Show what the user is currently pressing in the user interface."""
self.active_selection_label.set_key(key)
def get(self, name):
"""Get a widget from the window"""
return self.user_interface.builder.get_object(name)
def _on_recording_toggle_focus(self, *_):
"""Refresh useful usage information."""
self._reset_keycode_consumption()
reader.clear()
self.user_interface.can_modify_mapping()
def _on_delete_button_clicked(self, *_):
"""Destroy the row and remove it from the config."""
accept = Gtk.ResponseType.ACCEPT
if (
len(self.get_symbol_input_text()) > 0
and self._show_confirm_delete() != accept
):
return
key = self.get_key()
if key is not None:
custom_mapping.clear(key)
# make sure there is no outdated information lying around in memory
self.set_key(None)
self.load_custom_mapping()
def _show_confirm_delete(self):
"""Blocks until the user decided about an action."""
confirm_delete = self.get("confirm-delete")
text = f"Are you sure to delete this mapping?"
self.get("confirm-delete-label").set_text(text)
confirm_delete.show()
response = confirm_delete.run()
confirm_delete.hide()
return response
def gather_changes_and_save(self, *_):
"""Look into the ui if new changes should be written, and save the preset."""
# correct case
symbol = self.get_symbol_input_text()
if not symbol:
return
correct_case = system_mapping.correct_case(symbol)
if symbol != correct_case:
self.get_text_input().get_buffer().set_text(correct_case)
# make sure the custom_mapping is up to date
key = self.get_key()
if correct_case is not None and key is not None:
custom_mapping.change(key, correct_case)
# save to disk if required
if custom_mapping.has_unsaved_changes():
self.user_interface.save_preset()
def is_waiting_for_input(self):
"""Check if the user is interacting with the ToggleButton for key recording."""
return self.get_recording_toggle().get_active()
def consume_newest_keycode(self, key):
"""To capture events from keyboards, mice and gamepads.
Parameters
----------
key : Key or None
"""
self._switch_focus_if_complete()
if key is None:
return
if not self.is_waiting_for_input():
return
if not isinstance(key, Key):
raise TypeError("Expected new_key to be a Key object")
# keycode is already set by some other row
existing = custom_mapping.get_symbol(key)
if existing is not None:
existing = re.sub(r"\s", "", existing)
msg = f'"{key.beautify()}" already mapped to "{existing}"'
logger.info("%s %s", key, msg)
self.user_interface.show_status(CTX_KEYCODE, msg)
return True
if key.is_problematic():
self.user_interface.show_status(
CTX_WARNING,
"ctrl, alt and shift may not combine properly",
"Your system might reinterpret combinations "
+ "with those after they are injected, and by doing so "
+ "break them.",
)
# the newest_keycode is populated since the ui regularly polls it
# in order to display it in the status bar.
previous_key = self.get_key()
# it might end up being a key combination, wait for more
self._input_has_arrived = True
# keycode didn't change, do nothing
if key == previous_key:
logger.debug("%s didn't change", previous_key)
return
self.set_key(key)
symbol = self.get_symbol_input_text()
# the symbol is empty and therefore the mapping is not complete
if not symbol:
return
# else, the keycode has changed, the symbol is set, all good
custom_mapping.change(new_key=key, symbol=symbol, previous_key=previous_key)
def _switch_focus_if_complete(self):
"""If keys are released, it will switch to the text_input.
States:
1. not doing anything, waiting for the user to start using it
2. user focuses it, no keys pressed
3. user presses keys
4. user releases keys. no keys are pressed, just like in step 2, but this time
the focus needs to switch.
"""
if not self.is_waiting_for_input():
self._reset_keycode_consumption()
return
all_keys_released = reader.get_unreleased_keys() is None
if all_keys_released and self._input_has_arrived and self.get_key():
# A key was pressed and then released.
# Switch to the symbol. idle_add this so that the
# keycode event won't write into the symbol input as well.
window = self.user_interface.window
self.enable_symbol_input()
GLib.idle_add(lambda: window.set_focus(self.get_text_input()))
if not all_keys_released:
# currently the user is using the widget, and certain keys have already
# reached it.
self._input_has_arrived = True
return
self._reset_keycode_consumption()
def _reset_keycode_consumption(self, *_):
self._input_has_arrived = False

@ -61,7 +61,7 @@ class Reader:
self.previous_result = None
self._unreleased = {}
self._debounce_remove = {}
self._devices_updated = False
self._groups_updated = False
self._cleared_at = 0
self.group = None
@ -74,13 +74,13 @@ class Reader:
self._results = Pipe(f"/tmp/input-remapper-{USER}/results")
self._commands = Pipe(f"/tmp/input-remapper-{USER}/commands")
def are_new_devices_available(self):
def are_new_groups_available(self):
"""Check if groups contains new devices.
The ui should then update its list.
"""
outdated = self._devices_updated
self._devices_updated = False # assume the ui will react accordingly
outdated = self._groups_updated
self._groups_updated = False # assume the ui will react accordingly
return outdated
def _get_event(self, message):
@ -92,7 +92,7 @@ class Reader:
if message_body != groups.dumps():
groups.loads(message_body)
logger.debug("Received %d devices", len(groups))
self._devices_updated = True
self._groups_updated = True
return None
if message_type == "event":

@ -1,409 +0,0 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
# input-remapper 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.
#
# input-remapper 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 input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""A single, configurable key mapping."""
import evdev
from gi.repository import Gtk, GLib, Gdk
from inputremapper.system_mapping import system_mapping
from inputremapper.gui.custom_mapping import custom_mapping
from inputremapper.logger import logger
from inputremapper.key import Key
from inputremapper.gui.reader import reader
CTX_KEYCODE = 2
store = Gtk.ListStore(str)
def populate_store():
"""Fill the dropdown for key suggestions with values."""
for name in system_mapping.list_names():
store.append([name])
extra = [
"mouse(up, 1)",
"mouse(down, 1)",
"mouse(left, 1)",
"mouse(right, 1)",
"wheel(up, 1)",
"wheel(down, 1)",
"wheel(left, 1)",
"wheel(right, 1)",
]
for key in extra:
# add some more keys to the dropdown list
store.append([key])
populate_store()
def to_string(key):
"""A nice to show description of the pressed key."""
if isinstance(key, Key):
return " + ".join([to_string(sub_key) for sub_key in key])
if isinstance(key[0], tuple):
raise Exception("deprecated stuff")
ev_type, code, value = key
if ev_type not in evdev.ecodes.bytype:
logger.error("Unknown key type for %s", key)
return str(code)
if code not in evdev.ecodes.bytype[ev_type]:
logger.error("Unknown key code for %s", key)
return str(code)
key_name = None
# first try to find the name in xmodmap to not display wrong
# names due to the keyboard layout
if ev_type == evdev.ecodes.EV_KEY:
key_name = system_mapping.get_name(code)
if key_name is None:
# if no result, look in the linux key constants. On a german
# keyboard for example z and y are switched, which will therefore
# cause the wrong letter to be displayed.
key_name = evdev.ecodes.bytype[ev_type][code]
if isinstance(key_name, list):
key_name = key_name[0]
if ev_type != evdev.ecodes.EV_KEY:
direction = {
# D-Pad
(evdev.ecodes.ABS_HAT0X, -1): "Left",
(evdev.ecodes.ABS_HAT0X, 1): "Right",
(evdev.ecodes.ABS_HAT0Y, -1): "Up",
(evdev.ecodes.ABS_HAT0Y, 1): "Down",
(evdev.ecodes.ABS_HAT1X, -1): "Left",
(evdev.ecodes.ABS_HAT1X, 1): "Right",
(evdev.ecodes.ABS_HAT1Y, -1): "Up",
(evdev.ecodes.ABS_HAT1Y, 1): "Down",
(evdev.ecodes.ABS_HAT2X, -1): "Left",
(evdev.ecodes.ABS_HAT2X, 1): "Right",
(evdev.ecodes.ABS_HAT2Y, -1): "Up",
(evdev.ecodes.ABS_HAT2Y, 1): "Down",
# joystick
(evdev.ecodes.ABS_X, 1): "Right",
(evdev.ecodes.ABS_X, -1): "Left",
(evdev.ecodes.ABS_Y, 1): "Down",
(evdev.ecodes.ABS_Y, -1): "Up",
(evdev.ecodes.ABS_RX, 1): "Right",
(evdev.ecodes.ABS_RX, -1): "Left",
(evdev.ecodes.ABS_RY, 1): "Down",
(evdev.ecodes.ABS_RY, -1): "Up",
# wheel
(evdev.ecodes.REL_WHEEL, -1): "Down",
(evdev.ecodes.REL_WHEEL, 1): "Up",
(evdev.ecodes.REL_HWHEEL, -1): "Left",
(evdev.ecodes.REL_HWHEEL, 1): "Right",
}.get((code, value))
if direction is not None:
key_name += f" {direction}"
key_name = key_name.replace("ABS_Z", "Trigger Left")
key_name = key_name.replace("ABS_RZ", "Trigger Right")
key_name = key_name.replace("ABS_HAT0X", "DPad")
key_name = key_name.replace("ABS_HAT0Y", "DPad")
key_name = key_name.replace("ABS_HAT1X", "DPad 2")
key_name = key_name.replace("ABS_HAT1Y", "DPad 2")
key_name = key_name.replace("ABS_HAT2X", "DPad 3")
key_name = key_name.replace("ABS_HAT2Y", "DPad 3")
key_name = key_name.replace("ABS_X", "Joystick")
key_name = key_name.replace("ABS_Y", "Joystick")
key_name = key_name.replace("ABS_RX", "Joystick 2")
key_name = key_name.replace("ABS_RY", "Joystick 2")
key_name = key_name.replace("BTN_", "Button ")
key_name = key_name.replace("KEY_", "")
key_name = key_name.replace("REL_", "")
key_name = key_name.replace("HWHEEL", "Wheel")
key_name = key_name.replace("WHEEL", "Wheel")
key_name = key_name.replace("_", " ")
key_name = key_name.replace(" ", " ")
return key_name
IDLE = 0
HOLDING = 1
class Row(Gtk.ListBoxRow):
"""A single, configurable key mapping."""
__gtype_name__ = "ListBoxRow"
def __init__(self, delete_callback, window, key=None, symbol=None):
"""Construct a row widget.
Parameters
----------
key : Key
"""
if key is not None and not isinstance(key, Key):
raise TypeError("Expected key to be a Key object")
super().__init__()
self.device = window.group
self.window = window
self.delete_callback = delete_callback
self.symbol_input = None
self.keycode_input = None
self.key = key
self.put_together(symbol)
self._state = IDLE
def refresh_state(self):
"""Refresh the state.
The state is needed to switch focus when no keys are held anymore,
but only if the row has been in the HOLDING state before.
"""
old_state = self._state
if not self.keycode_input.is_focus():
self._state = IDLE
return
unreleased_keys = reader.get_unreleased_keys()
if unreleased_keys is None and old_state == HOLDING and self.key:
# A key was pressed and then released.
# Switch to the symbol. idle_add this so that the
# keycode event won't write into the symbol input as well.
window = self.window.window
GLib.idle_add(lambda: window.set_focus(self.symbol_input))
if unreleased_keys is not None:
self._state = HOLDING
return
self._state = IDLE
def get_key(self):
"""Get the Key object from the left column.
Or None if no code is mapped on this row.
"""
return self.key
def get_symbol(self):
"""Get the assigned symbol from the middle column."""
symbol = self.symbol_input.get_text()
return symbol if symbol else None
def set_new_key(self, new_key):
"""Check if a keycode has been pressed and if so, display it.
Parameters
----------
new_key : Key
"""
if new_key is not None and not isinstance(new_key, Key):
raise TypeError("Expected new_key to be a Key object")
# the newest_keycode is populated since the ui regularly polls it
# in order to display it in the status bar.
previous_key = self.get_key()
# no input
if new_key is None:
return
# it might end up being a key combination
self._state = HOLDING
# keycode didn't change, do nothing
if new_key == previous_key:
return
# keycode is already set by some other row
existing = custom_mapping.get_symbol(new_key)
if existing is not None:
msg = f'"{to_string(new_key)}" already mapped to "{existing}"'
logger.info(msg)
self.window.show_status(CTX_KEYCODE, msg)
return
# it's legal to display the keycode
# always ask for get_child to set the label, otherwise line breaking
# has to be configured again.
self.set_keycode_input_label(to_string(new_key))
self.key = new_key
symbol = self.get_symbol()
# the symbol is empty and therefore the mapping is not complete
if symbol is None:
return
# else, the keycode has changed, the symbol is set, all good
custom_mapping.change(new_key=new_key, symbol=symbol, previous_key=previous_key)
def on_symbol_input_change(self, _):
"""When the output symbol for that keycode is typed in."""
key = self.get_key()
symbol = self.get_symbol()
if symbol is None:
return
if key is not None:
custom_mapping.change(new_key=key, symbol=symbol, previous_key=None)
def match(self, _, key, tree_iter):
"""Search the avilable names."""
value = store.get_value(tree_iter, 0)
return key in value.lower()
def show_click_here(self):
"""Show 'click here' on the keycode input button."""
if self.get_key() is not None:
return
self.set_keycode_input_label("click here")
self.keycode_input.set_opacity(0.3)
def show_press_key(self):
"""Show 'press key' on the keycode input button."""
if self.get_key() is not None:
return
self.set_keycode_input_label("press key")
self.keycode_input.set_opacity(1)
def on_keycode_input_focus(self, *_):
"""Refresh useful usage information."""
reader.clear()
self.show_press_key()
self.window.can_modify_mapping()
def on_keycode_input_unfocus(self, *_):
"""Refresh useful usage information and set some state stuff."""
self.show_click_here()
self.keycode_input.set_active(False)
self._state = IDLE
self.window.save_preset()
def set_keycode_input_label(self, label):
"""Set the label of the keycode input."""
self.keycode_input.set_label(label)
# make the child label widget break lines, important for
# long combinations
label = self.keycode_input.get_child()
label.set_line_wrap(True)
label.set_line_wrap_mode(2)
label.set_max_width_chars(13)
label.set_justify(Gtk.Justification.CENTER)
self.keycode_input.set_opacity(1)
def on_symbol_input_unfocus(self, symbol_input, _):
"""Save the preset and correct the input casing."""
symbol = symbol_input.get_text()
correct_case = system_mapping.correct_case(symbol)
if symbol != correct_case:
symbol_input.set_text(correct_case)
self.window.save_preset()
def put_together(self, symbol):
"""Create all child GTK widgets and connect their signals."""
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_size_request(50, -1)
keycode_input = Gtk.ToggleButton()
self.keycode_input = keycode_input
keycode_input.set_size_request(140, -1)
if self.key is not None:
self.set_keycode_input_label(to_string(self.key))
else:
self.show_click_here()
# make the togglebutton go back to its normal state when doing
# something else in the UI
keycode_input.connect("focus-in-event", self.on_keycode_input_focus)
keycode_input.connect("focus-out-event", self.on_keycode_input_unfocus)
# don't leave the input when using arrow keys or tab. wait for the
# window to consume the keycode from the reader
keycode_input.connect("key-press-event", lambda *args: Gdk.EVENT_STOP)
symbol_input = Gtk.Entry()
self.symbol_input = symbol_input
symbol_input.set_alignment(0.5)
symbol_input.set_width_chars(4)
symbol_input.set_has_frame(False)
completion = Gtk.EntryCompletion()
completion.set_model(store)
completion.set_text_column(0)
completion.set_match_func(self.match)
symbol_input.set_completion(completion)
if symbol is not None:
symbol_input.set_text(symbol)
symbol_input.connect("changed", self.on_symbol_input_change)
symbol_input.connect("focus-out-event", self.on_symbol_input_unfocus)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
box.set_homogeneous(False)
box.set_spacing(0)
box.pack_start(keycode_input, expand=False, fill=True, padding=0)
box.pack_start(symbol_input, expand=True, fill=True, padding=0)
box.pack_start(delete_button, expand=False, fill=True, padding=0)
box.show_all()
box.get_style_context().add_class("row-box")
self.add(box)
self.show_all()
def on_delete_button_clicked(self, *_):
"""Destroy the row and remove it from the config."""
key = self.get_key()
if key is not None:
custom_mapping.clear(key)
self.symbol_input.set_text("")
self.set_keycode_input_label("")
self.key = None
self.delete_callback(self)

@ -24,14 +24,16 @@
import math
import os
import re
import sys
from gi.repository import Gtk, Gdk, GLib
from gi.repository import Gtk, GtkSource, Gdk, GLib, GObject
from inputremapper.data import get_data_path
from inputremapper.paths import get_config_path
from inputremapper.system_mapping import system_mapping
from inputremapper.gui.custom_mapping import custom_mapping
from inputremapper.gui.utils import HandlerDisabled
from inputremapper.presets import (
find_newest_preset,
get_presets,
@ -49,7 +51,7 @@ from inputremapper.groups import (
TOUCHPAD,
MOUSE,
)
from inputremapper.gui.row import Row, to_string
from inputremapper.gui.editor.editor import Editor
from inputremapper.key import Key
from inputremapper.gui.reader import reader
from inputremapper.gui.helper import is_helper_running
@ -57,19 +59,21 @@ from inputremapper.injection.injector import RUNNING, FAILED, NO_GRAB
from inputremapper.daemon import Daemon
from inputremapper.config import config
from inputremapper.injection.macros.parse import is_this_a_macro, parse
from inputremapper.gui.utils import (
CTX_ERROR,
CTX_MAPPING,
CTX_APPLY,
CTX_WARNING,
gtk_iteration,
)
def gtk_iteration():
"""Iterate while events are pending."""
while Gtk.events_pending():
Gtk.main_iteration()
# TODO add to .deb and AUR dependencies
# https://cjenkins.wordpress.com/2012/05/08/use-gtksourceview-widget-in-glade/
GObject.type_register(GtkSource.View)
# GtkSource.View() also works:
# https://stackoverflow.com/questions/60126579/gtk-builder-error-quark-invalid-object-type-webkitwebview
CTX_SAVE = 0
CTX_APPLY = 1
CTX_ERROR = 3
CTX_WARNING = 4
CTX_MAPPING = 5
CONTINUE = True
GO_BACK = False
@ -87,55 +91,50 @@ ICON_NAMES = {
ICON_PRIORITIES = [GRAPHICS_TABLET, TOUCHPAD, GAMEPAD, MOUSE, KEYBOARD, UNKNOWN]
def with_group(func):
def if_group_selected(func):
"""Decorate a function to only execute if a device is selected."""
# this should only happen if no device was found at all
def wrapped(window, *args):
if window.group is None:
def wrapped(self, *args, **kwargs):
if self.group is None:
return True # work with timeout_add
return func(window, *args)
return func(self, *args, **kwargs)
return wrapped
def with_preset_name(func):
def if_preset_selected(func):
"""Decorate a function to only execute if a preset is selected."""
# this should only happen if no device was found at all
def wrapped(window, *args):
if window.preset_name is None or window.group is None:
def wrapped(self, *args, **kwargs):
if self.preset_name is None or self.group is None:
return True # work with timeout_add
return func(window, *args)
return func(self, *args, **kwargs)
return wrapped
class HandlerDisabled:
"""Safely modify a widget without causing handlers to be called.
Use in a with statement.
"""
def on_close_about(about, _):
"""Hide the about dialog without destroying it."""
about.hide()
return True
def __init__(self, widget, handler):
self.widget = widget
self.handler = handler
def __enter__(self):
self.widget.handler_block_by_func(self.handler)
def ensure_everything_saved(func):
"""Make sure the editor has written its changes to custom_mapping and save."""
def __exit__(self, *_):
self.widget.handler_unblock_by_func(self.handler)
def wrapped(self, *args, **kwargs):
if self.preset_name:
self.editor.gather_changes_and_save()
return func(self, *args, **kwargs)
def on_close_about(about, _):
"""Hide the about dialog without destroying it."""
about.hide()
return True
return wrapped
class Window:
"""User Interface."""
class UserInterface:
"""The key mapper gtk window."""
def __init__(self):
self.dbus = None
@ -161,6 +160,8 @@ class Window:
builder.connect_signals(self)
self.builder = builder
self.editor = Editor(self)
# set up the device selection
# https://python-gtk-3-tutorial.readthedocs.io/en/latest/treeview.html#the-view
combobox = self.get("device_selection")
@ -183,7 +184,8 @@ class Window:
self.about.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
self.get("version-label").set_text(
f"input-remapper {VERSION} {COMMIT_HASH[:7]}" f"\npython-evdev {EVDEV_VERSION}"
f"input-remapper {VERSION} {COMMIT_HASH[:7]}"
f"\npython-evdev {EVDEV_VERSION}"
if EVDEV_VERSION
else ""
)
@ -221,7 +223,6 @@ class Window:
def setup_timeouts(self):
"""Setup all GLib timeouts."""
self.timeouts = [
GLib.timeout_add(100, self.check_add_row),
GLib.timeout_add(1000 / 30, self.consume_newest_keycode),
]
@ -250,13 +251,13 @@ class Window:
self.confirm_delete.hide()
return response
def key_press(self, _, event):
def on_key_press(self, _, event):
"""To execute shortcuts.
This has nothing to do with the keycode reader.
"""
_, focused = self.get_focused_row()
if isinstance(focused, Gtk.ToggleButton):
if self.editor.is_waiting_for_input():
# don't perform shortcuts while keys are being recorded
return
gdk_keycode = event.get_keyval()[1]
@ -275,7 +276,7 @@ class Window:
if gdk_keycode == Gdk.KEY_Delete:
self.on_restore_defaults_clicked()
def key_release(self, _, event):
def on_key_release(self, _, event):
"""To execute shortcuts.
This has nothing to do with the keycode reader.
@ -316,10 +317,10 @@ class Window:
"""Get a widget from the window"""
return self.builder.get_object(name)
@ensure_everything_saved
def on_close(self, *_):
"""Safely close the application."""
logger.debug("Closing window")
self.save_preset()
self.window.hide()
for timeout in self.timeouts:
GLib.source_remove(timeout)
@ -327,46 +328,16 @@ class Window:
reader.terminate()
Gtk.main_quit()
def check_add_row(self):
"""Ensure that one empty row is available at all times."""
rows = self.get("key_list").get_children()
# verify that all mappings are displayed.
# One of them is possibly the empty row
num_rows = len(rows)
num_maps = len(custom_mapping)
if num_rows < num_maps or num_rows > num_maps + 1:
logger.error(
"custom_mapping contains %d rows, but %d are displayed",
len(custom_mapping),
num_rows,
)
logger.spam("Mapping %s", list(custom_mapping))
logger.spam(
"Rows %s", [(row.get_key(), row.get_symbol()) for row in rows]
)
# iterating over that 10 times per second is a bit wasteful,
# but the old approach which involved just counting the number of
# mappings and rows didn't seem very robust.
for row in rows:
if row.get_key() is None or row.get_symbol() is None:
# unfinished row found
break
else:
self.add_empty()
return True
@ensure_everything_saved
def select_newest_preset(self):
"""Find and select the newest preset (and its device)."""
device, preset = find_newest_preset()
group = groups.find(name=device)
if device is not None:
self.get("device_selection").set_active_id(group.key)
group_name, preset = find_newest_preset()
if group_name is not None:
self.get("device_selection").set_active_id(group_name)
if preset is not None:
self.get("preset_selection").set_active_id(preset)
@ensure_everything_saved
def populate_devices(self):
"""Make the devices selectable."""
device_selection = self.get("device_selection")
@ -385,7 +356,8 @@ class Window:
self.select_newest_preset()
@with_group
@if_group_selected
@ensure_everything_saved
def populate_presets(self):
"""Show the available presets for the selected device.
@ -410,15 +382,10 @@ class Window:
for preset in presets:
preset_selection.append(preset, preset)
# and select the newest one (on the top). triggers on_select_preset
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)
custom_mapping.empty()
def can_modify_mapping(self, *_):
"""Show a message if changing the mapping is not possible."""
if self.dbus.get_state(self.group.key) != RUNNING:
@ -427,23 +394,7 @@ class Window:
# because the device is in grab mode by the daemon and
# therefore the original keycode inaccessible
logger.info("Cannot change keycodes while injecting")
self.show_status(CTX_ERROR, 'Use "Restore Defaults" to stop before editing')
def get_focused_row(self):
"""Get the Row and its child that is currently in focus."""
focused = self.window.get_focus()
if focused is None:
return None, None
box = focused.get_parent()
if box is None:
return None, None
row = box.get_parent()
if not isinstance(row, Row):
return None, None
return row, focused
self.show_status(CTX_ERROR, 'Use "Stop Injection" to stop before editing')
def consume_newest_keycode(self):
"""To capture events from keyboards, mice and gamepads."""
@ -456,32 +407,14 @@ class Window:
# they have already been read.
key = reader.read()
if reader.are_new_devices_available():
if reader.are_new_groups_available():
self.populate_devices()
# TODO highlight if a row for that key exists or something
# inform the currently selected row about the new keycode
row, focused = self.get_focused_row()
if key is not None:
if isinstance(focused, Gtk.ToggleButton):
row.set_new_key(key)
if key.is_problematic() and isinstance(focused, Gtk.ToggleButton):
self.show_status(
CTX_WARNING,
"ctrl, alt and shift may not combine properly",
"Your system might reinterpret combinations "
+ "with those after they are injected, and by doing so "
+ "break them.",
)
if row is not None:
row.refresh_state()
self.editor.consume_newest_keycode(key)
return True
@with_group
@if_group_selected
def on_restore_defaults_clicked(self, *_):
"""Stop injecting the mapping."""
self.dbus.stop_injecting(self.group.key)
@ -519,8 +452,9 @@ class Window:
if context_id == CTX_WARNING:
self.get("warning_status_icon").show()
if len(message) > 55:
message = message[:52] + "..."
max_length = 45
if len(message) > max_length:
message = message[: max_length - 3] + "..."
status_bar.push(context_id, message)
status_bar.set_tooltip_text(tooltip)
@ -536,10 +470,11 @@ class Window:
if error is None:
continue
position = to_string(key)
position = key.beautify()
msg = f"Syntax error at {position}, hover for info"
self.show_status(CTX_MAPPING, msg, error)
@ensure_everything_saved
def on_rename_button_clicked(self, _):
"""Rename the preset based on the contents of the name input."""
new_name = self.get("preset_name_input").get_text()
@ -547,8 +482,6 @@ class Window:
if new_name in ["", self.preset_name]:
return
self.save_preset()
new_name = rename_preset(self.group.name, self.preset_name, new_name)
# if the old preset was being autoloaded, change the
@ -556,24 +489,26 @@ class Window:
is_autoloaded = config.is_autoloaded(self.group.key, self.preset_name)
if is_autoloaded:
config.set_autoload_preset(self.group.key, new_name)
# TODO always save_config in set_autoload_preset?
config.save_config()
self.get("preset_name_input").set_text("")
self.populate_presets()
@with_preset_name
def on_delete_preset_clicked(self, _):
@if_preset_selected
def on_delete_preset_clicked(self, *_):
"""Delete a preset from the file system."""
accept = Gtk.ResponseType.ACCEPT
if len(custom_mapping) > 0 and self.show_confirm_delete() != accept:
return
custom_mapping.changed = False
# avoid having the text of the symbol input leak into the custom_mapping again
# via a gazillion hooks, causing the preset to be saved again after deleting.
self.editor.clear()
delete_preset(self.group.name, self.preset_name)
self.populate_presets()
@with_preset_name
@if_preset_selected
def on_apply_preset_clicked(self, _):
"""Apply a preset without saving changes."""
self.save_preset()
@ -581,14 +516,7 @@ class Window:
if custom_mapping.num_saved_keys == 0:
logger.error("Cannot apply empty preset file")
# also helpful for first time use
if custom_mapping.changed:
self.show_status(
CTX_ERROR,
"You need to save your changes first",
"No mappings are stored in the preset .json file yet",
)
else:
self.show_status(CTX_ERROR, "You need to add keys and save first")
self.show_status(CTX_ERROR, "You need to add keys and save first")
return
preset = self.preset_name
@ -636,26 +564,22 @@ class Window:
key = self.group.key
preset = self.preset_name
config.set_autoload_preset(key, preset if active else None)
config.save_config()
# tell the service to refresh its config
self.dbus.set_config_dir(get_config_path())
@ensure_everything_saved
def on_select_device(self, dropdown):
"""List all presets, create one if none exist yet."""
self.save_preset()
if self.group and dropdown.get_active_id() == self.group.key:
return
# selecting a device will also automatically select a different
# preset. Prevent another unsaved-changes dialog to pop up
custom_mapping.changed = False
group_key = dropdown.get_active_id()
if group_key is None:
return
self.editor.clear()
logger.debug('Selecting device "%s"', group_key)
self.group = groups.find(key=group_key)
@ -709,19 +633,19 @@ class Window:
else:
self.get("apply_system_layout").set_opacity(0.4)
@with_preset_name
def on_copy_preset_clicked(self, _):
@if_preset_selected
def on_copy_preset_clicked(self, *_):
"""Copy the current preset and select it."""
self.create_preset(True)
self.create_preset(copy=True)
@with_group
def on_create_preset_clicked(self, _):
"""Create a new preset and select it."""
@if_group_selected
def on_create_preset_clicked(self, *_):
"""Create a new empty preset and select it."""
self.create_preset()
@ensure_everything_saved
def create_preset(self, copy=False):
"""Create a new preset and select it."""
self.save_preset()
name = self.group.name
preset = self.preset_name
@ -730,57 +654,54 @@ class Window:
new_preset = get_available_preset_name(name, preset, copy)
else:
new_preset = get_available_preset_name(name)
self.editor.clear()
custom_mapping.empty()
path = self.group.get_preset_path(new_preset)
custom_mapping.save(path)
self.get("preset_selection").append(new_preset, new_preset)
# triggers on_select_preset
self.get("preset_selection").set_active_id(new_preset)
if self.get("preset_selection").get_active_id() != new_preset:
# for whatever reason I have to use set_active_id twice for this
# to work in tests all of the sudden
self.get("preset_selection").set_active_id(new_preset)
except PermissionError as error:
error = str(error)
self.show_status(CTX_ERROR, "Permission denied!", error)
logger.error(error)
@ensure_everything_saved
def on_select_preset(self, dropdown):
"""Show the mappings of the preset."""
# beware in tests that this function won't be called at all if the
# active_id stays the same
self.save_preset()
if dropdown.get_active_id() == self.preset_name:
return
self.clear_mapping_table()
preset = dropdown.get_active_text()
if preset is None:
return
logger.debug('Selecting preset "%s"', preset)
self.editor.clear()
self.preset_name = preset
custom_mapping.load(self.group.get_preset_path(preset))
key_list = self.get("key_list")
for key, output in custom_mapping:
single_key_mapping = Row(
window=self, delete_callback=self.on_row_removed, key=key, symbol=output
)
key_list.insert(single_key_mapping, -1)
self.editor.load_custom_mapping()
autoload_switch = self.get("preset_autoload_switch")
with HandlerDisabled(autoload_switch, self.on_autoload_switch):
autoload_switch.set_active(
config.is_autoloaded(self.group.key, self.preset_name)
)
is_autoloaded = config.is_autoloaded(self.group.key, self.preset_name)
autoload_switch.set_active(is_autoloaded)
self.get("preset_name_input").set_text("")
self.add_empty()
self.initialize_gamepad_config()
custom_mapping.changed = False
custom_mapping.set_has_unsaved_changes(False)
def on_left_joystick_changed(self, dropdown):
"""Set the purpose of the left joystick."""
@ -799,34 +720,18 @@ class Window:
speed = 2 ** gtk_range.get_value()
custom_mapping.set("gamepad.joystick.pointer_speed", speed)
def add_empty(self):
"""Add one empty row for a single mapped key."""
empty = Row(window=self, delete_callback=self.on_row_removed)
key_list = self.get("key_list")
key_list.insert(empty, -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)
def save_preset(self, *_):
"""Write changes to presets to disk."""
if not custom_mapping.changed:
"""Write changes in the custom_mapping to disk."""
if not custom_mapping.has_unsaved_changes():
# optimization, and also avoids tons of redundant logs
logger.spam("Not saving because mapping did not change")
return
try:
assert self.preset_name is not None
path = self.group.get_preset_path(self.preset_name)
custom_mapping.save(path)
custom_mapping.changed = False
# after saving the config, its modification date will be the
# newest, so populate_presets will automatically select the
# right one again.
@ -837,11 +742,15 @@ class Window:
logger.error(error)
for _, symbol in custom_mapping:
if not symbol:
continue
if is_this_a_macro(symbol):
continue
if system_mapping.get(symbol) is None:
self.show_status(CTX_MAPPING, f'Unknown mapping "{symbol}"')
trimmed = re.sub(r"\s+", " ", symbol).strip()
self.show_status(CTX_MAPPING, f'Unknown mapping "{trimmed}"')
break
else:
# no broken mappings found

@ -0,0 +1,54 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
# input-remapper 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.
#
# input-remapper 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 input-remapper. If not, see <https://www.gnu.org/licenses/>.
from gi.repository import Gtk
# status ctx ids
CTX_SAVE = 0
CTX_APPLY = 1
CTX_KEYCODE = 2
CTX_ERROR = 3
CTX_WARNING = 4
CTX_MAPPING = 5
class HandlerDisabled:
"""Safely modify a widget without causing handlers to be called.
Use in a with statement.
"""
def __init__(self, widget, handler):
self.widget = widget
self.handler = handler
def __enter__(self):
self.widget.handler_block_by_func(self.handler)
def __exit__(self, *_):
self.widget.handler_unblock_by_func(self.handler)
def gtk_iteration():
"""Iterate while events are pending."""
while Gtk.events_pending():
Gtk.main_iteration()

@ -30,7 +30,7 @@ import evdev
from evdev.ecodes import EV_KEY, EV_ABS
from inputremapper.logger import logger
from inputremapper.mapping import DISABLE_CODE
from inputremapper.system_mapping import DISABLE_CODE
from inputremapper import utils
from inputremapper.injection.consumers.consumer import Consumer
from inputremapper.utils import RELEASE

@ -31,7 +31,7 @@ from evdev.ecodes import EV_KEY, EV_REL
from inputremapper.logger import logger
from inputremapper.groups import classify, GAMEPAD
from inputremapper.mapping import DISABLE_CODE
from inputremapper.system_mapping import DISABLE_CODE
from inputremapper.injection.context import Context
from inputremapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock
from inputremapper.injection.consumer_control import ConsumerControl

@ -627,12 +627,12 @@ class Macro:
self.child_macros.append(otherwise)
async def task(handler):
mappable_event_1 = (self._newest_event.type, self._newest_event.code)
triggering_event = (self._newest_event.type, self._newest_event.code)
def event_filter(event, action):
"""Which event may wake if_tap up."""
"""Which event may wake if_single up."""
# release event of the actual key
if (event.type, event.code) == mappable_event_1:
if (event.type, event.code) == triggering_event:
return True
# press event of another key
@ -647,9 +647,12 @@ class Macro:
else:
await coroutine
mappable_event_2 = (self._newest_event.type, self._newest_event.code)
combined = mappable_event_1 != mappable_event_2
if not combined:
newest_event = (self._newest_event.type, self._newest_event.code)
# if newest_event == triggering_event, then no other key was pressed.
# if it is !=, then a new key was pressed in the meantime.
new_key_pressed = triggering_event != newest_event
if not new_key_pressed:
# no timeout and not combined
if then:
await then.run(handler)

@ -83,6 +83,18 @@ def use_safe_argument_names(keyword_args):
del keyword_args[built_in]
def get_macro_argument_names(function):
"""Certain names, like "else" or "type" cannot be used as parameters in python.
Removes the "_" in from of them for displaying them correctly.
"""
# don't include "self"
return [
name[1:] if name.startswith("_") else name
for name in inspect.getfullargspec(function).args[1:]
]
def get_num_parameters(function):
"""Get the number of required parameters and the maximum number of parameters."""
fullargspec = inspect.getfullargspec(function)
@ -224,8 +236,10 @@ def _parse_recurse(code, context, macro_instance=None, depth=0):
call = call_match[1] if call_match else None
if call is not None:
if macro_instance is None:
# start a new chain
macro_instance = Macro(code, context)
else:
# chain this call to the existing instance
assert isinstance(macro_instance, Macro)
function = FUNCTIONS.get(call)
@ -324,7 +338,7 @@ def handle_plus_syntax(macro):
return output
def _remove_whitespaces(macro, delimiter='"'):
def remove_whitespaces(macro, delimiter='"'):
"""Remove whitespaces, tabs, newlines and such outside of string quotes."""
result = ""
for i, chunk in enumerate(macro.split(delimiter)):
@ -339,7 +353,7 @@ def _remove_whitespaces(macro, delimiter='"'):
return result[: -len(delimiter)]
def _remove_comments(macro):
def remove_comments(macro):
"""Remove comments from the macro and return the resulting code."""
# keep hashtags inside quotes intact
result = ""
@ -364,6 +378,11 @@ def _remove_comments(macro):
return result
def clean(code):
"""Remove everything irrelevant for the macro."""
return remove_whitespaces(remove_comments(code), '"')
def parse(macro, context, return_errors=False):
"""parse and generate a Macro that can be run as often as you want.
@ -383,9 +402,7 @@ def parse(macro, context, return_errors=False):
"""
macro = handle_plus_syntax(macro)
macro = _remove_comments(macro)
macro = _remove_whitespaces(macro, '"')
macro = clean(macro)
if return_errors:
logger.spam("checking the syntax of %s", macro)

@ -24,8 +24,12 @@
import itertools
import evdev
from evdev import ecodes
from inputremapper.system_mapping import system_mapping
from inputremapper.logger import logger
def verify(key):
"""Check if the key is an int 3-tuple of type, code, value"""
@ -162,3 +166,100 @@ class Key:
permutations.append(Key(*permutation, self.keys[-1]))
return permutations
def beautify(self):
"""Get a human readable string representation."""
result = []
for sub_key in self:
if isinstance(sub_key[0], tuple):
raise Exception("deprecated stuff")
ev_type, code, value = sub_key
if ev_type not in evdev.ecodes.bytype:
logger.error("Unknown key type for %s", sub_key)
result.append(str(code))
continue
if code not in evdev.ecodes.bytype[ev_type]:
logger.error("Unknown key code for %s", sub_key)
result.append(str(code))
continue
key_name = None
# first try to find the name in xmodmap to not display wrong
# names due to the keyboard layout
if ev_type == evdev.ecodes.EV_KEY:
key_name = system_mapping.get_name(code)
if key_name is None:
# if no result, look in the linux key constants. On a german
# keyboard for example z and y are switched, which will therefore
# cause the wrong letter to be displayed.
key_name = evdev.ecodes.bytype[ev_type][code]
if isinstance(key_name, list):
key_name = key_name[0]
if ev_type != evdev.ecodes.EV_KEY:
direction = {
# D-Pad
(evdev.ecodes.ABS_HAT0X, -1): "Left",
(evdev.ecodes.ABS_HAT0X, 1): "Right",
(evdev.ecodes.ABS_HAT0Y, -1): "Up",
(evdev.ecodes.ABS_HAT0Y, 1): "Down",
(evdev.ecodes.ABS_HAT1X, -1): "Left",
(evdev.ecodes.ABS_HAT1X, 1): "Right",
(evdev.ecodes.ABS_HAT1Y, -1): "Up",
(evdev.ecodes.ABS_HAT1Y, 1): "Down",
(evdev.ecodes.ABS_HAT2X, -1): "Left",
(evdev.ecodes.ABS_HAT2X, 1): "Right",
(evdev.ecodes.ABS_HAT2Y, -1): "Up",
(evdev.ecodes.ABS_HAT2Y, 1): "Down",
# joystick
(evdev.ecodes.ABS_X, 1): "Right",
(evdev.ecodes.ABS_X, -1): "Left",
(evdev.ecodes.ABS_Y, 1): "Down",
(evdev.ecodes.ABS_Y, -1): "Up",
(evdev.ecodes.ABS_RX, 1): "Right",
(evdev.ecodes.ABS_RX, -1): "Left",
(evdev.ecodes.ABS_RY, 1): "Down",
(evdev.ecodes.ABS_RY, -1): "Up",
# wheel
(evdev.ecodes.REL_WHEEL, -1): "Down",
(evdev.ecodes.REL_WHEEL, 1): "Up",
(evdev.ecodes.REL_HWHEEL, -1): "Left",
(evdev.ecodes.REL_HWHEEL, 1): "Right",
}.get((code, value))
if direction is not None:
key_name += f" {direction}"
key_name = key_name.replace("ABS_Z", "Trigger Left")
key_name = key_name.replace("ABS_RZ", "Trigger Right")
key_name = key_name.replace("ABS_HAT0X", "DPad")
key_name = key_name.replace("ABS_HAT0Y", "DPad")
key_name = key_name.replace("ABS_HAT1X", "DPad 2")
key_name = key_name.replace("ABS_HAT1Y", "DPad 2")
key_name = key_name.replace("ABS_HAT2X", "DPad 3")
key_name = key_name.replace("ABS_HAT2Y", "DPad 3")
key_name = key_name.replace("ABS_X", "Joystick")
key_name = key_name.replace("ABS_Y", "Joystick")
key_name = key_name.replace("ABS_RX", "Joystick 2")
key_name = key_name.replace("ABS_RY", "Joystick 2")
key_name = key_name.replace("BTN_", "Button ")
key_name = key_name.replace("KEY_", "")
key_name = key_name.replace("REL_", "")
key_name = key_name.replace("HWHEEL", "Wheel")
key_name = key_name.replace("WHEEL", "Wheel")
key_name = key_name.replace("_", " ")
key_name = key_name.replace(" ", " ")
result.append(key_name)
return " + ".join(result)

@ -162,7 +162,10 @@ except pkg_resources.DistributionNotFound as error:
def log_info(name="input-remapper"):
"""Log version and name to the console."""
logger.info(
"%s %s %s https://github.com/sezanzeb/input-remapper", name, VERSION, COMMIT_HASH
"%s %s %s https://github.com/sezanzeb/input-remapper",
name,
VERSION,
COMMIT_HASH,
)
if EVDEV_VERSION:

@ -32,11 +32,7 @@ from inputremapper.logger import logger
from inputremapper.paths import touch
from inputremapper.config import ConfigBase, config
from inputremapper.key import Key
DISABLE_NAME = "disable"
DISABLE_CODE = -1
from inputremapper.injection.macros.parse import clean
def split_key(key):
@ -62,7 +58,7 @@ class Mapping(ConfigBase):
def __init__(self):
self._mapping = {} # a mapping of Key objects to strings
self.changed = False
self._changed = False
# are there actually any keys set in the mapping file?
self.num_saved_keys = 0
@ -78,12 +74,12 @@ class Mapping(ConfigBase):
def set(self, *args):
"""Set a config value. See `ConfigBase.set`."""
self.changed = True
self._changed = True
return super().set(*args)
def remove(self, *args):
"""Remove a config value. See `ConfigBase.remove`."""
self.changed = True
self._changed = True
return super().remove(*args)
def change(self, new_key, symbol, previous_key=None):
@ -105,22 +101,40 @@ class Mapping(ConfigBase):
if not isinstance(new_key, Key):
raise TypeError(f"Expected {new_key} to be a Key object")
if symbol is None:
raise ValueError("Expected `symbol` not to be None")
if symbol is None or symbol.strip() == "":
raise ValueError("Expected `symbol` not to be empty")
symbol = symbol.strip()
logger.debug('%s will map to "%s"', new_key, symbol)
if previous_key is None and self._mapping.get(new_key):
# the key didn't change
previous_key = new_key
key_changed = new_key != previous_key
if not key_changed and symbol == self._mapping.get(new_key):
# nothing was changed, no need to act
return
self.clear(new_key) # this also clears all equivalent keys
logger.debug('changing %s to "%s"', new_key, clean(symbol))
self._mapping[new_key] = symbol
if previous_key is not None:
code_changed = new_key != previous_key
if code_changed:
# clear previous mapping of that code, because the line
# representing that one will now represent a different one
self.clear(previous_key)
if key_changed and previous_key is not None:
# clear previous mapping of that code, because the line
# representing that one will now represent a different one
self.clear(previous_key)
self._changed = True
self.changed = True
def has_unsaved_changes(self):
"""Check if there are unsaved changed."""
return self._changed
def set_has_unsaved_changes(self, changed):
"""Write down if there are unsaved changes, or if they have been saved."""
self._changed = changed
def clear(self, key):
"""Remove a keycode from the mapping.
@ -130,20 +144,20 @@ class Mapping(ConfigBase):
key : Key
"""
if not isinstance(key, Key):
raise TypeError("Expected key to be a Key object")
raise TypeError(f"Expected key to be a Key object but got {key}")
for permutation in key.get_permutations():
if permutation in self._mapping:
logger.debug("%s will be cleared", permutation)
logger.debug("%s cleared", permutation)
del self._mapping[permutation]
self.changed = True
self._changed = True
# there should be only one variation of the permutations
# in the mapping actually
def empty(self):
"""Remove all mappings and custom configs without saving."""
self._mapping = {}
self.changed = True
self._changed = True
self.clear_config()
def load(self, path):
@ -158,7 +172,8 @@ class Mapping(ConfigBase):
if not os.path.exists(path):
raise FileNotFoundError(f'Tried to load non-existing preset "{path}"')
self.clear_config()
self.empty()
self._changed = False
with open(path, "r") as file:
preset_dict = json.load(file)
@ -188,7 +203,7 @@ class Mapping(ConfigBase):
if None in key:
continue
logger.spam("%s maps to %s", key, symbol)
logger.spam("%s maps to %s", key, clean(symbol))
self._mapping[key] = symbol
# add any metadata of the mapping
@ -197,14 +212,14 @@ class Mapping(ConfigBase):
continue
self._config[key] = preset_dict[key]
self.changed = False
self._changed = False
self.num_saved_keys = len(self)
def clone(self):
"""Create a copy of the mapping."""
mapping = Mapping()
mapping._mapping = copy.deepcopy(self._mapping)
mapping.changed = self.changed
mapping.set_has_unsaved_changes(self._changed)
return mapping
def save(self, path):
@ -236,7 +251,7 @@ class Mapping(ConfigBase):
json.dump(preset_dict, file, indent=4)
file.write("\n")
self.changed = False
self._changed = False
self.num_saved_keys = len(self)
def get_symbol(self, key):
@ -247,7 +262,7 @@ class Mapping(ConfigBase):
key : Key
"""
if not isinstance(key, Key):
raise TypeError("Expected key to be a Key object")
raise TypeError(f"Expected key to be a Key object but got {key}")
for permutation in key.get_permutations():
existing = self._mapping.get(permutation)

@ -131,7 +131,6 @@ def find_newest_preset(group_name=None):
break
if newest_path is None:
logger.debug("None of the configured devices is currently online")
return get_any_preset()
preset = os.path.splitext(preset)[0]

@ -28,10 +28,12 @@ import subprocess
import evdev
from inputremapper.logger import logger
from inputremapper.mapping import DISABLE_NAME, DISABLE_CODE
from inputremapper.paths import get_config_path, touch
from inputremapper.utils import is_service
DISABLE_NAME = "disable"
DISABLE_CODE = -1
# xkb uses keycodes that are 8 higher than those from evdev
XKB_KEYCODE_OFFSET = 8
@ -45,20 +47,25 @@ class SystemMapping:
def __init__(self):
"""Construct the system_mapping."""
self._mapping = None
self._xmodmap = {}
self._case_insensitive_mapping = {}
self._xmodmap = None
self._case_insensitive_mapping = None
def __getattribute__(self, key):
def __getattribute__(self, wanted):
"""To lazy load system_mapping info only when needed.
For example, this helps to keep logs of input-remapper-control clear when it doesnt
need it the information.
For example, this helps to keep logs of input-remapper-control clear when it
doesnt need it the information.
"""
if key == "_mapping" and object.__getattribute__(self, "_mapping") is None:
object.__setattr__(self, "_mapping", {})
object.__getattribute__(self, "populate")()
lazy_loaded_attributes = ["_mapping", "_xmodmap", "_case_insensitive_mapping"]
for lazy_loaded_attribute in lazy_loaded_attributes:
if wanted != lazy_loaded_attribute:
continue
if object.__getattribute__(self, lazy_loaded_attribute) is None:
object.__setattr__(self, lazy_loaded_attribute, {})
object.__getattribute__(self, "populate")()
return object.__getattribute__(self, key)
return object.__getattribute__(self, wanted)
def list_names(self):
"""Return an array of all possible names in the mapping."""

@ -143,7 +143,7 @@ msgid "Rename"
msgstr ""
#: data/input-remapper.glade:120
msgid "Restore Defaults"
msgid "Stop Injection"
msgstr ""
#: data/input-remapper.glade:509

@ -150,7 +150,7 @@ msgid "Rename"
msgstr "Rinomina"
#: data/input-remapper.glade:120
msgid "Restore Defaults"
msgid "Stop Injection"
msgstr "Ripristina impostazioni predefinite"
#: data/input-remapper.glade:509

@ -149,7 +149,7 @@ msgid "Rename"
msgstr "Premenovať"
#: data/input-remapper.glade:120
msgid "Restore Defaults"
msgid "Stop Injection"
msgstr "Obnoviť predvolené"
#: data/input-remapper.glade:509

@ -39,7 +39,7 @@ be mostly compliant with pylint.
- [x] map keys using a `modifier + modifier + ... + key` syntax
- [x] inject in an additional device instead to avoid clashing capabilities
- [x] don't run any GUI code as root for improved wayland compatibility
- [ ] macro editor with easier to read function names
- [x] advanced multiline editor
- [ ] plugin support
- [x] getting it into the official debian repo
@ -274,3 +274,4 @@ while input-remapper reads from multiple InputDevices it injects the mapped lett
- [python-evdev](https://python-evdev.readthedocs.io/en/stable/)
- [Python Unix Domain Sockets](https://pymotw.com/2/socket/uds.html)
- [GNOME HIG](https://developer.gnome.org/hig/stable/)
- [GtkSource Example](https://github.com/wolfthefallen/py-GtkSourceCompletion-example)

@ -17,7 +17,7 @@
<text x="22.0" y="14">pylint</text>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="63.0" y="15" fill="#010101" fill-opacity=".3">9.54</text>
<text x="62.0" y="14">9.54</text>
<text x="63.0" y="15" fill="#010101" fill-opacity=".3">9.39</text>
<text x="62.0" y="14">9.39</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 33 KiB

@ -19,7 +19,7 @@ can be found [below](#key-names-and-macros).
Changes are saved automatically. Afterwards press the "Apply" button.
To change the mapping, you need to use the "Restore Defaults" button, so that
To change the mapping, you need to use the "Stop Injection" button, so that
the application can read the original keycode. It would otherwise be
invisible since the daemon maps it independently of the GUI.

@ -93,7 +93,10 @@ lang_data = []
for po_file in glob.glob(PO_FILES):
lang = splitext(basename(po_file))[0]
lang_data.append(
(f"/usr/share/input-remapper/lang/{lang}/LC_MESSAGES", [f"mo/{lang}/input-remapper.mo"])
(
f"/usr/share/input-remapper/lang/{lang}/LC_MESSAGES",
[f"mo/{lang}/input-remapper.mo"],
)
)

@ -40,6 +40,7 @@ import gi
gi.require_version("Gtk", "3.0")
gi.require_version("GLib", "2.0")
gi.require_version("GtkSource", "4")
from xmodmap import xmodmap
@ -590,13 +591,13 @@ def quick_cleanup(log=True):
config.path = os.path.join(get_config_path(), "config.json")
config.clear_config()
config.save_config()
config._save_config()
system_mapping.populate()
custom_mapping.empty()
custom_mapping.clear_config()
custom_mapping.changed = False
custom_mapping.set_has_unsaved_changes(False)
clear_write_history()
@ -656,7 +657,7 @@ def main():
# so provide both options.
if len(modules) > 0:
# for example
# `tests/test.py test_integration.TestIntegration.test_can_start`
# `tests/test.py test_integration.TestGui.test_can_start`
# or `tests/test.py test_integration test_daemon`
testsuite = unittest.defaultTestLoader.loadTestsFromNames(
[f"testcases.{module}" for module in modules]

@ -22,9 +22,8 @@
import os
import unittest
from inputremapper.config import config, GlobalConfig
from inputremapper.paths import touch, CONFIG_PATH
from inputremapper.logger import logger
from inputremapper.config import config
from inputremapper.paths import touch
from tests.test import quick_cleanup, tmp
@ -84,7 +83,8 @@ class TestConfig(unittest.TestCase):
self.assertTrue(config.is_autoloaded("d2.foo", "c"))
self.assertEqual(config._config["autoload"]["d2.foo"], "c")
self.assertListEqual(
list(config.iterate_autoload_presets()), [("d1", "a"), ("d2.foo", "c")]
list(config.iterate_autoload_presets()),
[("d1", "a"), ("d2.foo", "c")],
)
config.set_autoload_preset("d2.foo", None)
@ -114,14 +114,11 @@ class TestConfig(unittest.TestCase):
config.set_autoload_preset("d1", "a")
config.set_autoload_preset("d2.foo", "b")
config.save_config()
# ignored after load
config.set_autoload_preset("d3", "c")
config.load_config()
self.assertListEqual(
list(config.iterate_autoload_presets()), [("d1", "a"), ("d2.foo", "b")]
list(config.iterate_autoload_presets()),
[("d1", "a"), ("d2.foo", "b")],
)
config_2 = os.path.join(tmp, "config_2.json")

@ -100,7 +100,6 @@ class TestControl(unittest.TestCase):
config.set_autoload_preset(groups_[0].key, presets[0])
config.set_autoload_preset(groups_[1].key, presets[1])
config.save_config()
communicate(options("autoload", None, None, None, False, False, False), daemon)
self.assertEqual(len(start_history), 2)
@ -157,7 +156,6 @@ class TestControl(unittest.TestCase):
)
self.assertEqual(stop_counter, 3)
config.set_autoload_preset(groups_[1].key, presets[2])
config.save_config()
communicate(
options("autoload", None, None, groups_[1].key, False, False, False), daemon
)
@ -215,10 +213,10 @@ class TestControl(unittest.TestCase):
config.load_config()
config.set_autoload_preset(device_names[0], presets[0])
config.set_autoload_preset(device_names[1], presets[1])
config.save_config()
communicate(
options("autoload", config_dir, None, None, False, False, False), daemon
options("autoload", config_dir, None, None, False, False, False),
daemon,
)
self.assertEqual(len(start_history), 2)
@ -239,13 +237,15 @@ class TestControl(unittest.TestCase):
daemon.stop_all = lambda *args: stop_all_history.append(args)
communicate(
options("start", None, preset, group.paths[0], False, False, False), daemon
options("start", None, preset, group.paths[0], False, False, False),
daemon,
)
self.assertEqual(len(start_history), 1)
self.assertEqual(start_history[0], (group.key, preset))
communicate(
options("stop", None, None, group.paths[1], False, False, False), daemon
options("stop", None, None, group.paths[1], False, False, False),
daemon,
)
self.assertEqual(len(stop_history), 1)
# provided any of the groups paths as --device argument, figures out

@ -100,7 +100,7 @@ class TestDaemon(unittest.TestCase):
self.grab = evdev.InputDevice.grab
self.daemon = None
mkdir(get_config_path())
config.save_config()
config._save_config()
def tearDown(self):
# avoid race conditions with other tests, daemon may run processes
@ -329,7 +329,7 @@ class TestDaemon(unittest.TestCase):
# to use the directory
config_path = os.path.join(config_dir, "config.json")
config.path = config_path
config.save_config()
config._save_config()
xmodmap_path = os.path.join(config_dir, "xmodmap.json")
with open(xmodmap_path, "w") as file:
@ -418,7 +418,6 @@ class TestDaemon(unittest.TestCase):
self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset))
config.set_autoload_preset(group.key, preset)
config.save_config()
len_before = len(self.daemon.autoload_history._autoload_history)
# now autoloading is configured, so it will autoload
self.daemon._autoload(group.key)
@ -483,7 +482,6 @@ class TestDaemon(unittest.TestCase):
mapping.save(group.get_preset_path(preset))
config.set_autoload_preset(group.key, preset)
config.save_config()
self.daemon = Daemon()
groups.set_groups([]) # caused the bug

File diff suppressed because it is too large Load Diff

@ -58,9 +58,9 @@ from inputremapper.injection.injector import (
get_udev_name,
)
from inputremapper.injection.numlock import is_numlock_on, set_numlock, ensure_numlock
from inputremapper.system_mapping import system_mapping
from inputremapper.system_mapping import system_mapping, DISABLE_CODE, DISABLE_NAME
from inputremapper.gui.custom_mapping import custom_mapping
from inputremapper.mapping import Mapping, DISABLE_CODE, DISABLE_NAME
from inputremapper.mapping import Mapping
from inputremapper.config import config, NONE, MOUSE, WHEEL, BUTTONS
from inputremapper.key import Key
from inputremapper.injection.macros.parse import parse
@ -988,7 +988,7 @@ class TestModifyCapabilities(unittest.TestCase):
def test_construct_capabilities(self):
self.mapping.change(Key(EV_KEY, 60, 1), self.macro.code)
self.injector = Injector(groups.find(name="foo"), self.mapping)
self.injector = Injector(None, self.mapping)
self.injector.context = Context(self.mapping)
capabilities = self.injector._construct_capabilities(gamepad=False)
@ -1009,7 +1009,7 @@ class TestModifyCapabilities(unittest.TestCase):
# I don't know what ABS_VOLUME is, for now I would like to just always
# remove it until somebody complains, since its presence broke stuff
self.injector = Injector(groups.find(name="foo"), self.mapping)
self.injector = Injector(None, self.mapping)
self.fake_device._capabilities = {
EV_ABS: [ABS_VOLUME, (ABS_X, evdev.AbsInfo(0, 0, 500, 0, 0, 0))],
EV_KEY: [1, 2, 3],
@ -1032,7 +1032,7 @@ class TestModifyCapabilities(unittest.TestCase):
config.set("gamepad.joystick.left_purpose", MOUSE)
self.mapping.set("gamepad.joystick.right_purpose", WHEEL)
self.injector = Injector(groups.find(name="foo"), self.mapping)
self.injector = Injector(None, self.mapping)
self.injector.context = Context(self.mapping)
self.assertTrue(self.injector.context.maps_joystick())
self.assertTrue(self.injector.context.joystick_as_mouse())
@ -1054,7 +1054,7 @@ class TestModifyCapabilities(unittest.TestCase):
config.set("gamepad.joystick.left_purpose", NONE)
self.mapping.set("gamepad.joystick.right_purpose", NONE)
self.injector = Injector(groups.find(name="foo"), self.mapping)
self.injector = Injector(None, self.mapping)
self.injector.context = Context(self.mapping)
self.assertFalse(self.injector.context.maps_joystick())
self.assertFalse(self.injector.context.joystick_as_mouse())
@ -1071,7 +1071,7 @@ class TestModifyCapabilities(unittest.TestCase):
config.set("gamepad.joystick.left_purpose", BUTTONS)
self.mapping.set("gamepad.joystick.right_purpose", BUTTONS)
self.injector = Injector(groups.find(name="foo"), self.mapping)
self.injector = Injector(None, self.mapping)
self.injector.context = Context(self.mapping)
self.assertTrue(self.injector.context.maps_joystick())
self.assertFalse(self.injector.context.joystick_as_mouse())
@ -1090,7 +1090,7 @@ class TestModifyCapabilities(unittest.TestCase):
config.set("gamepad.joystick.left_purpose", BUTTONS)
self.mapping.set("gamepad.joystick.right_purpose", BUTTONS)
self.injector = Injector(groups.find(name="foo"), self.mapping)
self.injector = Injector(None, self.mapping)
self.injector.context = Context(self.mapping)
capabilities = self.injector._construct_capabilities(gamepad=False)

File diff suppressed because it is too large Load Diff

@ -46,7 +46,8 @@ from inputremapper.injection.macros.parse import parse
from inputremapper.injection.context import Context
from inputremapper.utils import RELEASE, PRESS
from inputremapper.config import config, BUTTONS
from inputremapper.mapping import Mapping, DISABLE_CODE
from inputremapper.mapping import Mapping
from inputremapper.system_mapping import DISABLE_CODE
from tests.test import (
new_event,

@ -55,8 +55,8 @@ from inputremapper.injection.macros.parse import (
handle_plus_syntax,
_count_brackets,
_split_keyword_arg,
_remove_whitespaces,
_remove_comments,
remove_whitespaces,
remove_comments,
)
from inputremapper.injection.context import Context
from inputremapper.config import config
@ -105,37 +105,37 @@ class TestMacros(MacroTestBase):
await parse("k(1, b=2, c=3)", self.context).run(self.handler)
self.assertListEqual(result, [(1, 2, 3, 4), (1, 2, 3, 400)])
def test_remove_whitespaces(self):
self.assertEqual(_remove_whitespaces('foo"bar"foo'), 'foo"bar"foo')
self.assertEqual(_remove_whitespaces('foo" bar"foo'), 'foo" bar"foo')
self.assertEqual(_remove_whitespaces('foo" bar"fo" "o'), 'foo" bar"fo" "o')
self.assertEqual(_remove_whitespaces(' fo o"\nba r "f\noo'), 'foo"\nba r "foo')
self.assertEqual(_remove_whitespaces(' a " b " c " '), 'a" b "c" ')
def testremove_whitespaces(self):
self.assertEqual(remove_whitespaces('foo"bar"foo'), 'foo"bar"foo')
self.assertEqual(remove_whitespaces('foo" bar"foo'), 'foo" bar"foo')
self.assertEqual(remove_whitespaces('foo" bar"fo" "o'), 'foo" bar"fo" "o')
self.assertEqual(remove_whitespaces(' fo o"\nba r "f\noo'), 'foo"\nba r "foo')
self.assertEqual(remove_whitespaces(' a " b " c " '), 'a" b "c" ')
self.assertEqual(_remove_whitespaces('"""""""""'), '"""""""""')
self.assertEqual(_remove_whitespaces('""""""""'), '""""""""')
self.assertEqual(remove_whitespaces('"""""""""'), '"""""""""')
self.assertEqual(remove_whitespaces('""""""""'), '""""""""')
self.assertEqual(_remove_whitespaces(" "), "")
self.assertEqual(_remove_whitespaces(' " '), '" ')
self.assertEqual(_remove_whitespaces(' " " '), '" "')
self.assertEqual(remove_whitespaces(" "), "")
self.assertEqual(remove_whitespaces(' " '), '" ')
self.assertEqual(remove_whitespaces(' " " '), '" "')
self.assertEqual(_remove_whitespaces("a# ##b", delimiter="##"), "a###b")
self.assertEqual(_remove_whitespaces("a###b", delimiter="##"), "a###b")
self.assertEqual(_remove_whitespaces("a## #b", delimiter="##"), "a## #b")
self.assertEqual(_remove_whitespaces("a## ##b", delimiter="##"), "a## ##b")
self.assertEqual(remove_whitespaces("a# ##b", delimiter="##"), "a###b")
self.assertEqual(remove_whitespaces("a###b", delimiter="##"), "a###b")
self.assertEqual(remove_whitespaces("a## #b", delimiter="##"), "a## #b")
self.assertEqual(remove_whitespaces("a## ##b", delimiter="##"), "a## ##b")
def test_remove_comments(self):
self.assertEqual(_remove_comments("a#b"), "a")
self.assertEqual(_remove_comments('"a#b"'), '"a#b"')
self.assertEqual(_remove_comments('a"#"#b'), 'a"#"')
self.assertEqual(_remove_comments('a"#""#"#b'), 'a"#""#"')
self.assertEqual(_remove_comments('#a"#""#"#b'), "")
def testremove_comments(self):
self.assertEqual(remove_comments("a#b"), "a")
self.assertEqual(remove_comments('"a#b"'), '"a#b"')
self.assertEqual(remove_comments('a"#"#b'), 'a"#"')
self.assertEqual(remove_comments('a"#""#"#b'), 'a"#""#"')
self.assertEqual(remove_comments('#a"#""#"#b'), "")
self.assertEqual(
re.sub(
r"\s",
"",
_remove_comments(
remove_comments(
"""
# a
b
@ -179,7 +179,7 @@ class TestMacros(MacroTestBase):
self.assertEqual(_type_check("1", [int, None], "foo", 1), 1)
self.assertEqual(_type_check(1.2, [str], "foo", 2), "1.2")
self.assertRaises(TypeError, lambda: _type_check("1.2", [int], "foo", 3), 1.2)
self.assertRaises(TypeError, lambda: _type_check("1.2", [int], "foo", 3))
self.assertRaises(TypeError, lambda: _type_check("a", [None], "foo", 0))
self.assertRaises(TypeError, lambda: _type_check("a", [int], "foo", 1))
self.assertRaises(TypeError, lambda: _type_check("a", [int, float], "foo", 2))

@ -22,8 +22,9 @@
import os
import unittest
import json
from unittest.mock import patch
from evdev.ecodes import EV_KEY, EV_ABS, ABS_HAT0X, KEY_A
from evdev.ecodes import EV_KEY, EV_ABS, KEY_A
from inputremapper.mapping import Mapping, split_key
from inputremapper.system_mapping import SystemMapping, XMODMAP_FILENAME
@ -129,7 +130,7 @@ class TestSystemMapping(unittest.TestCase):
class TestMapping(unittest.TestCase):
def setUp(self):
self.mapping = Mapping()
self.assertFalse(self.mapping.changed)
self.assertFalse(self.mapping.has_unsaved_changes())
def tearDown(self):
quick_cleanup()
@ -139,11 +140,11 @@ class TestMapping(unittest.TestCase):
self.assertEqual(self.mapping.get("a"), None)
self.assertFalse(self.mapping.changed)
self.assertFalse(self.mapping.has_unsaved_changes())
self.mapping.set("a", 1)
self.assertEqual(self.mapping.get("a"), 1)
self.assertTrue(self.mapping.changed)
self.assertTrue(self.mapping.has_unsaved_changes())
self.mapping.remove("a")
self.mapping.set("a.b", 2)
@ -162,15 +163,15 @@ class TestMapping(unittest.TestCase):
self.assertEqual(self.mapping.num_saved_keys, 0)
self.mapping.save(get_preset_path("foo", "bar"))
self.assertEqual(self.mapping.num_saved_keys, len(self.mapping))
self.assertFalse(self.mapping.changed)
self.assertFalse(self.mapping.has_unsaved_changes())
self.mapping.load(get_preset_path("foo", "bar"))
self.assertEqual(self.mapping.get_symbol(Key(EV_KEY, 81, 1)), "a")
self.assertIsNone(self.mapping.get("mapping.a"))
self.assertFalse(self.mapping.changed)
self.assertFalse(self.mapping.has_unsaved_changes())
# loading a different preset also removes the configs from memory
self.mapping.remove("a")
self.assertTrue(self.mapping.changed)
self.assertTrue(self.mapping.has_unsaved_changes())
self.mapping.set("a.b.c", 6)
self.mapping.load(get_preset_path("foo", "bar2"))
self.assertIsNone(self.mapping.get("a.b.c"))
@ -233,7 +234,7 @@ class TestMapping(unittest.TestCase):
# 1 is not assigned yet, ignore it
self.mapping.change(ev_1, "a", ev_2)
self.assertTrue(self.mapping.changed)
self.assertTrue(self.mapping.has_unsaved_changes())
self.assertIsNone(self.mapping.get_symbol(ev_2))
self.assertEqual(self.mapping.get_symbol(ev_1), "a")
self.assertEqual(len(self.mapping), 1)
@ -262,6 +263,26 @@ class TestMapping(unittest.TestCase):
self.assertEqual(self.mapping.num_saved_keys, 0)
def test_rejects_empty(self):
key = Key(EV_KEY, 1, 111)
self.assertEqual(len(self.mapping), 0)
self.assertRaises(ValueError, lambda: self.mapping.change(key, " \n "))
self.assertEqual(len(self.mapping), 0)
def test_avoids_redundant_changes(self):
# to avoid logs that don't add any value
def clear(*_):
# should not be called
raise AssertionError
key = Key(EV_KEY, 987, 1)
symbol = "foo"
self.mapping.change(key, symbol)
with patch.object(self.mapping, "clear", clear):
self.mapping.change(key, symbol)
self.mapping.change(key, symbol, previous_key=key)
def test_combinations(self):
ev_1 = Key(EV_KEY, 1, 111)
ev_2 = Key(EV_KEY, 1, 222)
@ -297,17 +318,17 @@ class TestMapping(unittest.TestCase):
ev_4 = Key(EV_KEY, 10, 1)
self.mapping.clear(ev_1)
self.assertFalse(self.mapping.changed)
self.assertFalse(self.mapping.has_unsaved_changes())
self.assertEqual(len(self.mapping), 0)
self.mapping._mapping[ev_1] = "b"
self.assertEqual(len(self.mapping), 1)
self.mapping.clear(ev_1)
self.assertEqual(len(self.mapping), 0)
self.assertTrue(self.mapping.changed)
self.assertTrue(self.mapping.has_unsaved_changes())
self.mapping.change(ev_4, "KEY_KP1", None)
self.assertTrue(self.mapping.changed)
self.assertTrue(self.mapping.has_unsaved_changes())
self.mapping.change(ev_3, "KEY_KP2", None)
self.mapping.change(ev_2, "KEY_KP3", None)
self.assertEqual(len(self.mapping), 3)

@ -62,7 +62,7 @@ class TestMigrations(unittest.TestCase):
self.assertTrue(new.startswith("/tmp"))
try:
os.rmdir(new)
shutil.rmtree(new)
except FileNotFoundError:
pass

@ -172,6 +172,7 @@ class TestFindPresets(unittest.TestCase):
path = os.path.join(PRESETS, "Bar Device", "picture.png")
os.mknod(path)
os.system("find /tmp/input-remapper-test/")
self.assertEqual(find_newest_preset(), ("Bar Device", "preset 2"))
def test_find_newest_preset_2(self):

@ -549,29 +549,29 @@ class TestReader(unittest.TestCase):
time.sleep(EVENT_READ_TIMEOUT * 3)
self.assertFalse(reader._results.poll())
def test_are_new_devices_available(self):
def test_are_new_groups_available(self):
self.create_helper()
groups.set_groups({})
# read stuff from the helper, which includes the devices
self.assertFalse(reader.are_new_devices_available())
self.assertFalse(reader.are_new_groups_available())
reader.read()
self.assertTrue(reader.are_new_devices_available())
self.assertTrue(reader.are_new_groups_available())
# a bit weird, but it assumes the gui handled that and returns
# false afterwards
self.assertFalse(reader.are_new_devices_available())
self.assertFalse(reader.are_new_groups_available())
# send the same devices again
reader._get_event({"type": "groups", "message": groups.dumps()})
self.assertFalse(reader.are_new_devices_available())
self.assertFalse(reader.are_new_groups_available())
# send changed devices
message = groups.dumps()
message = message.replace("Foo Device", "foo_device")
reader._get_event({"type": "groups", "message": message})
self.assertTrue(reader.are_new_devices_available())
self.assertFalse(reader.are_new_devices_available())
self.assertTrue(reader.are_new_groups_available())
self.assertFalse(reader.are_new_groups_available())
if __name__ == "__main__":

Loading…
Cancel
Save