Merged xkb prototype with new main

xkb
sezanzeb 3 years ago
parent f5bdafa682
commit cd625a1257

@ -0,0 +1,265 @@
// keycodes configuration for key-mapper presets
// The concept of "reasonable symbolic names" [3] doesn't apply
// when mouse buttons are all over the place. Furthermore this file has
// to work for all devices, which place their keys in different places.
// So create an identity mapping instead to make generating "symbols" files
// easier. Keycode 10 -> "<10>"
// This has the added benefit that keycodes reported by xev can be
// identified in the symbols file.
// Keycodes reported by evdev are 8 lower than those reported by xev,
// so 10 in this file means 2 in key-mapper.
default xkb_keycodes "key-mapper" {
minimum = 8;
maximum = 255;
<8> = 8;
<9> = 9;
<10> = 10;
<11> = 11;
<12> = 12;
<13> = 13;
<14> = 14;
<15> = 15;
<16> = 16;
<17> = 17;
<18> = 18;
<19> = 19;
<20> = 20;
<21> = 21;
<22> = 22;
<23> = 23;
<24> = 24;
<25> = 25;
<26> = 26;
<27> = 27;
<28> = 28;
<29> = 29;
<30> = 30;
<31> = 31;
<32> = 32;
<33> = 33;
<34> = 34;
<35> = 35;
<36> = 36;
<37> = 37;
<38> = 38;
<39> = 39;
<40> = 40;
<41> = 41;
<42> = 42;
<43> = 43;
<44> = 44;
<45> = 45;
<46> = 46;
<47> = 47;
<48> = 48;
<49> = 49;
<50> = 50;
<51> = 51;
<52> = 52;
<53> = 53;
<54> = 54;
<55> = 55;
<56> = 56;
<57> = 57;
<58> = 58;
<59> = 59;
<60> = 60;
<61> = 61;
<62> = 62;
<63> = 63;
<64> = 64;
<65> = 65;
<66> = 66;
<67> = 67;
<68> = 68;
<69> = 69;
<70> = 70;
<71> = 71;
<72> = 72;
<73> = 73;
<74> = 74;
<75> = 75;
<76> = 76;
<77> = 77;
<78> = 78;
<79> = 79;
<80> = 80;
<81> = 81;
<82> = 82;
<83> = 83;
<84> = 84;
<85> = 85;
<86> = 86;
<87> = 87;
<88> = 88;
<89> = 89;
<90> = 90;
<91> = 91;
<92> = 92;
<93> = 93;
<94> = 94;
<95> = 95;
<96> = 96;
<97> = 97;
<98> = 98;
<99> = 99;
<100> = 100;
<101> = 101;
<102> = 102;
<103> = 103;
<104> = 104;
<105> = 105;
<106> = 106;
<107> = 107;
<108> = 108;
<109> = 109;
<110> = 110;
<111> = 111;
<112> = 112;
<113> = 113;
<114> = 114;
<115> = 115;
<116> = 116;
<117> = 117;
<118> = 118;
<119> = 119;
<120> = 120;
<121> = 121;
<122> = 122;
<123> = 123;
<124> = 124;
<125> = 125;
<126> = 126;
<127> = 127;
<128> = 128;
<129> = 129;
<130> = 130;
<131> = 131;
<132> = 132;
<133> = 133;
<134> = 134;
<135> = 135;
<136> = 136;
<137> = 137;
<138> = 138;
<139> = 139;
<140> = 140;
<141> = 141;
<142> = 142;
<143> = 143;
<144> = 144;
<145> = 145;
<146> = 146;
<147> = 147;
<148> = 148;
<149> = 149;
<150> = 150;
<151> = 151;
<152> = 152;
<153> = 153;
<154> = 154;
<155> = 155;
<156> = 156;
<157> = 157;
<158> = 158;
<159> = 159;
<160> = 160;
<161> = 161;
<162> = 162;
<163> = 163;
<164> = 164;
<165> = 165;
<166> = 166;
<167> = 167;
<168> = 168;
<169> = 169;
<170> = 170;
<171> = 171;
<172> = 172;
<173> = 173;
<174> = 174;
<175> = 175;
<176> = 176;
<177> = 177;
<178> = 178;
<179> = 179;
<180> = 180;
<181> = 181;
<182> = 182;
<183> = 183;
<184> = 184;
<185> = 185;
<186> = 186;
<187> = 187;
<188> = 188;
<189> = 189;
<190> = 190;
<191> = 191;
<192> = 192;
<193> = 193;
<194> = 194;
<195> = 195;
<196> = 196;
<197> = 197;
<198> = 198;
<199> = 199;
<200> = 200;
<201> = 201;
<202> = 202;
<203> = 203;
<204> = 204;
<205> = 205;
<206> = 206;
<207> = 207;
<208> = 208;
<209> = 209;
<210> = 210;
<211> = 211;
<212> = 212;
<213> = 213;
<214> = 214;
<215> = 215;
<216> = 216;
<217> = 217;
<218> = 218;
<219> = 219;
<220> = 220;
<221> = 221;
<222> = 222;
<223> = 223;
<224> = 224;
<225> = 225;
<226> = 226;
<227> = 227;
<228> = 228;
<229> = 229;
<230> = 230;
<231> = 231;
<232> = 232;
<233> = 233;
<234> = 234;
<235> = 235;
<236> = 236;
<237> = 237;
<238> = 238;
<239> = 239;
<240> = 240;
<241> = 241;
<242> = 242;
<243> = 243;
<244> = 244;
<245> = 245;
<246> = 246;
<247> = 247;
<248> = 248;
<249> = 249;
<250> = 250;
<251> = 251;
<252> = 252;
<253> = 253;
<254> = 254;
<255> = 255;
};

@ -56,6 +56,10 @@ INITIAL_CONFIG = {
"y_scroll_speed": 0.5,
},
},
# if true, will generate symbols and keycodes files and applies them
# to the device (setxkbmap) in order to inject keys that are unknown
# to the system
'generate_xkb_config': True
}

@ -51,6 +51,7 @@ from keymapper.groups import (
)
from keymapper.gui.row import Row, to_string
from keymapper.key import Key
from keymapper.gui.xkb import apply_xkb_config
from keymapper.gui.reader import reader
from keymapper.gui.helper import is_helper_running
from keymapper.injection.injector import RUNNING, FAILED, NO_GRAB
@ -677,6 +678,14 @@ class Window:
self.show_status(CTX_APPLY, msg)
# TODO test
if custom_mapping.get('generate_xkb_config'):
try:
apply_xkb_config(self.group.key)
except Exception as error:
# since this is optional, ignore all exceptions
logger.error('apply_xkb_config failed: %s', error)
self.show_device_mapping_status()
return False

@ -0,0 +1,113 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
# Copyright (C) 2021 sezanzeb <proxima@hip70890b.de>
#
# This file is part of key-mapper.
#
# key-mapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# key-mapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""Handles calls to setxkbmap. See injection/xkb.py for more info.
Since the daemon doesn't know about the X session the gui has to do it.
"""
import os
import subprocess
import time
from keymapper.logger import logger
from keymapper.injection.injector import get_udev_name
from keymapper.injection.xkb import get_xkb_symbols_name
def get_device_id(device):
"""Return the device ID as known to the display server.
Can be used in setxkbmap.
Parameters
----------
device : string
Device name as found in evtest
"""
try:
names = subprocess.check_output(["xinput", "list", "--name-only"])
names = names.decode().split("\n")
ids = subprocess.check_output(["xinput", "list", "--id-only"])
ids = ids.decode().split("\n")
except subprocess.CalledProcessError as error:
# systemd services and ttys can't do that
logger.error(str(error))
return None
for name, id in zip(names, ids):
if name == device:
device_id = id
break
else:
return None
return device_id
def apply_xkb_config(group_key):
"""Call setxkbmap to apply a different xkb keyboard layout to a device.
Parameters
----------
group_key : string
"""
# TODO test
# needs at least 0.2 seconds for me until the mapping device
# is visible in xinput
mapped_name = get_udev_name(group_key, "mapped")
for _ in range(5):
time.sleep(0.2)
device_id = get_device_id(mapped_name)
if device_id is not None:
break
else:
logger.error('Failed to get device ID for "%s"', mapped_name)
return
name = get_xkb_symbols_name(group_key)
path = f"/usr/share/X11/xkb/symbols/{name}"
if not os.path.exists(path):
logger.debug('Symbols "%s" doen\'t exist, skipping setxkbmap', path)
return
logger.info("Applying xkb configuration")
# XkbBadKeyboard: wrong -device id
device_id = get_device_id(mapped_name)
if device_id is None:
return
cmd = [
"setxkbmap",
"-keycodes",
"key-mapper-keycodes",
"-symbols",
name,
"-device",
str(device_id),
]
logger.debug('Running "%s"', " ".join(cmd))
# TODO disable Popen for setxkbmap in tests
subprocess.Popen(cmd)

@ -22,6 +22,8 @@
"""Stores injection-process wide information."""
from evdev.ecodes import EV_KEY
from keymapper.logger import logger
from keymapper.injection.macros.parse import parse, is_this_a_macro
from keymapper.system_mapping import system_mapping
@ -129,9 +131,9 @@ class Context:
if is_this_a_macro(output):
continue
target_code = system_mapping.get(output)
target_code = system_mapping.get_or_allocate(output)
if target_code is None:
logger.error('Don\'t know what "%s" is', output)
logger.error('Could not map unknown key "%s"', output)
continue
for permutation in key.get_permutations():
@ -156,6 +158,18 @@ class Context:
"""
return key in self.macros or key in self.key_to_code
def is_written(self, code):
"""Check if this code will be written during the injection."""
# TODO test
if code in self.key_to_code.values():
return True
for macro in self.macros.values():
if code in macro.capabilities[EV_KEY]:
return True
return False
def maps_joystick(self):
"""If at least one of the joysticks will serve a special purpose."""
return (self.left_purpose, self.right_purpose) != (NONE, NONE)

@ -23,6 +23,7 @@
import asyncio
import traceback
import time
import multiprocessing
@ -33,6 +34,7 @@ from keymapper.logger import logger
from keymapper.groups import classify, GAMEPAD
from keymapper.mapping import DISABLE_CODE
from keymapper.injection.context import Context
from keymapper.injection.xkb import generate_xkb_config
from keymapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock
from keymapper.injection.consumer_control import ConsumerControl
@ -380,6 +382,14 @@ class Injector(multiprocessing.Process):
events=self._construct_capabilities(GAMEPAD in self.group.types),
)
if self.mapping.get("generate_xkb_config"):
try:
generate_xkb_config(self.context, self.group.key) # TODO test
except Exception as error:
# since this is optional, catch all exceptions and ignore them
logger.error("generate_xkb_config failed: %s", error)
logger.debug("".join(traceback.format_tb(error.__traceback__)).strip())
for source in sources:
# certain capabilities can have side effects apparently. with an
# EV_ABS capability, EV_REL won't move the mouse pointer anymore.

@ -374,7 +374,7 @@ class Macro:
_type_check(macro, [Macro], "m (modify)", 2)
modifier = str(modifier)
code = system_mapping.get(modifier)
code = system_mapping.get_or_allocate(modifier) # TODO test
if code is None:
raise KeyError(f'Unknown modifier "{modifier}"')
@ -419,7 +419,7 @@ class Macro:
_type_check_keyname(symbol)
symbol = str(symbol)
code = system_mapping.get(symbol)
code = system_mapping.get_or_allocate(symbol) # TODO test
self.capabilities[EV_KEY].add(code)
async def task(handler):

@ -0,0 +1,172 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
# Copyright (C) 2021 sezanzeb <proxima@hip70890b.de>
#
# This file is part of key-mapper.
#
# key-mapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# key-mapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""Handles xkb config files.
This is optional and can be disabled via the configuration. If disabled,
outputting keys that are unknown to the system layout is impossible.
It is optional because broken xkb configs can crash the X session or screw
up the injection, and in ttys xkb configs don't have any effect. setxkbmap
is hard to work with, and xkb configs are horrible.
It uses setxkbmap to tell the window manager to do stuff differently for
the injected keycodes.
workflow:
1. injector preparation sees that "a" maps to "b"
2. check which keycode "b" would usually be
2.a if not in the system_mapping, this keycode is unknown to the
window manager and therefore cannot be used. To fix that,
find an integer code that is not present in system_mapping yet.
2.b if in the system_mapping, use the existing int code.
3. in the symbols file map that code to "b"
4. the "key-mapper ... mapped" uinput is created by the daemon. Since the
daemon doesn't know anything about the users X session, the GUI calls
setxkbmap instead with the appropriate path generated by helper functions.
injection:
1. running injection sees that "a" was clicked on the keyboard
2. the injector knows based on the mapping that this maps to
e.g. 48 and injects it
3. the window manager sees code 48 and writes a "b" into the focused
application, because the xkb config tells it to do so
now it is possible to map "ö" on an US keyboard
Resources:
[1] https://wiki.archlinux.org/index.php/Keyboard_input
[2] http://people.uleth.ca/~daniel.odonnell/Blog/custom-keyboard-in-linuxx11
[3] https://www.x.org/releases/X11R7.7/doc/xorg-docs/input/XKB-Enhancing.html
Mapping code 10 to a on device_1 and 10 to shift on device_2 may cause issues
when pressing both at the same time, More information can be found in
readme/history.md. That's why the resulting symbols file should match
the existing keyboard layout as much as possible, so that shift stays on the
code that it would usually be.
"""
import os
from keymapper.logger import logger
from keymapper.paths import touch
from keymapper.system_mapping import system_mapping, XKB_KEYCODE_OFFSET
SYMBOLS_TEMPLATE = """default xkb_symbols "key-mapper" {
%s
};
"""
LINE_TEMPLATE = "key <%d> { [ %s ] };"
def get_xkb_symbols_name(device):
"""Get the name that can be used for -symbols argument of setxkbmap."""
return f"key-mapper/{device}".replace(" ", "_")
def generate_symbols_lines(context):
"""Generate lines to put in symbols files.
Returns
-------
string[]
list of 'key <...> {[...]};' strings
"""
symbols = []
# because tricky problems appeared during development, add this to
# have some assertion that it works correctly
used_codes = set()
for name, code in system_mapping.xmodmap_dict.items():
# TODO if name is a, how to get modified versions of it (A)
# into the symbols file?
code = int(code)
if not context.is_written(code):
# don't include any codes in the symbols file that are
# not used anyway
continue
logger.spam('"%s" (%s) from xmodmap is used', name, code)
symbols.append(LINE_TEMPLATE % (code + XKB_KEYCODE_OFFSET, name))
assert code not in used_codes
used_codes.add(code)
# TODO store unknown mappings in the context to clarify that it is
# unique per injection
for name, code in system_mapping.get_unknown_mappings().items():
logger.spam('"%s" (%s) is allocated', name, code)
symbols.append(LINE_TEMPLATE % (code + XKB_KEYCODE_OFFSET, name))
assert code not in used_codes
used_codes.add(code)
return symbols
def generate_xkb_config(context, device):
"""Generate the needed config file for apply_xkb_config.
If it is not needed, it will remove any existing xkb symbols config for
that device.
Parameters
----------
context : Context
device : string
Used to name the file in /usr/share/X11/xkb/symbols/key-mapper.
Existing configs with that name will be overwritten.
As indexed in get_devices
"""
# TODO test
name = get_xkb_symbols_name(device)
path = f"/usr/share/X11/xkb/symbols/{name}"
# remove the old xkb config. If there is no need to apply it then
# there is no need to keep it. The ui will apply it if it is there.
if os.path.exists(path):
os.remove(path)
if len(context.macros) == 0 and len(context.key_to_code) == 0:
return None
if len(system_mapping.get_unknown_mappings()) == 0:
# there is no need to change the layout of the device
return None
logger.info("Unknown characters found, creating xkb configs")
symbols = generate_symbols_lines(context)
if len(symbols) == 0:
logger.error("Failed to populate symbols with anything")
return
touch(path)
with open(path, "w") as f:
logger.info('Writing xkb symbols "%s"', path)
contents = SYMBOLS_TEMPLATE % "\n ".join(symbols)
logger.spam('"%s":\n%s', path, contents.strip())
f.write(contents)

@ -31,6 +31,7 @@ from keymapper.logger import logger
from keymapper.mapping import DISABLE_NAME, DISABLE_CODE
from keymapper.paths import get_config_path, touch
from keymapper.utils import is_service
from keymapper.valid_symbols import VALID_XKB_SYMBOLS
# xkb uses keycodes that are 8 higher than those from evdev
@ -47,6 +48,13 @@ class SystemMapping:
self._mapping = None
self._xmodmap = {}
self._case_insensitive_mapping = {}
self._allocated_unknowns = {} # int to str # TODO test
self.xmodmap_dict = {}
# this may contain more entries than _mapping, since _mapping
# stores only one code per character, but a keyboard layout can
# have one character mapped to multiple keys.
self._occupied_keycodes = set() # TODO test
def __getattribute__(self, key):
"""To lazy load system_mapping info only when needed.
@ -75,16 +83,21 @@ class SystemMapping:
"""Get a mapping of all available names to their keycodes."""
logger.debug("Gathering available keycodes")
self.clear()
xmodmap_dict = {}
try:
xmodmap = subprocess.check_output(
["xmodmap", "-pke"], stderr=subprocess.STDOUT
).decode()
xmodmap = xmodmap
self._xmodmap = re.findall(r"(\d+) = (.+)\n", xmodmap + "\n")
xmodmap_dict = self._find_legit_mappings()
if len(xmodmap_dict) == 0:
self.xmodmap_dict = self._find_legit_mappings()
if len(self.xmodmap_dict) == 0:
logger.info("`xmodmap -pke` did not yield any symbol")
for keycode in self.xmodmap_dict.values():
self._occupied_keycodes.add(keycode)
except (subprocess.CalledProcessError, FileNotFoundError):
# might be within a tty
logger.info("Optional `xmodmap` command not found. This is not critical.")
@ -97,9 +110,9 @@ class SystemMapping:
touch(path)
with open(path, "w") as file:
logger.debug('Writing "%s"', path)
json.dump(xmodmap_dict, file, indent=4)
json.dump(self.xmodmap_dict, file, indent=4)
for name, code in xmodmap_dict.items():
for name, code in self.xmodmap_dict.items():
self._set(name, code)
for name, ecode in evdev.ecodes.ecodes.items():
@ -138,12 +151,68 @@ class SystemMapping:
return self._mapping.get(name)
def get_or_allocate(self, character):
"""Get a code to inject for that character.
If that character does not exist in the systems keyboard layout,
remember it and return a free code for that to use.
Without modifying the keyboard layout of the display server
injecting the returned code won't do anything.
Parameters
----------
character : string
For example F24 or odiaeresis
"""
# TODO test
if character is None:
return None
character = str(character)
# check if part of the system layout
from_system_layout = self.get(character)
if from_system_layout is not None:
return from_system_layout
if character not in VALID_XKB_SYMBOLS:
# not something xkb can do stuff with
return None
# it's not part of the systems keyboard layout yet, allocate instead
for key, code in self._allocated_unknowns.items():
# check if already asked to allocate before
if key == character:
return code
for code in range(1, 256):
# find a free keycode in the range of working keycodes
# TODO test that keys like key_zenkakuhankaku from the linux
# headers are ignored when finding free codes. only the xmodmap
# layout is relevant
if code in self._occupied_keycodes:
continue
self._allocated_unknowns[character] = code
self._occupied_keycodes.add(code) # TODO test that added
logger.debug('Using %s for "%s"', code, character)
return code
return None
def clear(self):
"""Remove all mapped keys. Only needed for tests."""
keys = list(self._mapping.keys())
for key in keys:
del self._mapping[key]
def get_unknown_mappings(self):
"""Return a mapping of unknown characters to codes.
For example, odiaeresis is unknown on US keyboards. The code
in that case is just any code that was not used by the systems
keyboard layout.
"""
# TODO test
return self._allocated_unknowns
def get_name(self, code):
"""Get the first matching name for the code."""
for entry in self._xmodmap:

File diff suppressed because it is too large Load Diff

@ -17,7 +17,7 @@
<text x="32.5" y="14">coverage</text>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="83.0" y="15" fill="#010101" fill-opacity=".3">93%</text>
<text x="82.0" y="14">93%</text>
<text x="83.0" y="15" fill="#010101" fill-opacity=".3">91%</text>
<text x="82.0" y="14">91%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

@ -142,7 +142,7 @@ just need to be commited.
**Example system startup**
1. systemd loads `key-mapper.service` on boot
2. on login, `key-mapper-autoload.desktop` is executed, which has knowledge
2. on login, `key-mapper-autoload.desktop` is executed, which has knowledge
of the current user und doesn't run as root
2.1 it sends the users config directory to the service
2.2 it makes the service stop all ongoing injectings
@ -191,7 +191,7 @@ Is the device a gamepad? Does the GUI show joystick configurations?
- if yes, no: adjust `is_gamepad` to loosen up the constraints
- if no, yes: adjust `is_gamepad` to tighten up the constraints
Try to do it in such a way that other devices won't break. Also see
Try to do it in such a way that other devices won't break. Also see
readme/capabilities.md
**It won't offer mapping a button**

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

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

@ -113,6 +113,7 @@ setup(
("/usr/share/key-mapper/", glob.glob("data/*")),
("/usr/share/applications/", ["data/key-mapper.desktop"]),
("/usr/share/polkit-1/actions/", ["data/key-mapper.policy"]),
("/usr/share/X11/xkb/keycodes/", ["data/key-mapper-keycodes"]),
("/usr/lib/systemd/system", ["data/key-mapper.service"]),
("/etc/dbus-1/system.d/", ["data/keymapper.Control.conf"]),
("/etc/xdg/autostart/", ["data/key-mapper-autoload.desktop"]),

Loading…
Cancel
Save