From d18505679d6572af7dcef46d4bcc77d9d2b068e4 Mon Sep 17 00:00:00 2001 From: Quantum Snowball Date: Thu, 9 Jun 2022 19:18:36 +0800 Subject: [PATCH 01/12] license, readme draft --- LICENSE | 21 +++++++++++++++++++++ README.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 LICENSE create mode 100644 README.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c4acfa0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Quantum Snowball + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bf9e4f --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# wayland-keymapper + +I started to code this program because I want to use RightAlt + hjkl as system-wide arrow keys. Everyone has been using `xmodmap` to remap keys on linux. However, after switching to Wayland window system, `xmodmap` stopped working. I started to look for ready-to-use solution but with no luck. Probably Wayland are still too new for the community to catch up. Luckly, Google gave me some hints on how to achieve this task. The key is to go to lower level! This module makes use of `evdev` and `uinput` module to achieve keymapping. Therefore it should work on any Desktop, such as X11 or Wayland. + +## Install +Simply install using `pip` and run it as a cli application: +``` +pip install wayland-keymapper +``` +It is recommended to install the package to its own virtual environment, especially when installed as a linux system service. For example, install using `pipx`: +``` +pipx install wayland-keymapper +``` + +## Usage +Your can run the keymapper directly by supplying the config file. +```bash +sudo wayland-keymapper +``` +You can see the key event inputs and outputs printed out in real time by running in debug mode. You can also check the string name required by the config file. +```bash +sudo wayland-keymapper --debug +``` + +## Configuration +The program accept a config file path as argument. The config file should be in yaml format. +```yaml +# keymaps.yaml + +- type: swap, + key1: KEY_CAPSLOCK, + key2: KEY_ESC +# map one single key with another +- type: map, + from: KEY_LEFTALT, + to: KEY_LEFTCTRL +# map 2-keys-chord into a new key +- type: map, + from: + modifier: KEY_RIGHTALT, + key: KEY_K, + to: KEY_UP + +``` + +## Install as Linux system services + +After writing your config file and test running it without problem, your can install the program as a system services. This should w automatically start the keymapper when you login. + +``` +TODO +``` From d35c80c4905e0bb86edc673d033b1dc1efbc2bc0 Mon Sep 17 00:00:00 2001 From: Quantum Snowball Date: Thu, 9 Jun 2022 19:35:30 +0800 Subject: [PATCH 02/12] read a user config file as dict --- keymapper.py | 11 ++++++++++- keymaps.yaml | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 keymaps.yaml diff --git a/keymapper.py b/keymapper.py index dd96c40..540ead4 100644 --- a/keymapper.py +++ b/keymapper.py @@ -2,6 +2,7 @@ import traceback import evdev import uinput +import yaml import logging @@ -18,8 +19,16 @@ def get_event_name(value): def main(): + # parse user config file + keymaps = None + with open('keymaps.yaml') as f: + keymaps = yaml.safe_load(f) + if not keymaps: + raise Exception('Error in reading user config file') + breakpoint() + # start capturing from evdev try: - path = '/dev/input/event20' + path = '/dev/input/event21' kb = evdev.InputDevice(path) kb.grab() logging.info('Device:', kb) diff --git a/keymaps.yaml b/keymaps.yaml new file mode 100644 index 0000000..d1c9175 --- /dev/null +++ b/keymaps.yaml @@ -0,0 +1,15 @@ +# simply swap two single key +- type: swap, + key1: KEY_CAPSLOCK, + key2: KEY_ESC +# map one single key with another +- type: map, + from: KEY_LEFTALT, + to: KEY_LEFTCTRL +# map 2-keys-chord into a new key +- type: map, + from: + modifier: KEY_RIGHTALT, + key: KEY_K, + to: KEY_UP +# more key combo options is coming From a2f51e96c0ef3b471b548ae2204ea3686f37cbc4 Mon Sep 17 00:00:00 2001 From: Quantum Snowball Date: Thu, 9 Jun 2022 19:37:51 +0800 Subject: [PATCH 03/12] gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80e1023 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.python-version +pyrightconfig.json +__pycache__ From 97e4fbf167104452428dd269b9b1ab67c0226e1d Mon Sep 17 00:00:00 2001 From: Quantum Snowball Date: Thu, 9 Jun 2022 20:04:09 +0800 Subject: [PATCH 04/12] better config file structure --- keymapper.py | 10 +++++++--- keymaps.yaml | 21 ++++++++++----------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/keymapper.py b/keymapper.py index 540ead4..27709d2 100644 --- a/keymapper.py +++ b/keymapper.py @@ -25,7 +25,13 @@ def main(): keymaps = yaml.safe_load(f) if not keymaps: raise Exception('Error in reading user config file') - breakpoint() + # organize into swap list and map list + swap_list = tuple((m['target1'], m['target2']) + for m in keymaps if m['type'] == 'swap') + map_list = tuple((m['source'], m['target']) + for m in keymaps if m['type'] == 'map') + combo_list = tuple((m['modifier'], m['source'], m['target']) + for m in keymaps if m['type'] == 'combo') # start capturing from evdev try: path = '/dev/input/event21' @@ -78,8 +84,6 @@ def main(): type_out, code_out = uinput.KEY_DELETE if code_in == uinput.KEY_O[1]: type_out, code_out = uinput.KEY_DELETE - # fake the OS right alt key is released - # vdev.emit(uinput.KEY_RIGHTALT, 0) # send back all event vdev.emit((type_out, code_out), value_out) # log diff --git a/keymaps.yaml b/keymaps.yaml index d1c9175..d01ad6b 100644 --- a/keymaps.yaml +++ b/keymaps.yaml @@ -1,15 +1,14 @@ # simply swap two single key -- type: swap, - key1: KEY_CAPSLOCK, - key2: KEY_ESC +- type: swap + target1: KEY_CAPSLOCK + target2: KEY_ESC # map one single key with another -- type: map, - from: KEY_LEFTALT, - to: KEY_LEFTCTRL +- type: map + source: KEY_LEFTALT + target: KEY_LEFTCTRL # map 2-keys-chord into a new key -- type: map, - from: - modifier: KEY_RIGHTALT, - key: KEY_K, - to: KEY_UP +- type: combo + modifier: KEY_RIGHTALT + source: KEY_K + target: KEY_UP # more key combo options is coming From ae28b8c1e2424be295f8366b07f94d40eae2cf86 Mon Sep 17 00:00:00 2001 From: Quantum Snowball Date: Thu, 9 Jun 2022 20:38:10 +0800 Subject: [PATCH 05/12] try refactor filtering jobs to a new class --- filter.py | 20 ++++++++++++++++++++ keymapper.py | 23 ++++++++--------------- 2 files changed, 28 insertions(+), 15 deletions(-) create mode 100644 filter.py diff --git a/filter.py b/filter.py new file mode 100644 index 0000000..4e59359 --- /dev/null +++ b/filter.py @@ -0,0 +1,20 @@ +import yaml + + +class Filter: + def __init__(self, path: str): + with open(path) as f: + keymaps = yaml.safe_load(f) + # organize into swap list and map list + self._swap = tuple((m['target1'], m['target2']) + for m in keymaps if m['type'] == 'swap') + self._map = tuple((m['source'], m['target']) + for m in keymaps if m['type'] == 'map') + self._combo = tuple((m['modifier'], m['source'], m['target']) + for m in keymaps if m['type'] == 'combo') + self._onMods = {k[0]:False for k in self._combo} + + + def target(self, type: int, code: int, value: int) -> tuple: + return () + diff --git a/keymapper.py b/keymapper.py index 27709d2..4cc3035 100644 --- a/keymapper.py +++ b/keymapper.py @@ -4,6 +4,7 @@ import uinput import yaml import logging +from filter import Filter logging.basicConfig(level=logging.INFO, format='%(message)s') @@ -13,28 +14,19 @@ KEYS = {k:v for k,v in EVENTS.items() if k.startswith('KEY_')} -def get_event_name(value): +def get_event_name(value: tuple) -> str: keys = {v:k for k,v in EVENTS.items()} return keys.get(value, '') +def event_is_modifier(onMods: dict) -> bool: + return False + def main(): - # parse user config file - keymaps = None - with open('keymaps.yaml') as f: - keymaps = yaml.safe_load(f) - if not keymaps: - raise Exception('Error in reading user config file') - # organize into swap list and map list - swap_list = tuple((m['target1'], m['target2']) - for m in keymaps if m['type'] == 'swap') - map_list = tuple((m['source'], m['target']) - for m in keymaps if m['type'] == 'map') - combo_list = tuple((m['modifier'], m['source'], m['target']) - for m in keymaps if m['type'] == 'combo') + filter = Filter('keymaps.yaml') # start capturing from evdev try: - path = '/dev/input/event21' + path = '/dev/input/event22' kb = evdev.InputDevice(path) kb.grab() logging.info('Device:', kb) @@ -42,6 +34,7 @@ def main(): with uinput.Device(KEYS.values()) as vdev: # keep track of if alt is press in last loop isRalt = False + # loop to capture every event for ev in kb.read_loop(): # look at each event type_in, code_in, value_in = ev.type, ev.code, ev.value From 06ead58a9d8c3f40fc9cfb589a604e42fa1c07a7 Mon Sep 17 00:00:00 2001 From: Quantum Snowball Date: Thu, 9 Jun 2022 22:41:12 +0800 Subject: [PATCH 06/12] feature: 1. refactor mapping heavy load to filter class 2. keymapper only handle exception and logging 3. moved mapping dicts to constants module 4. updated keymaps.yaml --- constants.py | 34 +++++++++++++++++++++++ filter.py | 40 ++++++++++++++++++++++----- keymapper.py | 78 ++++++++++++---------------------------------------- keymaps.yaml | 47 +++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 68 deletions(-) create mode 100644 constants.py diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..daf3c2d --- /dev/null +++ b/constants.py @@ -0,0 +1,34 @@ +import uinput + +# {'ITEM_XXX': (0,0)} +EVENTS_NAME_VALUE_DICT = { + k:v for k,v in vars(uinput.ev).items() + if k.startswith(('KEY_', 'BTN_', 'REL_', 'ABS_', ))} + +# {'KEY_XXX': (0,0)} +KEYS_NAME_VALUE_DICT = { + k:v for k,v in EVENTS_NAME_VALUE_DICT.items() + if k.startswith('KEY_')} + +# {(0,0), ...} +KEYS_VALUE_TUPLE = KEYS_NAME_VALUE_DICT.values() + +# {(0,0): 'KEY_XXX'} +KEYS_VALUE_NAME_DICT = { + v:k for k,v in KEYS_NAME_VALUE_DICT.items()} + +# {'KEY_XXX': 0} +KEY_NAME_CODE_DICT = { + k:v[1] for k,v in KEYS_NAME_VALUE_DICT.items()} + +# {0: 'KEY_XXX'} +KEY_CODE_NAME_DICT = { + v:k for k,v in KEY_NAME_CODE_DICT.items()} + +def name_to_code(name: str) -> int: + code = KEY_NAME_CODE_DICT[name] + return code + +def code_to_name(code: int) -> str: + name = KEY_CODE_NAME_DICT[code] + return name diff --git a/filter.py b/filter.py index 4e59359..bcfdf5e 100644 --- a/filter.py +++ b/filter.py @@ -1,4 +1,5 @@ import yaml +from constants import name_to_code as ntc class Filter: @@ -6,15 +7,40 @@ def __init__(self, path: str): with open(path) as f: keymaps = yaml.safe_load(f) # organize into swap list and map list - self._swap = tuple((m['target1'], m['target2']) + self._swap = tuple((ntc(m['target1']), ntc(m['target2'])) for m in keymaps if m['type'] == 'swap') - self._map = tuple((m['source'], m['target']) + self._map = tuple((ntc(m['source']), ntc(m['target'])) for m in keymaps if m['type'] == 'map') - self._combo = tuple((m['modifier'], m['source'], m['target']) + self._combo = tuple((ntc(m['modifier']), ntc(m['source']), ntc(m['target'])) for m in keymaps if m['type'] == 'combo') - self._onMods = {k[0]:False for k in self._combo} + self._on_mods = {k[0]:False for k in self._combo} + # self._mod_tree = { for c in self._combo} + d = {} + for c in self._combo: + pth = [] + pth.append((c[1], c[2])) + d[c] = pth - - def target(self, type: int, code: int, value: int) -> tuple: - return () + def target(self, type_in: int, code_in: int, value_in: int) -> tuple | None: + type_out, code_out, value_out = type_in, code_in, value_in + # only interested in key event, ignore sync event + if type_in == 1: + # check if input is a modifier key + if code_in in self._on_mods: + # if confirm, change on_mods state + self._on_mods[code_in] = value_in >= 1 + # then issue discard signal + return None + # check all registered mods tree and trigger + for modifier, source, target in self._combo: + # check if modifier is on + if self._on_mods[modifier]: + # check if key pressed matched source + if code_in == source: + # change output target accordingly + code_out = target + # only need to match the first path + break + # unless is a registered modifier key, always return a valid result + return ((type_out, code_out), value_out) diff --git a/keymapper.py b/keymapper.py index 4cc3035..1097c66 100644 --- a/keymapper.py +++ b/keymapper.py @@ -2,87 +2,43 @@ import traceback import evdev import uinput -import yaml import logging from filter import Filter +from constants import KEYS_VALUE_TUPLE, code_to_name logging.basicConfig(level=logging.INFO, format='%(message)s') -EVENTS = {k:v for k,v in vars(uinput.ev).items() if k.startswith(('KEY_', 'BTN_', 'REL_', 'ABS_', ))} -KEYS = {k:v for k,v in EVENTS.items() if k.startswith('KEY_')} - - -def get_event_name(value: tuple) -> str: - keys = {v:k for k,v in EVENTS.items()} - return keys.get(value, '') - - -def event_is_modifier(onMods: dict) -> bool: - return False - def main(): filter = Filter('keymaps.yaml') # start capturing from evdev try: - path = '/dev/input/event22' + path = '/dev/input/event20' kb = evdev.InputDevice(path) kb.grab() - logging.info('Device:', kb) - with uinput.Device(KEYS.values()) as vdev: - # keep track of if alt is press in last loop - isRalt = False + with uinput.Device(KEYS_VALUE_TUPLE) as vdev: # loop to capture every event for ev in kb.read_loop(): - # look at each event + # each event represented by three ints type_in, code_in, value_in = ev.type, ev.code, ev.value - type_out, code_out, value_out = type_in, code_in, value_in try: - # only interested in key event, ignore sync event - if type_in == 1: - # check for modifier key - if code_in == uinput.KEY_RIGHTALT[1]: - # key mapper will record that the status of right alt - isRalt = value_in >= 1 - # right alt key events are blocked - continue - # do remapping here - if isRalt: - # remap alt + hjkl - if code_in == uinput.KEY_K[1]: - type_out, code_out = uinput.KEY_UP - if code_in == uinput.KEY_J[1]: - type_out, code_out = uinput.KEY_DOWN - if code_in == uinput.KEY_H[1]: - type_out, code_out = uinput.KEY_LEFT - if code_in == uinput.KEY_L[1]: - type_out, code_out = uinput.KEY_RIGHT - # remap home + pgdn + pgup + end - if code_in == uinput.KEY_N[1]: - type_out, code_out = uinput.KEY_HOME - if code_in == uinput.KEY_M[1]: - type_out, code_out = uinput.KEY_PAGEDOWN - if code_in == uinput.KEY_COMMA[1]: - type_out, code_out = uinput.KEY_PAGEUP - if code_in == uinput.KEY_DOT[1]: - type_out, code_out = uinput.KEY_END - # remap backspace + delete - if code_in == uinput.KEY_Y[1]: - type_out, code_out = uinput.KEY_BACKSPACE - if code_in == uinput.KEY_U[1]: - type_out, code_out = uinput.KEY_BACKSPACE - if code_in == uinput.KEY_I[1]: - type_out, code_out = uinput.KEY_DELETE - if code_in == uinput.KEY_O[1]: - type_out, code_out = uinput.KEY_DELETE - # send back all event - vdev.emit((type_out, code_out), value_out) + # ask filter to for correct mapping + result = filter.target(type_in, code_in, value_in) + # this key is a registered modifier key + if result is None: + # drop the event and move on to next + continue + # emit the event to OS + vdev.emit(*result) # log + (type_out, code_out), value_out = result + name_in = code_to_name(code_in) if type_in == 1 else '' + name_out = code_to_name(code_out) if type_out == 1 else '' logging.info(( - f'\tIN: ({get_event_name((type_in, code_in)):>15},T={type_in:1},C={code_in:3},V={value_in:6}), ' - f'\tOUT: ({get_event_name((type_out, code_out)):>15},T={type_out:1},C={code_out:3},V={value_out:6}), \tisRalt: {isRalt}')) + f'\tIN: ({name_in:>15},T={type_in:1},C={code_in:3},V={value_in:6}), ' + f'\tOUT: ({name_out:>15},T={type_out:1},C={code_out:3},V={value_out:6})')) except KeyboardInterrupt: kb.ungrab() logging.error(traceback.format_exc()) diff --git a/keymaps.yaml b/keymaps.yaml index d01ad6b..1e2bd41 100644 --- a/keymaps.yaml +++ b/keymaps.yaml @@ -7,8 +7,55 @@ source: KEY_LEFTALT target: KEY_LEFTCTRL # map 2-keys-chord into a new key +# # RightAlt + hjkl to arrows +- type: combo + modifier: KEY_RIGHTALT + source: KEY_H + target: KEY_LEFT +- type: combo + modifier: KEY_RIGHTALT + source: KEY_J + target: KEY_DOWN - type: combo modifier: KEY_RIGHTALT source: KEY_K target: KEY_UP +- type: combo + modifier: KEY_RIGHTALT + source: KEY_L + target: KEY_RIGHT +# # remap home + pgdn + pgup + end +- type: combo + modifier: KEY_RIGHTALT + source: KEY_N + target: KEY_HOME +- type: combo + modifier: KEY_RIGHTALT + source: KEY_M + target: KEY_PAGEDOWN +- type: combo + modifier: KEY_RIGHTALT + source: KEY_COMMA + target: KEY_PAGEUP +- type: combo + modifier: KEY_RIGHTALT + source: KEY_DOT + target: KEY_END +# # remap backspace + delete +- type: combo + modifier: KEY_RIGHTALT + source: KEY_Y + target: KEY_BACKSPACE +- type: combo + modifier: KEY_RIGHTALT + source: KEY_U + target: KEY_BACKSPACE +- type: combo + modifier: KEY_RIGHTALT + source: KEY_I + target: KEY_DELETE +- type: combo + modifier: KEY_RIGHTALT + source: KEY_O + target: KEY_DELETE # more key combo options is coming From f06938c2c0c53b82838ec0dca37aab5a8d4c4819 Mon Sep 17 00:00:00 2001 From: Quantum Snowball Date: Fri, 10 Jun 2022 01:06:41 +0800 Subject: [PATCH 07/12] feature: packaged to be a cli ready program --- .gitignore | 1 + README.md | 42 ++++++++++++++----------- requirements/common.txt | 4 +++ requirements/dev.txt | 5 +++ setup.py | 26 +++++++++++++++ waylandmap/__init__.py | 0 constants.py => waylandmap/constants.py | 0 waylandmap/devices.py | 6 ++++ filter.py => waylandmap/filter.py | 2 +- keymapper.py => waylandmap/keymapper.py | 15 +++------ keymaps.yaml => waylandmap/keymaps.yaml | 0 waylandmap/main.py | 39 +++++++++++++++++++++++ 12 files changed, 110 insertions(+), 30 deletions(-) create mode 100644 requirements/common.txt create mode 100644 requirements/dev.txt create mode 100644 setup.py create mode 100644 waylandmap/__init__.py rename constants.py => waylandmap/constants.py (100%) create mode 100644 waylandmap/devices.py rename filter.py => waylandmap/filter.py (97%) rename keymapper.py => waylandmap/keymapper.py (87%) rename keymaps.yaml => waylandmap/keymaps.yaml (100%) create mode 100644 waylandmap/main.py diff --git a/.gitignore b/.gitignore index 80e1023..f17204d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .python-version pyrightconfig.json __pycache__ +waylandmap.egg-info diff --git a/README.md b/README.md index 5bf9e4f..7e26889 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,25 @@ -# wayland-keymapper +# WaylandMap I started to code this program because I want to use RightAlt + hjkl as system-wide arrow keys. Everyone has been using `xmodmap` to remap keys on linux. However, after switching to Wayland window system, `xmodmap` stopped working. I started to look for ready-to-use solution but with no luck. Probably Wayland are still too new for the community to catch up. Luckly, Google gave me some hints on how to achieve this task. The key is to go to lower level! This module makes use of `evdev` and `uinput` module to achieve keymapping. Therefore it should work on any Desktop, such as X11 or Wayland. ## Install Simply install using `pip` and run it as a cli application: ``` -pip install wayland-keymapper +pip install waylandmap ``` It is recommended to install the package to its own virtual environment, especially when installed as a linux system service. For example, install using `pipx`: ``` -pipx install wayland-keymapper +pipx install waylandmap ``` ## Usage -Your can run the keymapper directly by supplying the config file. +Your can run the keymapper directly by supplying the name of keyboard and the keymap file. ```bash -sudo wayland-keymapper +sudo waylandmap -n ``` You can see the key event inputs and outputs printed out in real time by running in debug mode. You can also check the string name required by the config file. ```bash -sudo wayland-keymapper --debug +sudo waylandmap --debug -n ``` ## Configuration @@ -27,19 +27,23 @@ The program accept a config file path as argument. The config file should be in ```yaml # keymaps.yaml -- type: swap, - key1: KEY_CAPSLOCK, - key2: KEY_ESC -# map one single key with another -- type: map, - from: KEY_LEFTALT, - to: KEY_LEFTCTRL -# map 2-keys-chord into a new key -- type: map, - from: - modifier: KEY_RIGHTALT, - key: KEY_K, - to: KEY_UP +# # RightAlt + hjkl to arrows +- type: combo + modifier: KEY_RIGHTALT + source: KEY_H + target: KEY_LEFT +- type: combo + modifier: KEY_RIGHTALT + source: KEY_J + target: KEY_DOWN +- type: combo + modifier: KEY_RIGHTALT + source: KEY_K + target: KEY_UP +- type: combo + modifier: KEY_RIGHTALT + source: KEY_L + target: KEY_RIGHT ``` diff --git a/requirements/common.txt b/requirements/common.txt new file mode 100644 index 0000000..b288c48 --- /dev/null +++ b/requirements/common.txt @@ -0,0 +1,4 @@ +evdev +python-uinput +click +pyyaml diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..6b4553f --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,5 @@ +evdev +python-uinput +click +pyyaml +grip diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..56a83b5 --- /dev/null +++ b/setup.py @@ -0,0 +1,26 @@ +from setuptools import setup, find_packages + + +with open('README.md', 'r') as f: + long_description = f.read() + +setup( + name='waylandmap', + packages=['waylandmap', ], + version='1.0.0', + description='A keymapper that works under both X11 or Wayland', + long_description=long_description, + long_description_content_type='text/markdown', + author='Quantum Snowball', + author_email='quantum.snowball@gmail.com', + url='https://github.com/quantumsnowball/waylandmap', + keywords=['wayland', 'keymappers', 'evdev', 'python-uinput', ], + python_requires='>=3.6', + install_requires=['click', 'evdev', 'python-uinput', 'pyyaml' ], + entry_points={ + 'console_scripts': [ + 'waylandmap=waylandmap.main:cli', + ] + } +) + diff --git a/waylandmap/__init__.py b/waylandmap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/constants.py b/waylandmap/constants.py similarity index 100% rename from constants.py rename to waylandmap/constants.py diff --git a/waylandmap/devices.py b/waylandmap/devices.py new file mode 100644 index 0000000..ae298b4 --- /dev/null +++ b/waylandmap/devices.py @@ -0,0 +1,6 @@ +import evdev + + +def get_devices() -> list: + devices = [evdev.InputDevice(dev) for dev in evdev.list_devices()] + return devices diff --git a/filter.py b/waylandmap/filter.py similarity index 97% rename from filter.py rename to waylandmap/filter.py index bcfdf5e..1fa787e 100644 --- a/filter.py +++ b/waylandmap/filter.py @@ -1,5 +1,5 @@ import yaml -from constants import name_to_code as ntc +from waylandmap.constants import name_to_code as ntc class Filter: diff --git a/keymapper.py b/waylandmap/keymapper.py similarity index 87% rename from keymapper.py rename to waylandmap/keymapper.py index 1097c66..dc7f7c1 100644 --- a/keymapper.py +++ b/waylandmap/keymapper.py @@ -3,19 +3,18 @@ import evdev import uinput import logging -from filter import Filter -from constants import KEYS_VALUE_TUPLE, code_to_name +from waylandmap.filter import Filter +from waylandmap.constants import KEYS_VALUE_TUPLE, code_to_name logging.basicConfig(level=logging.INFO, format='%(message)s') -def main(): - filter = Filter('keymaps.yaml') +def run(device, keymaps): + filter = Filter(keymaps) # start capturing from evdev try: - path = '/dev/input/event20' - kb = evdev.InputDevice(path) + kb = evdev.InputDevice(device) kb.grab() with uinput.Device(KEYS_VALUE_TUPLE) as vdev: @@ -47,7 +46,3 @@ def main(): except (PermissionError, ): logging.error("Must be run as sudo") - -if __name__ == '__main__': - main() - diff --git a/keymaps.yaml b/waylandmap/keymaps.yaml similarity index 100% rename from keymaps.yaml rename to waylandmap/keymaps.yaml diff --git a/waylandmap/main.py b/waylandmap/main.py new file mode 100644 index 0000000..d933efc --- /dev/null +++ b/waylandmap/main.py @@ -0,0 +1,39 @@ +import click +from waylandmap.devices import get_devices +from waylandmap.keymapper import run + + +@click.command(short_help='WaylandMap is a keymapper tools that works on both X11 or Wayland machine.') +@click.option('-l', '--list-devices', is_flag=True, default=False, help='List name of all available devices. Please find you keyboard.') +@click.option('-n', '--name', default=None, help='Name of the target keyboard.') +@click.argument('keymaps', nargs=1, required=False, default=None) +def cli(list_devices, name, keymaps): + """KEYMAPS is the path to your config yaml file""" + # show available devices + if list_devices: + devs = get_devices() + if len(devs) == 0: + click.echo('Need root permission to access devices.') + else: + print('List of avaliable devices:') + for dev in devs: + print(f'{dev.path}\t\t{dev.name}') + return + if name is None: + print('Please provide the name of you keyboard to remap. You may use the `-l` flag to show all available devices.') + return + else: + devs = get_devices() + for dev in devs: + if name == dev.name: + dev_path = dev.path + break + else: + print('Name of devices does not exists. Please check your device name.') + return + if keymaps is None: + print('Please provide the path to your keymap config file.') + return + # finally run the program + run(dev_path, keymaps) + From 63fba0f04a1e04c8e5155b3ba18a8d6d16462f20 Mon Sep 17 00:00:00 2001 From: Quantum Snowball Date: Fri, 10 Jun 2022 01:16:18 +0800 Subject: [PATCH 08/12] feature: added verbose flag to show live event mappings --- waylandmap/keymapper.py | 3 --- waylandmap/main.py | 9 ++++++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/waylandmap/keymapper.py b/waylandmap/keymapper.py index dc7f7c1..1d8d4a8 100644 --- a/waylandmap/keymapper.py +++ b/waylandmap/keymapper.py @@ -7,9 +7,6 @@ from waylandmap.constants import KEYS_VALUE_TUPLE, code_to_name -logging.basicConfig(level=logging.INFO, format='%(message)s') - - def run(device, keymaps): filter = Filter(keymaps) # start capturing from evdev diff --git a/waylandmap/main.py b/waylandmap/main.py index d933efc..13cda10 100644 --- a/waylandmap/main.py +++ b/waylandmap/main.py @@ -1,4 +1,5 @@ import click +import logging from waylandmap.devices import get_devices from waylandmap.keymapper import run @@ -7,7 +8,8 @@ @click.option('-l', '--list-devices', is_flag=True, default=False, help='List name of all available devices. Please find you keyboard.') @click.option('-n', '--name', default=None, help='Name of the target keyboard.') @click.argument('keymaps', nargs=1, required=False, default=None) -def cli(list_devices, name, keymaps): +@click.option('-v', '--verbose', is_flag=True, default=False, help='Print live event mapping info to terminal.') +def cli(list_devices, name, keymaps, verbose): """KEYMAPS is the path to your config yaml file""" # show available devices if list_devices: @@ -19,6 +21,7 @@ def cli(list_devices, name, keymaps): for dev in devs: print(f'{dev.path}\t\t{dev.name}') return + # ensure device name if name is None: print('Please provide the name of you keyboard to remap. You may use the `-l` flag to show all available devices.') return @@ -31,9 +34,13 @@ def cli(list_devices, name, keymaps): else: print('Name of devices does not exists. Please check your device name.') return + # ensure keymap config file if keymaps is None: print('Please provide the path to your keymap config file.') return + # set log level + logging.basicConfig(format='%(message)s', + level=logging.DEBUG if verbose else logging.WARNING) # finally run the program run(dev_path, keymaps) From d94d55377040ace34fe265e47f2cb8687a5e6344 Mon Sep 17 00:00:00 2001 From: Quantum Snowball Date: Fri, 10 Jun 2022 03:08:44 +0800 Subject: [PATCH 09/12] feature: infinite retry loop to reconnect after wake from sleep --- waylandmap/keymapper.py | 67 ++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/waylandmap/keymapper.py b/waylandmap/keymapper.py index 1d8d4a8..ba8f657 100644 --- a/waylandmap/keymapper.py +++ b/waylandmap/keymapper.py @@ -1,5 +1,5 @@ import sys -import traceback +import time import evdev import uinput import logging @@ -7,39 +7,52 @@ from waylandmap.constants import KEYS_VALUE_TUPLE, code_to_name +def infinite_retry(sleep, catch): + def wrapper(func): + def wrapped(*args, **kwargs): + while True: + try: + func(*args, **kwargs) + except PermissionError: + logging.error("Must be run as sudo") + except catch as e: + logging.error(f'{str(e)}: Failed to connect to device, possibly due to wake from sleep, will keep retrying ...') + time.sleep(sleep) + return wrapped + return wrapper + + +@infinite_retry(sleep=1, + catch=(FileNotFoundError, OSError, Exception)) def run(device, keymaps): filter = Filter(keymaps) # start capturing from evdev - try: - kb = evdev.InputDevice(device) - kb.grab() + kb = evdev.InputDevice(device) + kb.grab() + try: with uinput.Device(KEYS_VALUE_TUPLE) as vdev: # loop to capture every event for ev in kb.read_loop(): # each event represented by three ints type_in, code_in, value_in = ev.type, ev.code, ev.value - try: - # ask filter to for correct mapping - result = filter.target(type_in, code_in, value_in) - # this key is a registered modifier key - if result is None: - # drop the event and move on to next - continue - # emit the event to OS - vdev.emit(*result) - # log - (type_out, code_out), value_out = result - name_in = code_to_name(code_in) if type_in == 1 else '' - name_out = code_to_name(code_out) if type_out == 1 else '' - logging.info(( - f'\tIN: ({name_in:>15},T={type_in:1},C={code_in:3},V={value_in:6}), ' - f'\tOUT: ({name_out:>15},T={type_out:1},C={code_out:3},V={value_out:6})')) - except KeyboardInterrupt: - kb.ungrab() - logging.error(traceback.format_exc()) - sys.exit(0) - - except (PermissionError, ): - logging.error("Must be run as sudo") + # ask filter to for correct mapping + result = filter.target(type_in, code_in, value_in) + # this key is a registered modifier key + if result is None: + # drop the event and move on to next + continue + # emit the event to OS + vdev.emit(*result) + # log + (type_out, code_out), value_out = result + name_in = code_to_name(code_in) if type_in == 1 else '' + name_out = code_to_name(code_out) if type_out == 1 else '' + logging.info(( + f'\tIN: ({name_in:>15},T={type_in:1},C={code_in:3},V={value_in:6}), ' + f'\tOUT: ({name_out:>15},T={type_out:1},C={code_out:3},V={value_out:6})')) + except KeyboardInterrupt: + kb.ungrab() + logging.error('User keyboard interrupted, quitting now.') + sys.exit(0) From 62304f918b5d3c56170169cbcbafdea792fad396 Mon Sep 17 00:00:00 2001 From: Quantum Snowball Date: Fri, 10 Jun 2022 03:38:50 +0800 Subject: [PATCH 10/12] debug: retry using fixed dev_name instead of dev_path which may subject to change --- requirements/dev.txt | 1 + waylandmap/devices.py | 8 ++++++++ waylandmap/keymapper.py | 5 +++-- waylandmap/main.py | 4 ++-- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 6b4553f..7558f2a 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -3,3 +3,4 @@ python-uinput click pyyaml grip +twine diff --git a/waylandmap/devices.py b/waylandmap/devices.py index ae298b4..7bee5a2 100644 --- a/waylandmap/devices.py +++ b/waylandmap/devices.py @@ -4,3 +4,11 @@ def get_devices() -> list: devices = [evdev.InputDevice(dev) for dev in evdev.list_devices()] return devices + +def get_device_path(name): + for dev in get_devices(): + if name == dev.name: + return dev.path + else: + raise FileNotFoundError('Failed to look up device name.') + diff --git a/waylandmap/keymapper.py b/waylandmap/keymapper.py index ba8f657..80214cf 100644 --- a/waylandmap/keymapper.py +++ b/waylandmap/keymapper.py @@ -5,6 +5,7 @@ import logging from waylandmap.filter import Filter from waylandmap.constants import KEYS_VALUE_TUPLE, code_to_name +from waylandmap.devices import get_device_path def infinite_retry(sleep, catch): @@ -24,10 +25,10 @@ def wrapped(*args, **kwargs): @infinite_retry(sleep=1, catch=(FileNotFoundError, OSError, Exception)) -def run(device, keymaps): +def run(dev_name, keymaps): filter = Filter(keymaps) # start capturing from evdev - kb = evdev.InputDevice(device) + kb = evdev.InputDevice(get_device_path(dev_name)) kb.grab() try: diff --git a/waylandmap/main.py b/waylandmap/main.py index 13cda10..80edf36 100644 --- a/waylandmap/main.py +++ b/waylandmap/main.py @@ -29,7 +29,7 @@ def cli(list_devices, name, keymaps, verbose): devs = get_devices() for dev in devs: if name == dev.name: - dev_path = dev.path + dev_name = dev.name break else: print('Name of devices does not exists. Please check your device name.') @@ -42,5 +42,5 @@ def cli(list_devices, name, keymaps, verbose): logging.basicConfig(format='%(message)s', level=logging.DEBUG if verbose else logging.WARNING) # finally run the program - run(dev_path, keymaps) + run(dev_name, keymaps) From 034e2e40227b7f40006df68e78b4bbb8774556b3 Mon Sep 17 00:00:00 2001 From: Quantum Snowball Date: Fri, 10 Jun 2022 03:42:36 +0800 Subject: [PATCH 11/12] project: update readme --- README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/README.md b/README.md index 7e26889..e08f09c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,15 @@ The program accept a config file path as argument. The config file should be in ```yaml # keymaps.yaml +# simply swap two single key (to be implemented) +- type: swap + target1: KEY_CAPSLOCK + target2: KEY_ESC +# map one single key with another (to be implemented) +- type: map + source: KEY_LEFTALT + target: KEY_LEFTCTRL +# map 2-keys-chord into a new key # # RightAlt + hjkl to arrows - type: combo modifier: KEY_RIGHTALT @@ -44,6 +53,41 @@ The program accept a config file path as argument. The config file should be in modifier: KEY_RIGHTALT source: KEY_L target: KEY_RIGHT +# # remap home + pgdn + pgup + end +- type: combo + modifier: KEY_RIGHTALT + source: KEY_N + target: KEY_HOME +- type: combo + modifier: KEY_RIGHTALT + source: KEY_M + target: KEY_PAGEDOWN +- type: combo + modifier: KEY_RIGHTALT + source: KEY_COMMA + target: KEY_PAGEUP +- type: combo + modifier: KEY_RIGHTALT + source: KEY_DOT + target: KEY_END +# # remap backspace + delete +- type: combo + modifier: KEY_RIGHTALT + source: KEY_Y + target: KEY_BACKSPACE +- type: combo + modifier: KEY_RIGHTALT + source: KEY_U + target: KEY_BACKSPACE +- type: combo + modifier: KEY_RIGHTALT + source: KEY_I + target: KEY_DELETE +- type: combo + modifier: KEY_RIGHTALT + source: KEY_O + target: KEY_DELETE +# more key combo options is coming ``` From 0479567a74246ff4a1c69e0c24f5c509319a7742 Mon Sep 17 00:00:00 2001 From: Quantum Snowball Date: Fri, 10 Jun 2022 04:27:14 +0800 Subject: [PATCH 12/12] feature: provide systemctl service template --- .gitignore | 6 ++++-- README.md | 6 ++++-- {waylandmap => defaults}/keymaps.yaml | 0 systemctl/waylandmap.service | 11 +++++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) rename {waylandmap => defaults}/keymaps.yaml (100%) create mode 100644 systemctl/waylandmap.service diff --git a/.gitignore b/.gitignore index f17204d..12e5aae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .python-version pyrightconfig.json -__pycache__ -waylandmap.egg-info +__pycache__/ +waylandmap.egg-info/ +build/ +dist/ diff --git a/README.md b/README.md index e08f09c..9d1d598 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,10 @@ The program accept a config file path as argument. The config file should be in ## Install as Linux system services -After writing your config file and test running it without problem, your can install the program as a system services. This should w automatically start the keymapper when you login. +After writing your config file and test running it without problem, your can install the program as a system services. This should automatically start the keymapper everytime when you login. You may use `systemctl/waylandmap.service` as a template to edit the services file, and then install the service as follows: ``` -TODO +sudo cp systemctl/waylandmap.service /etc/systemd/system/ +sudo systemctl enable waylandmap.service ``` + diff --git a/waylandmap/keymaps.yaml b/defaults/keymaps.yaml similarity index 100% rename from waylandmap/keymaps.yaml rename to defaults/keymaps.yaml diff --git a/systemctl/waylandmap.service b/systemctl/waylandmap.service new file mode 100644 index 0000000..09a1eeb --- /dev/null +++ b/systemctl/waylandmap.service @@ -0,0 +1,11 @@ +[Unit] +Description=WaylandMap +After=multi-user.target + +[Service] +Type=simple +Restart=always +ExecStart=/path/to/your/waylandmap -n /path/to/your/keymaps.yaml + +[Install] +WantedBy=multi-user.target