diff --git a/keymap_drawer/config.py b/keymap_drawer/config.py index d0f20e7..e8bacff 100644 --- a/keymap_drawer/config.py +++ b/keymap_drawer/config.py @@ -221,6 +221,20 @@ class KeySidePars(BaseModel): class ParseConfig(BaseSettings, env_prefix="KEYMAP_", extra="ignore"): """Configuration settings related to parsing QMK/ZMK keymaps.""" + class ModifierFnMap(BaseModel): + """Mapping to replace modifiers in modifier functions with the given string.""" + + left_ctrl: str = "C" + right_ctrl: str = "C" + left_shift: str = "S" + right_shift: str = "S" + left_alt: str = "A" # Alt/Opt + right_alt: str = "A" # Alt/Opt/AltGr + left_gui: str = "G" # Cmd/Win + right_gui: str = "G" # Cmd/Win + keycode_combiner: str = "{mods}+{key}" # pattern to join modifier functions with the modified keycode + modifier_combiner: str = "{mod_1}{mod_2}" # string to join multiple modifier function strings + # run C preprocessor on ZMK keymaps preprocess: bool = True @@ -246,6 +260,10 @@ class ParseConfig(BaseSettings, env_prefix="KEYMAP_", extra="ignore"): # layer is active (which is the default behavior) or *any* of them (with this option) mark_alternate_layer_activators: bool = False + # convert modifiers in modifier functions (used in keycodes with built-in modifiers like LC(V) + # in ZMK or LCTL(KC_V) in QMK) to given symbols -- set to None/null to disable the mapping + modifier_fn_map: ModifierFnMap | None = ModifierFnMap() + # remove these prefixes from QMK keycodes before further processing # can be augmented with other locale prefixes, e.g. "DE_" qmk_remove_keycode_prefix: list[str] = ["KC_"] diff --git a/keymap_drawer/keymap.py b/keymap_drawer/keymap.py index 4d79fed..47894ec 100644 --- a/keymap_drawer/keymap.py +++ b/keymap_drawer/keymap.py @@ -6,7 +6,7 @@ from collections import defaultdict from functools import partial from itertools import chain -from typing import Iterable, Literal, Mapping, Sequence +from typing import Iterable, Literal, Mapping, Sequence, Callable from pydantic import BaseModel, Field, root_validator, validator @@ -45,6 +45,15 @@ def dict(self, *args, no_tapstr: bool = False, **kwargs): return dict_repr return dict_repr.get("t") or dict_repr.get("tap", "") + def apply_formatter(self, formatter: Callable[[str], str]) -> None: + """Add a formatter function (str -> str) to all non-empty fields.""" + if self.tap: + self.tap = formatter(self.tap) + if self.hold: + self.hold = formatter(self.hold) + if self.shifted: + self.shifted = formatter(self.shifted) + class ComboSpec(BaseModel, allow_population_by_field_name=True): """ diff --git a/keymap_drawer/parse/parse.py b/keymap_drawer/parse/parse.py index 89d44e4..3450e1f 100644 --- a/keymap_drawer/parse/parse.py +++ b/keymap_drawer/parse/parse.py @@ -3,6 +3,7 @@ Do not use directly, use QmkJsonParser or ZmkKeymapParser instead. """ +import re from abc import ABC from io import TextIOWrapper from typing import Sequence @@ -18,6 +19,8 @@ class ParseError(Exception): class KeymapParser(ABC): # pylint: disable=too-many-instance-attributes """Abstract base class for parsing firmware keymap representations.""" + _modifier_fn_to_std: dict[str, list[str]] + def __init__( self, config: ParseConfig, @@ -33,6 +36,42 @@ def __init__( self.conditional_layers: dict[int, list[int]] = {} # then-layer to if-layers mapping self.trans_key = LayoutKey.from_key_spec(self.cfg.trans_legend) self.raw_binding_map = self.cfg.raw_binding_map.copy() + self.modifier_fn_re = re.compile( + "(" + "|".join(re.escape(mod) for mod in self._modifier_fn_to_std) + r") *\( *(.*) *\)" + ) + + def parse_modifier_fns(self, keycode: str) -> tuple[str, list[str]]: + """ + Strip potential modifier functions from the keycode then return a tuple of the keycode and the modifiers. + """ + if self.cfg.modifier_fn_map is None: + return keycode, [] + + def strip_modifiers(keycode: str, current_mods: list[str] | None = None) -> tuple[str, list[str]]: + if current_mods is None: + current_mods = [] + if not (m := self.modifier_fn_re.fullmatch(keycode)): + return keycode, current_mods + return strip_modifiers(m.group(2), current_mods + self._modifier_fn_to_std[m.group(1)]) + + return strip_modifiers(keycode) + + def format_modified_keys(self, key_str: str, modifiers: list[str]) -> str: + """ + Format the combination of modifier functions and modified keycode into their display form, + as configured by parse_config.modifier_fn_map. + """ + if self.cfg.modifier_fn_map is None or not modifiers: + return key_str + + fn_map = self.cfg.modifier_fn_map.dict() + assert all( + mod in fn_map for mod in modifiers + ), f"Not all modifier functions in {modifiers} have a corresponding mapping in parse_config.modifier_fn_map" + fns_str = fn_map[modifiers[0]] + for mod in modifiers[1:]: + fns_str = self.cfg.modifier_fn_map.modifier_combiner.format(mod_1=fns_str, mod_2=fn_map[mod]) + return self.cfg.modifier_fn_map.keycode_combiner.format(mods=fns_str, key=key_str) def update_layer_activated_from( self, from_layers: Sequence[int], to_layer: int, key_positions: Sequence[int] diff --git a/keymap_drawer/parse/qmk.py b/keymap_drawer/parse/qmk.py index 0a85418..12d6da8 100644 --- a/keymap_drawer/parse/qmk.py +++ b/keymap_drawer/parse/qmk.py @@ -21,6 +21,43 @@ class QmkJsonParser(KeymapParser): _osm_re = re.compile(r"OSM\(MOD_(\S+)\)") _osl_re = re.compile(r"OSL\((\d+)\)") + _modifier_fn_to_std = { + "LCTL": ["left_ctrl"], + "C": ["left_ctrl"], + "LSFT": ["left_shift"], + "S": ["left_shift"], + "LALT": ["left_alt"], + "A": ["left_alt"], + "LOPT": ["left_alt"], + "LGUI": ["left_gui"], + "G": ["left_gui"], + "LCMD": ["left_gui"], + "LWIN": ["left_gui"], + "RCTL": ["right_ctrl"], + "RSFT": ["right_shift"], + "RALT": ["right_alt"], + "ROPT": ["right_alt"], + "ALGR": ["right_alt"], + "RGUI": ["right_gui"], + "RCMD": ["right_gui"], + "RWIN": ["right_gui"], + "LSG": ["left_shift", "left_gui"], + "SGUI": ["left_shift", "left_gui"], + "SCMD": ["left_shift", "left_gui"], + "SWIN": ["left_shift", "left_gui"], + "LAG": ["left_alt", "left_gui"], + "RSG": ["right_shift", "right_gui"], + "RAG": ["right_alt", "right_gui"], + "LCA": ["left_ctrl", "left_alt"], + "LSA": ["left_shift", "left_alt"], + "RSA": ["right_shift", "right_alt"], + "SAGR": ["right_shift", "right_alt"], + "RCS": ["right_ctrl", "right_shift"], + "LCAG": ["left_ctrl", "left_alt", "left_gui"], + "MEH": ["left_ctrl", "left_shift", "left_alt"], + "HYPR": ["left_ctrl", "left_shift", "left_alt", "left_gui"], + } + def __init__( self, config: ParseConfig, @@ -46,9 +83,13 @@ def _str_to_key( # pylint: disable=too-many-return-statements assert self.layer_names is not None def mapped(key: str) -> LayoutKey: + key, mods = self.parse_modifier_fns(key) if self._prefix_re is not None: key = self._prefix_re.sub("", key) - return LayoutKey.from_key_spec(self.cfg.qmk_keycode_map.get(key, key.replace("_", " "))) + mapped = LayoutKey.from_key_spec(self.cfg.qmk_keycode_map.get(key, key.replace("_", " "))) + if mods: + mapped.apply_formatter(lambda key: self.format_modified_keys(key, mods)) + return mapped if m := self._trans_re.fullmatch(key_str): # transparent return self.trans_key diff --git a/keymap_drawer/parse/zmk.py b/keymap_drawer/parse/zmk.py index 6294583..f903ab7 100644 --- a/keymap_drawer/parse/zmk.py +++ b/keymap_drawer/parse/zmk.py @@ -19,6 +19,16 @@ class ZmkKeymapParser(KeymapParser): """Parser for ZMK devicetree keymaps, using C preprocessor and hacky pyparsing-based parsers.""" _numbers_re = re.compile(r"N(UM(BER)?_)?(\d)") + _modifier_fn_to_std = { + "LC": ["left_ctrl"], + "LS": ["left_shift"], + "LA": ["left_alt"], + "LG": ["left_gui"], + "RC": ["right_ctrl"], + "RS": ["right_shift"], + "RA": ["right_alt"], + "RG": ["right_gui"], + } def __init__( self, @@ -59,6 +69,7 @@ def _str_to_key( # pylint: disable=too-many-return-statements,too-many-locals assert self.layer_names is not None def mapped(key: str) -> LayoutKey: + key, mods = self.parse_modifier_fns(key) if self._prefix_re is not None: key = self._prefix_re.sub("", key) mapped = LayoutKey.from_key_spec( @@ -73,6 +84,8 @@ def mapped(key: str) -> LayoutKey: ) if no_shifted: mapped.shifted = "" + if mods: + mapped.apply_formatter(lambda key: self.format_modified_keys(key, mods)) return mapped match binding.split():