1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221
|
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Make the systems/environments mapping of keys and codes accessible."""
import json
import re
import subprocess
from typing import Optional, List, Iterable, Tuple
import evdev
from inputremapper.configs.paths import PathUtils
from inputremapper.logging.logger import logger
from inputremapper.utils import is_service
DISABLE_NAME = "disable"
DISABLE_CODE = -1
# xkb uses keycodes that are 8 higher than those from evdev
XKB_KEYCODE_OFFSET = 8
XMODMAP_FILENAME = "xmodmap.json"
LAZY_LOAD = None
class KeyboardLayout:
"""Stores information about all available keycodes."""
_mapping: Optional[dict] = LAZY_LOAD
_xmodmap: Optional[List[Tuple[str, str]]] = LAZY_LOAD
_case_insensitive_mapping: Optional[dict] = LAZY_LOAD
def __getattribute__(self, wanted: str):
"""To lazy load keyboard_layout info only when needed.
For example, this helps to keep logs of input-remapper-control clear when it
doesn't need it the information.
"""
lazy_loaded_attributes = ["_mapping", "_xmodmap", "_case_insensitive_mapping"]
for lazy_loaded_attribute in lazy_loaded_attributes:
if wanted != lazy_loaded_attribute:
continue
if object.__getattribute__(self, lazy_loaded_attribute) is LAZY_LOAD:
# initialize _mapping and such with an empty dict, for populate
# to write into
object.__setattr__(self, lazy_loaded_attribute, {})
object.__getattribute__(self, "populate")()
return object.__getattribute__(self, wanted)
def list_names(self, codes: Optional[Iterable[int]] = None) -> List[str]:
"""Get all possible names in the mapping, optionally filtered by codes.
Parameters
----------
codes: list of event codes
"""
if not codes:
return self._mapping.keys()
return [name for name, code in self._mapping.items() if code in codes]
def correct_case(self, symbol: str):
"""Return the correct casing for a symbol."""
if symbol in self._mapping:
return symbol
# only if not e.g. both "a" and "A" are in the mapping
return self._case_insensitive_mapping.get(symbol.lower(), symbol)
def _use_xmodmap_symbols(self):
"""Look up xmodmap -pke, write xmodmap.json, and get the symbols."""
try:
xmodmap = subprocess.check_output(
["xmodmap", "-pke"],
stderr=subprocess.STDOUT,
).decode()
except FileNotFoundError:
logger.info("Optional `xmodmap` command not found. This is not critical.")
return
except subprocess.CalledProcessError as e:
logger.error('Call to `xmodmap -pke` failed with "%s"', e)
return
self._xmodmap = re.findall(r"(\d+) = (.+)\n", xmodmap + "\n")
xmodmap_dict = self._find_legit_mappings()
if len(xmodmap_dict) == 0:
logger.info("`xmodmap -pke` did not yield any symbol")
return
# Write this stuff into the input-remapper config directory, because
# the systemd service won't know the user sessions xmodmap.
path = PathUtils.get_config_path(XMODMAP_FILENAME)
PathUtils.touch(path)
with open(path, "w") as file:
logger.debug('Writing "%s"', path)
json.dump(xmodmap_dict, file, indent=4)
for name, code in xmodmap_dict.items():
self._set(name, code)
def _use_linux_evdev_symbols(self):
"""Look up the evdev constant names and use them."""
for name, ecode in evdev.ecodes.ecodes.items():
if name.startswith("KEY") or name.startswith("BTN"):
self._set(name, ecode)
def populate(self):
"""Get a mapping of all available names to their keycodes."""
logger.debug("Gathering available keycodes")
self.clear()
if not is_service():
# xmodmap is only available from within the login session.
# The service that runs via systemd can't use this.
self._use_xmodmap_symbols()
self._use_linux_evdev_symbols()
self._set(DISABLE_NAME, DISABLE_CODE)
def update(self, mapping: dict):
"""Update this with new keys.
Parameters
----------
mapping
maps from name to code. Make sure your keys are lowercase.
"""
len_before = len(self._mapping)
for name, code in mapping.items():
self._set(name, code)
logger.debug(
"Updated keycodes with %d new ones", len(self._mapping) - len_before
)
def _set(self, name: str, code: int):
"""Map name to code."""
self._mapping[str(name)] = code
self._case_insensitive_mapping[str(name).lower()] = name
def get(self, name: str) -> Optional[int]:
"""Return the code mapped to the key."""
# the correct casing should be shown when asking the keyboard_layout
# for stuff. indexing case insensitive to support old presets.
if name not in self._mapping:
# only if not e.g. both "a" and "A" are in the mapping
name = self._case_insensitive_mapping.get(str(name).lower())
return self._mapping.get(name)
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_name(self, code: int):
"""Get the first matching name for the code."""
for entry in self._xmodmap:
if int(entry[0]) - XKB_KEYCODE_OFFSET == code:
return entry[1].split()[0]
# Fall back to the linux constants
# This is especially important for BTN_LEFT and such
btn_name = evdev.ecodes.BTN.get(code, None)
if btn_name is not None:
if type(btn_name) in [list, tuple]:
# python-evdev >= 1.8.0 uses tuples
return btn_name[0]
return btn_name
key_name = evdev.ecodes.KEY.get(code, None)
if key_name is not None:
if type(key_name) in [list, tuple]:
# python-evdev >= 1.8.0 uses tuples
return key_name[0]
return key_name
return None
def _find_legit_mappings(self) -> dict:
"""From the parsed xmodmap list find usable symbols and their codes."""
xmodmap_dict = {}
for keycode, names in self._xmodmap:
# there might be multiple, like:
# keycode 64 = Alt_L Meta_L Alt_L Meta_L
# keycode 204 = NoSymbol Alt_L NoSymbol Alt_L
# Alt_L should map to code 64. Writing code 204 only works
# if a modifier is applied at the same time. So take the first
# one.
name = names.split()[0]
xmodmap_dict[name] = int(keycode) - XKB_KEYCODE_OFFSET
return xmodmap_dict
# TODO DI
# this mapping represents the xmodmap output, which stays constant
keyboard_layout = KeyboardLayout()
|