├── .gitignore ├── assets ├── interpret_overlay1 ├── interpret_overlay2 └── overlay_enable_keysym ├── resume.py ├── test.py ├── LICENSE.txt ├── pyxhook_license.txt ├── config.json ├── system_install.py ├── README.md ├── pyxhook.py └── mainloop.py /.gitignore: -------------------------------------------------------------------------------- 1 | .dropbox 2 | .idea 3 | *.iml 4 | *.pyc 5 | 6 | assets/generated_key_labels 7 | xtest.py 8 | -------------------------------------------------------------------------------- /assets/interpret_overlay1: -------------------------------------------------------------------------------- 1 | interpret Overlay1_Enable+AnyOfOrNone(all) { 2 | action= SetControls(controls=Overlay1); 3 | }; -------------------------------------------------------------------------------- /assets/interpret_overlay2: -------------------------------------------------------------------------------- 1 | interpret Overlay2_Enable+AnyOfOrNone(all) { 2 | action= SetControls(controls=Overlay2); 3 | }; -------------------------------------------------------------------------------- /resume.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import socket 4 | 5 | s = socket.socket() 6 | s.connect(("localhost", 24679)) 7 | s.close() 8 | -------------------------------------------------------------------------------- /assets/overlay_enable_keysym: -------------------------------------------------------------------------------- 1 | key <####> { 2 | type= "THREE_LEVEL", 3 | symbols[Group1]= [ NoSymbol, NoSymbol ], 4 | actions[Group1]= [ SetControls(controls=overlay$$$$), SetControls(controls=overlay$$$$) ] 5 | }; -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import Xlib 2 | 3 | import mainloop 4 | 5 | def key_faker_test(): 6 | key_faker = mainloop.KeyFaker() 7 | command_overlay = mainloop.CommandOverlay(0, 23, "hold") 8 | print Xlib.XK.string_to_keysym("NoSymbol") 9 | command_overlay.execute_command_sequence([ 10 | {"text": '{'} 11 | ]) 12 | 13 | if __name__ == "__main__": 14 | key_faker_test() -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Sven Langner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /pyxhook_license.txt: -------------------------------------------------------------------------------- 1 | This license applies to all files in this repository that do not have 2 | another license otherwise indicated. 3 | 4 | Copyright (c) 2014, Jeff Hoogland 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | * Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | * Neither the name of the nor the 15 | names of its contributors may be used to endorse or promote products 16 | derived from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 22 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 25 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "usage0": "[overlays] key: the key to activate the overlay. 'disabled' to disable the overlay", 3 | "usage1": "[mapping] mapping works as follows: when 'key_code' is pressed, 'mapped_keysym' is generated", 4 | "usage2": "[mapped] you may also define a 'mapped_key_label' directly, which will be pressed instead of 'key_code'", 5 | "usage3": "[xkb_code] a list of xkb_codes and their current mappings will be available after first execution", 6 | "usage4": "[mode] if the overlay activation key should act in 'toggle' or 'hold' mode (RE-LOGIN required)", 7 | "overlay1": { 8 | "key": "CAPS", 9 | "mode": "hold", 10 | "mapping": [ 11 | { 12 | "key_code": 16, 13 | "mapped_keysym": "braceleft" 14 | }, 15 | { 16 | "key_code": 17, 17 | "mapped_keysym": "bracketleft" 18 | }, 19 | { 20 | "key_code": 18, 21 | "mapped_keysym": "bracketright" 22 | }, 23 | { 24 | "key_code": 19, 25 | "mapped_keysym": "braceright" 26 | }, 27 | { 28 | "key_code": 20, 29 | "mapped_keysym": "backslash" 30 | }, 31 | { 32 | "key_code": 30, 33 | "mapped_key_label": "PGUP" 34 | }, 35 | { 36 | "key_code": 31, 37 | "mapped_key_label": "UP" 38 | }, 39 | { 40 | "key_code": 32, 41 | "mapped_key_label": "PGDN" 42 | }, 43 | { 44 | "key_code": 43, 45 | "mapped_key_label": "HOME" 46 | }, 47 | { 48 | "key_code": 44, 49 | "mapped_key_label": "LEFT" 50 | }, 51 | { 52 | "key_code": 45, 53 | "mapped_key_label": "DOWN" 54 | }, 55 | { 56 | "key_code": 46, 57 | "mapped_key_label": "RGHT" 58 | }, 59 | { 60 | "key_code": 47, 61 | "mapped_key_label": "END" 62 | }, 63 | { 64 | "key_code": 40, 65 | "mapped_key_label": "LCTL" 66 | }, 67 | { 68 | "key_code": 12, 69 | "mapped_sequences": { 70 | "down": [ 71 | {"text": "\\flqq{} \\frqq{}"}, 72 | {"key": "Left", "times": 7} 73 | ] 74 | } 75 | } 76 | ] 77 | }, 78 | "overlay2": { 79 | "mode": "hold", 80 | "key": "disabled", 81 | "mapping": [ 82 | 83 | ] 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /system_install.py: -------------------------------------------------------------------------------- 1 | """ 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2024 Sven Langner 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | """ 24 | 25 | import os 26 | import xml.etree.ElementTree as etree 27 | import io 28 | 29 | 30 | def generate_keyboard_layout_file_str( 31 | based_of: str, new_key_sections, replaced_key_sections 32 | ): 33 | contents = "" 34 | contents += "default partial alphanumeric_keys modifier_keys keypad_keys\n" 35 | includes = "" 36 | includes += f'\n include "{based_of}(basic)"' 37 | new_name = based_of + "_hrc" 38 | contents += f'xkb_symbols "{new_name}" ' + "{" 39 | contents += includes 40 | contents += "\n" 41 | for key_section in new_key_sections: 42 | contents += key_section 43 | for key_section in replaced_key_sections: 44 | contents += "\n replace " + key_section.replace("};", " };") 45 | contents += "\n};" 46 | 47 | return new_name, contents 48 | 49 | 50 | def get_xkb_base_dir(): 51 | return "/usr/share/X11/xkb/" 52 | 53 | 54 | def get_symbols_install_dir(): 55 | return f"{get_xkb_base_dir()}symbols/" 56 | 57 | 58 | def get_rules_install_dir(): 59 | return f"{get_xkb_base_dir()}rules/" 60 | 61 | 62 | def install_keyboard_layout_file(contents, new_name): 63 | with open("/tmp/new_xkb_map", "w+") as f: 64 | f.write(contents) 65 | print("") 66 | print(contents) 67 | print("") 68 | cmd = f"sudo -k cp /tmp/new_xkb_map {get_symbols_install_dir()}{new_name}" 69 | print(f"Enter sudo pw to copy layout to system:\n{cmd}") 70 | os.system(cmd) 71 | 72 | 73 | def add_layout_to_rules_xml_file(old_name, new_name): 74 | # TODO it is not always evdev? 75 | path = os.path.join(get_rules_install_dir(), "evdev.xml") 76 | xml_str = open(path).read() 77 | # make sure there is only one occurence of closing tag 78 | assert len(xml_str.split("")) == 2 79 | root = etree.parse(open(path)).getroot() 80 | layoutList = root.find("layoutList") 81 | layouts = layoutList.findall("layout") 82 | old_desc_short = None 83 | old_desc = None 84 | country_list = None 85 | language_list = None 86 | for layout in layouts: 87 | configItem = layout.find("configItem") 88 | name = configItem.find("name").text 89 | if name == old_name: 90 | old_desc = configItem.find("description").text 91 | old_desc_short = configItem.find("shortDescription").text 92 | country_list = configItem.find("countryList") 93 | language_list = configItem.find("languageList") 94 | if name == new_name: 95 | print(f"Already installed in {path}: {new_name}! (Edit file manually to remove {new_name} and trigger re-installation)") 96 | return 97 | if old_desc is None: 98 | raise RuntimeError( 99 | f"could not find layout with name {old_name} to base description of new layout on" 100 | ) 101 | country_list = etree.tostring(country_list).decode("utf-8").strip(" ").strip("\n") if country_list else "" 102 | language_list = etree.tostring(language_list).decode("utf-8").strip(" ").strip("\n") if language_list else "" 103 | new_layout_str = f""" 104 | 105 | 106 | {new_name} 107 | {old_desc_short} 108 | {old_desc} (hrc) 109 | {country_list} 110 | {language_list} 111 | 112 | 113 | """ 114 | print(f"Adding the following to {path}") 115 | print(new_layout_str) 116 | part_before, part_after = xml_str.split("") 117 | new_full_xml = part_before + new_layout_str + "\n " + part_after 118 | try: 119 | # sanity check: can we still parse after adding the layout? 120 | etree.parse(io.StringIO(new_full_xml)) 121 | except etree.ParseError: 122 | raise RuntimeError("Bug in XML generation. Could not be parsed after adding new layout.") 123 | 124 | with open("/tmp/new_evdev.xml", "w+") as f: 125 | f.write(new_full_xml) 126 | cmd = f"sudo -k cp /tmp/new_evdev.xml {path}" 127 | print(f"Enter sudo pw to copy new evdev.xml to system:\n{cmd}") 128 | os.system(cmd) 129 | 130 | 131 | def get_current_keyboard_layout_id(): 132 | return ( 133 | os.popen("setxkbmap -query") 134 | .read() 135 | .split("layout:")[1] 136 | .replace("\t", "") 137 | .replace(" ", "") 138 | .replace("\n", "") 139 | .replace("\r", "") 140 | .replace("_hrc", "") 141 | ).split(",")[0] 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Swift Map - Home Row Computing Keyboard Overlay 2 | 3 | Arrow keys and other navigation keys accessible while staying on the home row!
4 | How? Specify a key, disable its normal behaviour and use it as an activator for 5 | another key layer on your keyboard (this program comes with a default configuration using CapsLock).
6 | You can use the layout on both **Linux and Windows** without administrator rights! 7 | 8 | ## Installation on Linux 9 | 10 | - (**optional**) If you like to use command and string injection, please install python-xlib
11 | ```bash 12 | sudo apt install python3-xlib 13 | # or 14 | pip install XLib 15 | ``` 16 | 17 | - clone this repository. 18 | ```bash 19 | git clone https://github.com/svenlr/swift-map.git 20 | ``` 21 | 22 | - change working directory to the installation directory 23 | ```bash 24 | cd swift-map 25 | ``` 26 | 27 | - make scripts executable 28 | ```bash 29 | chmod +x mainloop.py 30 | ``` 31 | 32 | - test it 33 | ```bash 34 | ./mainloop.py nosleep 35 | ``` 36 | To test it, now open an editor and try pressing ijkl with and without CAPS held down. 37 | 38 | - Add it to start up (tested on Ubuntu 18.04 - 22.04): 39 | 1. Go to the launcher and open the program 'Startup Applications'. 40 | 2. Click on 'Add'. 41 | 3. Enter some name, such as Keyboard Remap. 42 | 4. Click on 'Browse'. 43 | 5. Navigate to 'mainloop.py'. 44 | 45 | - If you have sudo rights, you can permanently install the layout to your layout switcher. This also prevents the keyboard layout from breaking when resuming from sleep or attaching USB devices, etc... 46 | (Please be aware that the **installation modifies system config files** and the authors take no responsibilities for things breaking because of that) 47 | ``` 48 | ./mainloop.py install 49 | 50 | # Note that the installation alone does not handle snippet or command mappings. 51 | # Running the program on start-up is still required for that. 52 | ``` 53 | 54 | ## Configuration File (Linux only) 55 | 56 | The new key layer can be customized using a JSON configuration file on Linux (`config.json`). 57 | A default configuration file is already included. Add more mappings and functionality by editing it. 58 | 59 | - It is recommended to edit the JSON configuration file with an IDE/Editor that supports JSON, for example any JavaScript IDE/Editor should work. PyCharm works, too. 60 | - You can use the `xev` command line tool to obtain the `"key_code"` for the keys that you want to remap. 61 | `xev` also prints the key symbols (`mapped_keysym`) such as `braceleft`. For `xev` to work, you need to have focus (click on) the box window that appears after you started `xev`. 62 | 63 | Choosing a mapping type: 64 | - Example: If you want to make caps + r act like a Esc key use Cross Mapping ("mapped_key_label"). 65 | _Cross Mapping_ should be used when the target key (in this case Esc) is available without modifiers on your keyboard. 66 | - Example: If you want to assign symbols like `/` `(` `)` `=` to keys then use Key Symbol Mapping ("mapped_keysym"). 67 | _Key Symbol Mapping_ should be used when the target symbol (in this case `/` `(` `)` `=`) is **not** available without modifiers on your keyboard. 68 | 69 | ### Cross Mapping (`mapped_key_label`) 70 | 71 | Simulate pressing another key on your keyboard with the overlay. You can create as many Cross Mappings as you like. This method should be used if the target key - the one that you want to produce - is available somewhere on your keyboard without modifiers. 72 | 73 | **Example:** We want to map Caps+o to PageDown. 74 | (i.e., for every application, it will look as if you pressed the PageDown arrow key when you press Caps+o). 75 | 1. In order to find the key code for o, you type `xev` in command line, focus the appearing window and then press o. The key code is printed in the terminal, usually 32. 76 | 2. With `xev`, find the key code for the PageDown - usually 117. 77 | 3. Now, find the key label: `cat /usr/share/X11/xkb/keycodes/evdev | grep " 117;"` , which prints ``` = 117;``` 78 | 79 | Here is the resulting JSON for remapping Caps+o to the PageDown key (PGDN): 80 | ```json 81 | ... 82 | { 83 | "key_code": 32, 84 | "mapped_key_label": "PGDN" 85 | }, 86 | ... 87 | ``` 88 | 89 | ### Key Symbol Mapping (`mapped_keysym`) 90 | 91 | Use this if you want to generate a key that is not available on your keyboard without additional modifiers or not available at all. 92 | **Example:** We want to map Caps+7 to the left brace `{`. 93 | 1. Again, in order to find the key code for 7, we use `xev`, which gives us 16 in the example. 94 | 2. Then, we want to find the key symbol (keysym) for the left brace `{`. In order to do that, just open `xev` and type the desired target key `{` using the necessary modifiers. Along with the key code, `xev` prints `braceleft`, which is our keysym. 95 | 96 | Here is the resulting JSON for remapping Caps+7 to the left brace `{`. 97 | ```json 98 | ... 99 | { 100 | "key_code": 16, 101 | "mapped_keysym": "braceleft" 102 | }, 103 | ... 104 | ``` 105 | 106 | Note that for the moment, the number of mappings of this kind is usually limited by about 10. 107 | 108 | ### Command and String Mapping 109 | 110 | This requires the installation of `python-xlib`. 111 | Map a key code to a set of commands, also including shell commands. 112 | Define two sequences of commands, one for key up and one for key down. 113 | A command can be a string (evaluated as shell command), or an object in JSON notation. 114 | The following example can also be found in the default configuration file and allows us to make german quotes in LaTeX with only two key strokes. 115 | 116 | ```json 117 | ... 118 | { 119 | "key_code": 11, 120 | "mapped_sequences": { 121 | "down": [ 122 | {"text": "\\glqq{} \\grqq{}"}, 123 | {"key": "Left", "times": 7} 124 | ] 125 | } 126 | }, 127 | ... 128 | ``` 129 | 130 | Note that for the moment, the number of mappings of this kind is usually limited by about 10. 131 | 132 | ## Usage on Windows 133 | 134 | At the moment, I just recreated the default layout using a forked AutoHotKey script as a suggestion for the usage on Windows. 135 | 136 | - install [AutoHotKey](https://autohotkey.com/download/) - or use the [zip version](https://autohotkey.com/download/ahk.zip) if you don't have administrator rights on the system 137 | - download the [AutoHotKey CapsLock Remapping .ahk Script (DE layout)](https://gist.github.com/svenlr/2e09166ae6b70f0fcf8c897b7e7d4be8) (**it will be downloaded UTF-8 encoded and must be converted to ANSI manually!** Can be done using e.g. Notepad++ Portable) 138 | - place a batch script with the following content in the user autostart folder (%appdata%\Microsoft\Windows\Start Menu\Programs\Startup) 139 | 140 | ```bat 141 | C:\path\to\autohotkey.exe C:\path\to\capslock_remapping.ahk 142 | ``` 143 | 144 | ## Thanks 145 | - [arensonzz](https://github.com/arensonzz) for writing documentation. 146 | -------------------------------------------------------------------------------- /pyxhook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # pyxhook -- an extension to emulate some of the PyHook library on linux. 4 | # 5 | # Copyright (C) 2008 Tim Alexander 6 | # 7 | # This program is free software; you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation; either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program; if not, write to the Free Software 19 | # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 20 | # 21 | # Thanks to Alex Badea for writing the Record 22 | # demo for the xlib libraries. It helped me immensely working with these 23 | # in this library. 24 | # 25 | # Thanks to the python-xlib team. This wouldn't have been possible without 26 | # your code. 27 | # 28 | # This requires: 29 | # at least python-xlib 1.4 30 | # xwindows must have the "record" extension present, and active. 31 | # 32 | # This file has now been somewhat extensively modified by 33 | # Daniel Folkinshteyn 34 | # So if there are any bugs, they are probably my fault. :) 35 | 36 | import sys 37 | import os 38 | import re 39 | import time 40 | import threading 41 | 42 | from Xlib import X, XK, display, error 43 | from Xlib.ext import record 44 | from Xlib.protocol import rq 45 | 46 | ####################################################################### 47 | ########################START CLASS DEF################################ 48 | ####################################################################### 49 | 50 | class HookManager(threading.Thread): 51 | """This is the main class. Instantiate it, and you can hand it KeyDown and KeyUp (functions in your own code) which execute to parse the pyxhookkeyevent class that is returned. 52 | This simply takes these two values for now: 53 | KeyDown = The function to execute when a key is pressed, if it returns anything. It hands the function an argument that is the pyxhookkeyevent class. 54 | KeyUp = The function to execute when a key is released, if it returns anything. It hands the function an argument that is the pyxhookkeyevent class. 55 | """ 56 | 57 | def __init__(self): 58 | threading.Thread.__init__(self) 59 | self.finished = threading.Event() 60 | 61 | # Give these some initial values 62 | self.mouse_position_x = 0 63 | self.mouse_position_y = 0 64 | self.ison = {"shift":False, "caps":False} 65 | 66 | # Compile our regex statements. 67 | self.isshift = re.compile('^Shift') 68 | self.iscaps = re.compile('^Caps_Lock') 69 | self.shiftablechar = re.compile('^[a-z0-9]$|^minus$|^equal$|^bracketleft$|^bracketright$|^semicolon$|^backslash$|^apostrophe$|^comma$|^period$|^slash$|^grave$') 70 | self.logrelease = re.compile('.*') 71 | self.isspace = re.compile('^space$') 72 | 73 | # Assign default function actions (do nothing). 74 | self.KeyDown = lambda x: True 75 | self.KeyUp = lambda x: True 76 | self.MouseAllButtonsDown = lambda x: True 77 | self.MouseAllButtonsUp = lambda x: True 78 | self.MouseMovement = lambda x: True 79 | 80 | self.contextEventMask = [X.KeyPress,X.MotionNotify] 81 | 82 | # Hook to our display. 83 | self.local_dpy = display.Display() 84 | self.record_dpy = display.Display() 85 | 86 | def run(self): 87 | # Check if the extension is present 88 | if not self.record_dpy.has_extension("RECORD"): 89 | print("RECORD extension not found") 90 | sys.exit(1) 91 | r = self.record_dpy.record_get_version(0, 0) 92 | print("RECORD extension version %d.%d" % (r.major_version, r.minor_version)) 93 | 94 | # Create a recording context; we only want key and mouse events 95 | self.ctx = self.record_dpy.record_create_context( 96 | 0, 97 | [record.AllClients], 98 | [{ 99 | 'core_requests': (0, 0), 100 | 'core_replies': (0, 0), 101 | 'ext_requests': (0, 0, 0, 0), 102 | 'ext_replies': (0, 0, 0, 0), 103 | 'delivered_events': (0, 0), 104 | 'device_events': tuple(self.contextEventMask), #(X.KeyPress, X.ButtonPress), 105 | 'errors': (0, 0), 106 | 'client_started': False, 107 | 'client_died': False, 108 | }]) 109 | 110 | # Enable the context; this only returns after a call to record_disable_context, 111 | # while calling the callback function in the meantime 112 | self.record_dpy.record_enable_context(self.ctx, self.processevents) 113 | # Finally free the context 114 | self.record_dpy.record_free_context(self.ctx) 115 | 116 | def cancel(self): 117 | self.finished.set() 118 | self.local_dpy.record_disable_context(self.ctx) 119 | self.local_dpy.flush() 120 | 121 | def printevent(self, event): 122 | print(event) 123 | 124 | def HookKeyboard(self): 125 | pass 126 | # We don't need to do anything here anymore, since the default mask 127 | # is now set to contain X.KeyPress 128 | #self.contextEventMask[0] = X.KeyPress 129 | 130 | def HookMouse(self): 131 | pass 132 | # We don't need to do anything here anymore, since the default mask 133 | # is now set to contain X.MotionNotify 134 | 135 | # need mouse motion to track pointer position, since ButtonPress events 136 | # don't carry that info. 137 | #self.contextEventMask[1] = X.MotionNotify 138 | 139 | def processevents(self, reply): 140 | if reply.category != record.FromServer: 141 | return 142 | if reply.client_swapped: 143 | print("* received swapped protocol data, cowardly ignored") 144 | return 145 | if not len(reply.data) or ord(str(reply.data[0])) < 2: 146 | # not an event 147 | return 148 | data = reply.data 149 | while len(data): 150 | event, data = rq.EventField(None).parse_binary_value(data, self.record_dpy.display, None, None) 151 | if event.type == X.KeyPress: 152 | hookevent = self.keypressevent(event) 153 | self.KeyDown(hookevent) 154 | elif event.type == X.KeyRelease: 155 | hookevent = self.keyreleaseevent(event) 156 | self.KeyUp(hookevent) 157 | elif event.type == X.ButtonPress: 158 | hookevent = self.buttonpressevent(event) 159 | self.MouseAllButtonsDown(hookevent) 160 | elif event.type == X.ButtonRelease: 161 | hookevent = self.buttonreleaseevent(event) 162 | self.MouseAllButtonsUp(hookevent) 163 | elif event.type == X.MotionNotify: 164 | # use mouse moves to record mouse position, since press and release events 165 | # do not give mouse position info (event.root_x and event.root_y have 166 | # bogus info). 167 | hookevent = self.mousemoveevent(event) 168 | self.MouseMovement(hookevent) 169 | 170 | #print "processing events...", event.type 171 | 172 | def keypressevent(self, event): 173 | matchto = self.lookup_keysym(self.local_dpy.keycode_to_keysym(event.detail, 0)) 174 | if self.shiftablechar.match(self.lookup_keysym(self.local_dpy.keycode_to_keysym(event.detail, 0))): ## This is a character that can be typed. 175 | if self.ison["shift"] == False: 176 | keysym = self.local_dpy.keycode_to_keysym(event.detail, 0) 177 | return self.makekeyhookevent(keysym, event) 178 | else: 179 | keysym = self.local_dpy.keycode_to_keysym(event.detail, 1) 180 | return self.makekeyhookevent(keysym, event) 181 | else: ## Not a typable character. 182 | keysym = self.local_dpy.keycode_to_keysym(event.detail, 0) 183 | if self.isshift.match(matchto): 184 | self.ison["shift"] = self.ison["shift"] + 1 185 | elif self.iscaps.match(matchto): 186 | if self.ison["caps"] == False: 187 | self.ison["shift"] = self.ison["shift"] + 1 188 | self.ison["caps"] = True 189 | if self.ison["caps"] == True: 190 | self.ison["shift"] = self.ison["shift"] - 1 191 | self.ison["caps"] = False 192 | return self.makekeyhookevent(keysym, event) 193 | 194 | def keyreleaseevent(self, event): 195 | if self.shiftablechar.match(self.lookup_keysym(self.local_dpy.keycode_to_keysym(event.detail, 0))): 196 | if self.ison["shift"] == False: 197 | keysym = self.local_dpy.keycode_to_keysym(event.detail, 0) 198 | else: 199 | keysym = self.local_dpy.keycode_to_keysym(event.detail, 1) 200 | else: 201 | keysym = self.local_dpy.keycode_to_keysym(event.detail, 0) 202 | matchto = self.lookup_keysym(keysym) 203 | if self.isshift.match(matchto): 204 | self.ison["shift"] = self.ison["shift"] - 1 205 | return self.makekeyhookevent(keysym, event) 206 | 207 | def buttonpressevent(self, event): 208 | #self.clickx = self.rootx 209 | #self.clicky = self.rooty 210 | return self.makemousehookevent(event) 211 | 212 | def buttonreleaseevent(self, event): 213 | #if (self.clickx == self.rootx) and (self.clicky == self.rooty): 214 | ##print "ButtonClick " + str(event.detail) + " x=" + str(self.rootx) + " y=" + str(self.rooty) 215 | #if (event.detail == 1) or (event.detail == 2) or (event.detail == 3): 216 | #self.captureclick() 217 | #else: 218 | #pass 219 | 220 | return self.makemousehookevent(event) 221 | 222 | # sys.stdout.write("ButtonDown " + str(event.detail) + " x=" + str(self.clickx) + " y=" + str(self.clicky) + "\n") 223 | # sys.stdout.write("ButtonUp " + str(event.detail) + " x=" + str(self.rootx) + " y=" + str(self.rooty) + "\n") 224 | #sys.stdout.flush() 225 | 226 | def mousemoveevent(self, event): 227 | self.mouse_position_x = event.root_x 228 | self.mouse_position_y = event.root_y 229 | return self.makemousehookevent(event) 230 | 231 | # need the following because XK.keysym_to_string() only does printable chars 232 | # rather than being the correct inverse of XK.string_to_keysym() 233 | def lookup_keysym(self, keysym): 234 | for name in dir(XK): 235 | if name.startswith("XK_") and getattr(XK, name) == keysym: 236 | return name.lstrip("XK_") 237 | return "[%d]" % keysym 238 | 239 | def asciivalue(self, keysym): 240 | asciinum = XK.string_to_keysym(self.lookup_keysym(keysym)) 241 | return asciinum % 256 242 | 243 | def makekeyhookevent(self, keysym, event): 244 | storewm = self.xwindowinfo() 245 | if event.type == X.KeyPress: 246 | MessageName = "key down" 247 | elif event.type == X.KeyRelease: 248 | MessageName = "key up" 249 | return pyxhookkeyevent(storewm["handle"], storewm["name"], storewm["class"], self.lookup_keysym(keysym), self.asciivalue(keysym), False, event.detail, MessageName, event) 250 | 251 | def makemousehookevent(self, event): 252 | storewm = self.xwindowinfo() 253 | if event.detail == 1: 254 | MessageName = "mouse left " 255 | elif event.detail == 3: 256 | MessageName = "mouse right " 257 | elif event.detail == 2: 258 | MessageName = "mouse middle " 259 | elif event.detail == 5: 260 | MessageName = "mouse wheel down " 261 | elif event.detail == 4: 262 | MessageName = "mouse wheel up " 263 | else: 264 | MessageName = "mouse " + str(event.detail) + " " 265 | 266 | if event.type == X.ButtonPress: 267 | MessageName = MessageName + "down" 268 | elif event.type == X.ButtonRelease: 269 | MessageName = MessageName + "up" 270 | else: 271 | MessageName = "mouse moved" 272 | return pyxhookmouseevent(storewm["handle"], storewm["name"], storewm["class"], (self.mouse_position_x, self.mouse_position_y), MessageName) 273 | 274 | def xwindowinfo(self): 275 | try: 276 | windowvar = self.local_dpy.get_input_focus().focus 277 | wmname = windowvar.get_wm_name() 278 | wmclass = windowvar.get_wm_class() 279 | wmhandle = str(windowvar)[20:30] 280 | except: 281 | ## This is to keep things running smoothly. It almost never happens, but still... 282 | return {"name":None, "class":None, "handle":None} 283 | if (wmname == None) and (wmclass == None): 284 | try: 285 | windowvar = windowvar.query_tree().parent 286 | wmname = windowvar.get_wm_name() 287 | wmclass = windowvar.get_wm_class() 288 | wmhandle = str(windowvar)[20:30] 289 | except: 290 | ## This is to keep things running smoothly. It almost never happens, but still... 291 | return {"name":None, "class":None, "handle":None} 292 | if wmclass == None: 293 | return {"name":wmname, "class":wmclass, "handle":wmhandle} 294 | else: 295 | return {"name":wmname, "class":wmclass[0], "handle":wmhandle} 296 | 297 | class pyxhookkeyevent: 298 | """This is the class that is returned with each key event.f 299 | It simply creates the variables below in the class. 300 | 301 | Window = The handle of the window. 302 | WindowName = The name of the window. 303 | WindowProcName = The backend process for the window. 304 | Key = The key pressed, shifted to the correct caps value. 305 | Ascii = An ascii representation of the key. It returns 0 if the ascii value is not between 31 and 256. 306 | KeyID = This is just False for now. Under windows, it is the Virtual Key Code, but that's a windows-only thing. 307 | ScanCode = Please don't use this. It differs for pretty much every type of keyboard. X11 abstracts this information anyway. 308 | MessageName = "key down", "key up". 309 | """ 310 | 311 | def __init__(self, Window, WindowName, WindowProcName, Key, Ascii, KeyID, ScanCode, MessageName, XLibEvent): 312 | self.Window = Window 313 | self.WindowName = WindowName 314 | self.WindowProcName = WindowProcName 315 | self.Key = Key 316 | self.Ascii = Ascii 317 | self.KeyID = KeyID 318 | self.ScanCode = ScanCode 319 | self.MessageName = MessageName 320 | self.XLibEvent = XLibEvent 321 | 322 | def __str__(self): 323 | return "Window Handle: " + str(self.Window) + "\nWindow Name: " + str(self.WindowName) + "\nWindow's Process Name: " + str(self.WindowProcName) + "\nKey Pressed: " + str(self.Key) + "\nAscii Value: " + str(self.Ascii) + "\nKeyID: " + str(self.KeyID) + "\nScanCode: " + str(self.ScanCode) + "\nMessageName: " + str(self.MessageName) + "\n" 324 | 325 | class pyxhookmouseevent: 326 | """This is the class that is returned with each key event.f 327 | It simply creates the variables below in the class. 328 | 329 | Window = The handle of the window. 330 | WindowName = The name of the window. 331 | WindowProcName = The backend process for the window. 332 | Position = 2-tuple (x,y) coordinates of the mouse click 333 | MessageName = "mouse left|right|middle down", "mouse left|right|middle up". 334 | """ 335 | 336 | def __init__(self, Window, WindowName, WindowProcName, Position, MessageName): 337 | self.Window = Window 338 | self.WindowName = WindowName 339 | self.WindowProcName = WindowProcName 340 | self.Position = Position 341 | self.MessageName = MessageName 342 | 343 | def __str__(self): 344 | return "Window Handle: " + str(self.Window) + "\nWindow Name: " + str(self.WindowName) + "\nWindow's Process Name: " + str(self.WindowProcName) + "\nPosition: " + str(self.Position) + "\nMessageName: " + str(self.MessageName) + "\n" 345 | 346 | ####################################################################### 347 | #########################END CLASS DEF################################# 348 | ####################################################################### 349 | 350 | if __name__ == '__main__': 351 | hm = HookManager() 352 | hm.HookKeyboard() 353 | hm.HookMouse() 354 | hm.KeyDown = hm.printevent 355 | hm.KeyUp = hm.printevent 356 | hm.MouseAllButtonsDown = hm.printevent 357 | hm.MouseAllButtonsUp = hm.printevent 358 | hm.MouseMovement = hm.printevent 359 | hm.start() 360 | time.sleep(10) 361 | hm.cancel() 362 | -------------------------------------------------------------------------------- /mainloop.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | The MIT License (MIT) 5 | 6 | Copyright (c) 2024 Sven Langner 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | """ 26 | 27 | import json 28 | import os 29 | import re 30 | import socket 31 | import sys 32 | import time 33 | import itertools 34 | 35 | from system_install import generate_keyboard_layout_file_str, get_current_keyboard_layout_id, install_keyboard_layout_file, get_symbols_install_dir, \ 36 | add_layout_to_rules_xml_file 37 | 38 | try: 39 | import pyxhook 40 | import Xlib, Xlib.protocol, Xlib.display, Xlib.X 41 | 42 | xlib_support = True 43 | except Exception as e: 44 | pyxhook = Xlib = None 45 | xlib_support = False 46 | print("Warning:") 47 | print(e) 48 | print("No X library support. Snippets and commands are not supported without python3-xlib.") 49 | print("You can still use key remapping as usual.") 50 | print("You may want to install python3-xlib.") 51 | time.sleep(1) 52 | 53 | default_path = os.path.dirname(__file__) 54 | os.chdir(default_path) 55 | 56 | 57 | def read_file(file_): 58 | with open(file_) as f: 59 | return f.read() 60 | 61 | 62 | class Mapper: 63 | def __init__(self): 64 | self.keymap_data = "" 65 | self.keymap_file = "keymap" 66 | 67 | self.generated_key_labels_file = "assets/generated_key_labels" 68 | self.used_key_labels = self.restore_used_key_labels() 69 | 70 | self.config = json.loads(read_file("config.json")) 71 | 72 | self.overlays = [] 73 | 74 | self.created_keys = [] 75 | self.replaced_keys = [] 76 | 77 | self.allow_reuse_overlays = True 78 | 79 | def main(self): 80 | if "install" in sys.argv: 81 | self.allow_reuse_overlays = False 82 | self.generate_modified_keymap_data() 83 | name, contents_str = generate_keyboard_layout_file_str(get_current_keyboard_layout_id(), self.created_keys, self.replaced_keys) 84 | install_keyboard_layout_file(contents_str, name) 85 | add_layout_to_rules_xml_file(get_current_keyboard_layout_id(), f"{get_current_keyboard_layout_id()}_hrc") 86 | print("In order to activate the generated layout, go to your Keyboard Settings GUI and search for 'hrc'.") 87 | sys.exit(0) 88 | 89 | if "nosleep" not in sys.argv: 90 | time.sleep(5) 91 | 92 | if f"{get_current_keyboard_layout_id()}_hrc" in os.listdir(get_symbols_install_dir()): 93 | os.system(f"setxkbmap {get_current_keyboard_layout_id()}_hrc") 94 | 95 | self.configure_keymap() 96 | 97 | try: 98 | s = socket.socket() 99 | s.bind(("localhost", 24679)) 100 | s.listen(1) 101 | 102 | for overlay in self.overlays: 103 | overlay.start_hooking_keyboard() 104 | 105 | while True: 106 | try: 107 | s.accept() 108 | time.sleep(5) 109 | self.load_keymap_file(self.keymap_file) 110 | except KeyboardInterrupt: 111 | for overlay in self.overlays: 112 | overlay.stop_hooking_keyboard() 113 | break 114 | except OSError as e: 115 | print(e, ". Another instance of this program is already running. Kill previous instance and " 116 | "restart the program if you want command overlays to work correctly.") 117 | 118 | def store_used_key_labels(self): 119 | file_data = "" 120 | key_labels = [] 121 | # remove double elements 122 | for key_label in self.used_key_labels: 123 | if key_label not in key_labels: 124 | key_labels.append(key_label) 125 | self.used_key_labels = key_labels 126 | for key_label in self.used_key_labels: 127 | file_data += "\n" + key_label 128 | with open(self.generated_key_labels_file, "w") as f: 129 | f.write(file_data) 130 | 131 | def restore_used_key_labels(self): 132 | if os.path.isfile(self.generated_key_labels_file): 133 | codes = read_file(self.generated_key_labels_file).split("\n") 134 | while '' in codes: 135 | codes.remove('') 136 | return codes 137 | else: 138 | return [] 139 | 140 | def get_interpret_section(self, stroke): 141 | re_stroke = stroke.replace("+", "\\+").replace("(", "\\(").replace(")", "\\)") 142 | groups = re.search(r"interpret *" + re_stroke + r" *\{[^}]*\}([ \n])*;", self.keymap_data) 143 | if groups is None: 144 | return None 145 | interpret_section = groups.group(0) 146 | return interpret_section 147 | 148 | def get_key_label(self, key_code): 149 | key_codes = self.get_xkb_keycodes_section() 150 | key_code_search = re.search(r"<.{2,6}> *= *" + str(key_code) + r" *;", key_codes).group(0) 151 | key_label = key_code_search.split("=")[0].strip(' ')[1:-1] 152 | return key_label 153 | 154 | def get_key_code(self, key_label): 155 | key_codes = self.get_xkb_keycodes_section() 156 | key_label_search = re.search(r"<" + str(key_label) + "> *= *[0-9]{1,10};", key_codes).group(0) 157 | key_code = key_label_search.split("=")[1].strip(' ')[:-1] # cut ';' 158 | return key_code 159 | 160 | def get_xkb_keycodes_section(self): 161 | return re.search(r"xkb_keycodes *\".*\" *\{[^}]*\}", self.keymap_data).group(0) 162 | 163 | def get_xkb_symbols_section(self): 164 | return re.search(r"xkb_symbols *\".*\" *\{([^{]+(name|key)[^{]+\{[^}]+\};)*", self.keymap_data).group(0) 165 | 166 | def get_xkb_symbols_section_name(self): 167 | xkb_symbols_str = self.get_xkb_symbols_section() 168 | name = xkb_symbols_str.split("xkb_symbols")[1].split("{")[0] 169 | name = name.strip(" ").strip("\t").strip('"') 170 | return name 171 | 172 | def get_keysym_section(self, key_label): 173 | key_label = key_label.upper() 174 | return re.search(r"key <" + key_label + r"> \{[^}]*\};", self.keymap_data).group(0) 175 | 176 | def has_keysym_section(self, key_label): 177 | return re.search(r"key <" + key_label + r"> \{[^}]*\};", self.keymap_data) is not None 178 | 179 | def get_unused_key_labels(self): 180 | all_codes = self.get_key_labels() 181 | codes = [] 182 | xkb_symbols_section = self.get_xkb_symbols_section() 183 | for key_label in all_codes: 184 | if key_label not in xkb_symbols_section: 185 | codes.append(key_label) 186 | return codes 187 | 188 | def get_key_labels(self): 189 | codes = [] 190 | for i in range(9, 255): 191 | try: 192 | key_label = self.get_key_label(i) 193 | codes.append(key_label) 194 | except AttributeError as e: 195 | pass 196 | return codes 197 | 198 | def has_overlay(self, key_label, overlay_num): 199 | old_key_def = self.get_keysym_section(key_label) 200 | if f"overlay{overlay_num}" in old_key_def: 201 | return True 202 | else: 203 | return False 204 | 205 | def get_overlay(self, key_label, overlay_num): 206 | key_def = self.get_keysym_section(key_label) 207 | if f"overlay{overlay_num}" not in key_def: 208 | return None 209 | overlay_key_label = key_def.split(f"overlay{overlay_num}")[1].split("=")[1].split("\n")[0] 210 | overlay_key_label = overlay_key_label.split("<")[1].split(">")[0] 211 | return overlay_key_label 212 | 213 | def add_overlay_key(self, key_label, overlay_key_label, num_overlay): 214 | try: 215 | old_key_def = new_key_def = self.get_keysym_section(key_label) 216 | if self.has_overlay(key_label, num_overlay): 217 | if self.get_overlay(key_label, num_overlay) == overlay_key_label: 218 | print(f"{key_label} is already overlayed by {overlay_key_label}") 219 | self.replaced_keys.append(old_key_def) 220 | return True 221 | old_overlay = re.search(r",\n *overlay[0-9] *= *<.{2,6}>", old_key_def).group(0) 222 | new_key_def = new_key_def.replace(old_overlay, "") 223 | new_key_def = new_key_def.replace("}", 224 | ", overlay" + str(num_overlay) + " = <" + overlay_key_label + "> \n}") 225 | self.replaced_keys.append(new_key_def) 226 | self.keymap_data = self.keymap_data.replace(old_key_def, new_key_def) 227 | print(f"overlayed {key_label} with {overlay_key_label}") 228 | return True 229 | except Exception as e: 230 | print(e) 231 | return False 232 | 233 | def remove_overlay_key_interpret(self, num_overlay): 234 | num_overlay = str(num_overlay) 235 | interpret_section = self.get_interpret_section("Overlay%s_Enable+AnyOfOrNone(all)" % num_overlay) 236 | if interpret_section is not None: 237 | self.keymap_data = self.keymap_data.replace(interpret_section, "") 238 | 239 | def set_overlay_enable_key(self, key_label, num_overlay): 240 | # set up modifier key 241 | old_keysym_section = self.get_keysym_section(key_label) 242 | keysym = read_file("assets/overlay_enable_keysym").replace("####", key_label).replace("$$$$", str(num_overlay)) 243 | self.replaced_keys.append(keysym) 244 | self.keymap_data = self.keymap_data.replace(old_keysym_section, keysym) 245 | 246 | def create_keysym_section(self, key_label, keys_string): 247 | try: 248 | new_key_data = "\n key <" + key_label + "> {[" + keys_string + "]};" 249 | xkb_symbols_section = old_xkb_symbols_section = self.get_xkb_symbols_section() 250 | xkb_symbols_section += new_key_data 251 | self.keymap_data = self.keymap_data.replace(old_xkb_symbols_section, xkb_symbols_section) 252 | self.used_key_labels.append(key_label) 253 | self.created_keys.append(new_key_data) 254 | return True 255 | except AttributeError as e: 256 | print(e) 257 | return False 258 | 259 | def create_keysym_sections(self, mapping_data, overlay_index): 260 | failed = False 261 | for mapping in mapping_data: 262 | if "mapped_keysym" in mapping: 263 | key_label = self.get_key_label(mapping["key_code"]) 264 | overlay_key_label = self.create_or_reuse_overlay(key_label, mapping["mapped_keysym"], overlay_index) 265 | if overlay_key_label is None: 266 | print(f"Failed to map {key_label} to {mapping['mapped_keysym']}") 267 | failed = True 268 | if failed and len(self.get_unused_key_labels()) == 0: 269 | print("============================================================") 270 | print("NO FREE KEY LABELS AVAILABLE FOR SPECIAL MAPPINGS!!! ") 271 | print("please try switching layouts using settings or log back in again if that is not helping") 272 | print("if this problem persists, you may also have exceeded the number of available key labels") 273 | print("============================================================") 274 | return not failed 275 | 276 | def map_overlay_keys(self, mapping_data, num_overlay): 277 | # add overlays to keys 278 | for mapping in mapping_data: 279 | if "mapped_key_label" in mapping: 280 | code = mapping["key_code"] 281 | overlay_key_label = mapping["mapped_key_label"] 282 | key_label = self.get_key_label(code) 283 | self.add_overlay_key(key_label, overlay_key_label, num_overlay) 284 | 285 | def create_or_reuse_overlay(self, key_label, key_sym, overlay_index): 286 | overlay_key_label = self.get_overlay(key_label, overlay_index) 287 | if not self.allow_reuse_overlays: 288 | overlay_key_label = None 289 | # TODO improve this check 290 | if overlay_key_label is not None and self.has_keysym_section(overlay_key_label): 291 | if key_sym not in self.get_keysym_section(overlay_key_label): 292 | print(f"Found active overlay for {key_label}: {overlay_key_label}, but {overlay_key_label} does not produce {key_sym}.") 293 | print(" That means it can not be re-used.") 294 | overlay_key_label = None 295 | reusing = keysym_created = False 296 | if overlay_key_label is None: 297 | overlay_key_label = self.get_unused_key_labels()[-1] 298 | self.create_keysym_section(overlay_key_label, key_sym) 299 | else: 300 | reusing = True 301 | if not self.has_keysym_section(overlay_key_label): 302 | keysym_created = True 303 | self.create_keysym_section(overlay_key_label, key_sym) 304 | if not self.add_overlay_key(key_label, overlay_key_label, overlay_index): 305 | print(f"Failed to overlay {key_label} with {overlay_key_label}") 306 | return None 307 | if reusing: 308 | print(f" Re-used existing mapping of {key_label} to {overlay_key_label} to generate {key_sym}.") 309 | if keysym_created: 310 | print(f" Re-created keysym section for {overlay_key_label} to generate {key_sym}.") 311 | if overlay_key_label is not None: 312 | if overlay_key_label in self.get_unused_key_labels(): 313 | self.get_unused_key_labels().remove(overlay_key_label) 314 | return overlay_key_label 315 | 316 | def map_commands(self, mapping_data, overlay): 317 | for mapping in mapping_data: 318 | if "mapped_sequences" in mapping: 319 | sequences = mapping["mapped_sequences"] 320 | key_label = self.get_key_label(mapping["key_code"]) 321 | overlay_key_label = self.create_or_reuse_overlay(key_label, "NoSymbol", overlay.index) 322 | if overlay_key_label is None: 323 | continue 324 | if overlay_key_label is not None: 325 | overlay.add_command_mapping(self.get_key_code(overlay_key_label), sequences) 326 | else: 327 | print(f"Could not find an overlay key to create for {key_label}") 328 | 329 | def generate_modified_keymap_data(self): 330 | self.__init__() 331 | self.capture_keymap(self.keymap_file) 332 | 333 | # remove key_labels added during last execution 334 | while len(self.used_key_labels) > 0: 335 | previously_used_key_label = self.used_key_labels.pop() 336 | try: 337 | self.keymap_data = self.keymap_data.replace(self.get_keysym_section(previously_used_key_label), "") 338 | except AttributeError as e: 339 | pass 340 | 341 | self.overlays = [] 342 | for num_overlay in [1, 2]: 343 | overlay_id = "overlay" + str(num_overlay) 344 | if self.config[overlay_id]["key"] != "disabled": 345 | overlay_key_label = self.config[overlay_id]["key"] 346 | overlay_key_code = self.get_key_code(overlay_key_label) 347 | overlay_key_mode = self.config[overlay_id]["mode"] 348 | mapping_data = self.config[overlay_id]["mapping"] 349 | self.create_keysym_sections(mapping_data, num_overlay) 350 | self.set_overlay_enable_key(overlay_key_label, num_overlay) 351 | self.map_overlay_keys(mapping_data, num_overlay) 352 | 353 | global xlib_support 354 | if xlib_support: 355 | overlay = CommandOverlay(num_overlay, overlay_key_code, overlay_key_mode) 356 | self.map_commands(mapping_data, overlay) 357 | self.overlays.append(overlay) 358 | 359 | return self.keymap_data 360 | 361 | def configure_keymap(self): 362 | self.generate_modified_keymap_data() 363 | 364 | with open(self.keymap_file, "w") as f: 365 | f.write(self.keymap_data) 366 | self.load_keymap_file(self.keymap_file) 367 | 368 | def capture_keymap(self, filename): 369 | os.system("xkbcomp -xkb $DISPLAY " + filename) 370 | self.keymap_data = read_file(filename) 371 | 372 | def load_keymap_file(self, filename): 373 | self.store_used_key_labels() 374 | print("Loading generated keymap with xkb...") 375 | output = os.popen("xkbcomp -xkb %s $DISPLAY" % filename).read() 376 | if "rror" not in output: 377 | print("Successfully loaded keymap.") 378 | os.remove(filename) 379 | else: 380 | print("Error while loading keymap.") 381 | print("INFO: still " + str(len(self.get_unused_key_labels())) + " key_labels available for custom mappings.") 382 | 383 | 384 | class CommandOverlay: 385 | def __init__(self, index, overlay_enable_key_code, overlay_enable_mode): 386 | self.command_mapping = {} 387 | self.index = index 388 | 389 | self.__available_modifiers = { 390 | "None": 0, 391 | "Shift": Xlib.X.ShiftMask, 392 | "Control": Xlib.X.ControlMask, 393 | "Alt": Xlib.X.Mod1Mask, 394 | } 395 | 396 | self.__available_modifier_state_mask = sum(self.__available_modifiers.values()) 397 | 398 | self.key_code = int(overlay_enable_key_code) 399 | if overlay_enable_mode == "hold": 400 | self.on_overlay_key = self.hold_overlay_key 401 | elif overlay_enable_mode == "toggle": 402 | self.on_overlay_key = self.toggle_overlay_key 403 | 404 | self.key_faker = None 405 | 406 | self.overlay_active = False 407 | 408 | self.hookManager = pyxhook.HookManager() 409 | self.hookManager.HookKeyboard() 410 | self.hookManager.KeyDown = self.handle_key_event 411 | self.hookManager.KeyUp = self.handle_key_event 412 | 413 | def start_hooking_keyboard(self): 414 | self.hookManager.start() 415 | self.key_faker = KeyFaker() 416 | 417 | def stop_hooking_keyboard(self): 418 | self.hookManager.cancel() 419 | 420 | def add_command_mapping(self, key_code, sequences): 421 | key_code = int(key_code) 422 | self.__adjust_command_sequences_prop_names(sequences) 423 | self.command_mapping[key_code] = self.__create_combined_modifiers_commands_map(sequences) 424 | 425 | def handle_key_event(self, event): 426 | if event.ScanCode == self.key_code: 427 | self.on_overlay_key(event.MessageName) 428 | else: 429 | if self.overlay_active and event.ScanCode in self.command_mapping: 430 | # restrict the state to only the modifiers available here 431 | restricted_state = event.XLibEvent.state & self.__available_modifier_state_mask 432 | command_sequences = self.command_mapping[event.ScanCode][restricted_state] 433 | # which modifiers triggered the command? 434 | captured_state = command_sequences["__trigger_modifier_state__"] 435 | # which modifiers are pressed additionally that we must continue to hold? 436 | forwarded_state = restricted_state - captured_state 437 | sequence = command_sequences[event.MessageName] 438 | self.execute_command_sequence(sequence, current_modifier_state=forwarded_state) 439 | 440 | def __adjust_command_sequences_prop_names(self, command_sequences): 441 | if "down" in command_sequences: 442 | command_sequences["key down"] = command_sequences["down"] 443 | if "up" in command_sequences: 444 | command_sequences["key up"] = command_sequences["up"] 445 | if "key down" not in command_sequences: 446 | command_sequences["key down"] = [] 447 | if "key up" not in command_sequences: 448 | command_sequences["key up"] = [] 449 | 450 | def __create_combined_modifiers_commands_map(self, command_sequences): 451 | # all possible modifier states, thus any combinations of available modifiers summed up 452 | all_possible_combined_states = [0] # 0 = state for no modifiers 453 | 454 | # A map that maps any modifier combination to a command. 455 | # A command is assigned to a modifier combination, iff its trigger state ANDs (&) with the combination and 456 | # at the same time no command is defined for a larger combination that fits the combination 457 | all_possible_states_command_map = {} 458 | 459 | for i in range(0, len(self.__available_modifiers) + 1): 460 | # get all modifier combinations for length i (unfortunately includes different ordering as well) 461 | combinations_length_i = itertools.combinations(self.__available_modifiers.values(), i) 462 | # sum up items of each combination 463 | states_length_i = [sum(combination) for combination in combinations_length_i] 464 | # eliminate duplicates 465 | states_length_i = list(set(states_length_i)) 466 | all_possible_combined_states += states_length_i 467 | 468 | command_sequences["__trigger_modifier_state__"] = 0 469 | 470 | for state_combination in all_possible_combined_states: 471 | all_possible_states_command_map[state_combination] = command_sequences 472 | 473 | if "if_modifiers" in command_sequences: 474 | state_command_list = [] 475 | state_command_map = {} 476 | 477 | for modifiers, command in command_sequences["if_modifiers"].items(): 478 | state = self.__parse_modifier_state(modifiers) 479 | state_command_list.append(state) 480 | command["__trigger_modifier_state__"] = state 481 | self.__adjust_command_sequences_prop_names(command) 482 | state_command_map[state] = command 483 | 484 | state_command_list.sort() 485 | 486 | for state in all_possible_combined_states: 487 | command = None 488 | for trigger_state in state_command_list: 489 | if state & trigger_state > 0: 490 | command = state_command_map[trigger_state] 491 | if command: 492 | all_possible_states_command_map[state] = command 493 | 494 | return all_possible_states_command_map 495 | 496 | def __get_active_modifiers(self, modifier_state): 497 | modifiers = [] 498 | for modifier, state in self.__available_modifiers: 499 | if modifier_state & state: 500 | modifiers.append(modifier) 501 | return modifiers 502 | 503 | def __parse_modifier_state(self, modifiers): 504 | parsed_state = 0 505 | modifiers_list = [] 506 | if isinstance(modifiers, list): 507 | modifiers_list = modifiers 508 | # when the user gave us a plus-separated string with modifiers, split them to obtain a list 509 | if isinstance(modifiers, str): 510 | modifiers_list = modifiers.split("+") 511 | # remove space before and after each modifier 512 | modifiers_list = [mod.strip(" ") for mod in modifiers_list] 513 | for modifier, state in self.__available_modifiers.items(): 514 | if modifier in modifiers_list: 515 | parsed_state = parsed_state | state 516 | return parsed_state 517 | 518 | def __get_user_added_modifiers(self, command): 519 | if "modifiers" in command: 520 | return self.__parse_modifier_state(command["modifiers"]) 521 | else: 522 | return 0 523 | 524 | def execute_command_sequence(self, sequence, current_modifier_state=0): 525 | for command in sequence: 526 | try: 527 | if isinstance(command, str): 528 | os.system(command) 529 | elif isinstance(command, dict): 530 | times = 1 531 | if "times" in command: 532 | times = command["times"] 533 | text = command["text"] if "text" in command else None 534 | key = command["key"] if "key" in command else None 535 | key_code = command["key_code"] if "key_code" in command else None 536 | # OR the user-added modifiers with the current modifier state, 537 | # so that modifiers that are currently hold by the user do not get lost 538 | modifier_state = self.__get_user_added_modifiers(command) | current_modifier_state 539 | for i in range(times): 540 | if text: 541 | self.key_faker.type_text(text) 542 | elif key: 543 | self.key_faker.send_key(key, state=modifier_state) 544 | elif key_code: 545 | self.key_faker.send_key_code(key_code, state=modifier_state) 546 | except Exception as e: 547 | print("Error executing user defined command: ", e) 548 | 549 | def toggle_overlay_key(self, event_message): 550 | if event_message == "key down": 551 | self.overlay_active = not self.overlay_active 552 | 553 | def hold_overlay_key(self, event_message): 554 | self.overlay_active = (event_message == "key down") 555 | 556 | 557 | class KeyFaker: 558 | special_character_mapping = { 559 | '@': 'at', '`': 'grave', '\t': 'Tab', '|': 'bar', '\n': 'Return', '\r': 'Return', 560 | '~': 'asciitilde', 561 | '{': 'braceleft', '[': 'bracketleft', ']': 'bracketright', '\\': 'backslash', 562 | '_': 'underscore', 563 | '^': 'asciicircum', '!': 'exclam', ' ': 'space', '#': 'numbersign', '"': 'quotedbl', 564 | '%': 'percent', '$': 'dollar', 565 | "'": 'apostrophe', '&': 'ampersand', ')': 'parenright', '(': 'parenleft', '+': 'plus', 566 | '*': 'asterisk', 567 | '-': 'minus', ',': 'comma', '/': 'slash', '.': 'period', '\\e': 'Escape', 568 | '}': 'braceright', ';': 'semicolon', 569 | ':': 'colon', '=': 'equal', '<': 'less', '?': 'question', '>': 'greater' 570 | } 571 | 572 | def __init__(self): 573 | self.display = Xlib.display.Display() 574 | self.root = self.display.screen().root 575 | 576 | def __current_window(self): 577 | return self.display.get_input_focus()._data["focus"] 578 | 579 | def __char_to_key_code(self, char): 580 | key_symbol = Xlib.XK.string_to_keysym(char) 581 | if key_symbol == 0: 582 | key_symbol = Xlib.XK.string_to_keysym(self.special_character_mapping[char]) 583 | return self.display.keysym_to_keycode(key_symbol) 584 | 585 | def send_key(self, key_string, state=0): 586 | self.send_key_code(self.display.keysym_to_keycode(Xlib.XK.string_to_keysym(key_string)), state=state) 587 | self.__key_release(key_code=0) 588 | 589 | def type_text(self, text): 590 | for char in text: 591 | if char.isupper() or "{}<>()_\"?~!$%^&*+|:@#".find(char) >= 0: 592 | state = Xlib.X.ShiftMask 593 | else: 594 | state = 0 595 | self.__send_character(char, state=state) 596 | self.__key_release(key_code=0) 597 | self.display.sync() 598 | 599 | def send_key_code(self, key_code, state=0): 600 | window = self.__current_window() 601 | self.__key_press(window=window, key_code=key_code, state=state) 602 | self.__key_release(window=window, key_code=key_code, state=state) 603 | 604 | def __send_character(self, character, state=0): 605 | key_code = self.__char_to_key_code(character) 606 | self.send_key_code(key_code, state=state) 607 | 608 | def __key_press(self, window=None, key=None, key_code=None, state=0): 609 | if window is None: 610 | window = self.__current_window() 611 | if key_code is None: 612 | key_code = self.__char_to_key_code(key) 613 | key_press_event = Xlib.protocol.event.KeyPress( 614 | time=int(time.time()), 615 | root=self.root, 616 | window=window, 617 | same_screen=0, child=Xlib.X.NONE, 618 | root_x=0, root_y=0, event_x=0, event_y=0, 619 | state=state, 620 | detail=key_code 621 | ) 622 | window.send_event(key_press_event, propagate=True) 623 | 624 | def __key_release(self, window=None, key=None, key_code=None, state=0): 625 | if window is None: 626 | window = self.__current_window() 627 | if key_code is None: 628 | key_code = self.__char_to_key_code(key) 629 | key_release_event = Xlib.protocol.event.KeyRelease( 630 | time=int(time.time()), 631 | root=self.root, 632 | window=window, 633 | same_screen=0, child=Xlib.X.NONE, 634 | root_x=0, root_y=0, event_x=0, event_y=0, 635 | state=state, 636 | detail=key_code 637 | ) 638 | window.send_event(key_release_event, propagate=True) 639 | 640 | 641 | if __name__ == "__main__": 642 | mapper = Mapper() 643 | mapper.main() 644 | --------------------------------------------------------------------------------