Skip to content

Commit

Permalink
feat!: Parse and map modifier functions
Browse files Browse the repository at this point in the history
This is a breaking change, where using modified keycodes
like `LA(F4)` in the keys of `parse_config.*_keycode_map`
will not work as expected, since `LA` will be separated
from `F4` before the keycode mapping is applied.

The new way to achieve the same behavior is to place it
in the `parse_config.raw_binding_map` instead,
e.g. `"&kp LA(F4)": ...`
  • Loading branch information
caksoylar committed Apr 13, 2024
1 parent ce82fd8 commit b5bf711
Show file tree
Hide file tree
Showing 6 changed files with 159 additions and 2 deletions.
22 changes: 22 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,28 @@ _Type:_ `bool`

_Default:_ `false`

#### `modifier_fn_map`

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 `null` to disable the mapping. Valid fields:

- **`left_ctrl`**, **`right_ctrl`**, **`left_shift`**, **`right_shift`**, **`left_alt`**, **`right_alt`**, **`left_gui`**, **`right_gui`** (type: `str`):
Mapping of each modifier to their corresponding display forms.

_Default:_ `"Ctl"`, `"Ctl"`, `"Sft"`, `"Sft"`, `"Alt"`, `"AGr"`, `"Gui"`, `"Gui"`

- **`keycode_combiner`** (type: `str`): Pattern to join modifier functions with the modified keycode, must contain `{mods}` and `{key}`.

_Default:_ `"{mods}+ {key}"`

- **`mod_combiner`** (type: `str`): Pattern to join multiple modifier function strings, must contain `{mod_1}` and `{mod_2}`.

_Default:_ `"{mod_1}+{mod_2}"`

- **`special_combinations`** (type: `dict[str, str]`): Special look-up for combinations of mods, mod order is ignored. Keys must be modifier names joined by `+`.

_Default:_ `{"left_ctrl+left_alt+left_gui+left_shift": "Hyper", "left_ctrl+left_alt+left_shift": "Meh"}`

#### `qmk_remove_keycode_prefix`

Remove these prefixes from QMK keycodes before further processing.
Expand Down
26 changes: 26 additions & 0 deletions keymap_drawer/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,28 @@ 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. Includes `combiner`
patterns to determine how to format the result. Mod combinations in `mod_combinations` take
precedence over individual mod lookups.
"""

left_ctrl: str = "Ctl"
right_ctrl: str = "Ctl"
left_shift: str = "Sft"
right_shift: str = "Sft"
left_alt: str = "Alt" # Alt/Opt
right_alt: str = "AGr" # Alt/Opt/AltGr
left_gui: str = "Gui" # Cmd/Win
right_gui: str = "Gui" # Cmd/Win
keycode_combiner: str = "{mods}+ {key}" # pattern to join modifier functions with the modified keycode
mod_combiner: str = "{mod_1}+{mod_2}" # pattern to join multiple modifier function strings
special_combinations: dict[str, str] = { # special look-up for combinations of mods (mod order is ignored)
"left_ctrl+left_alt+left_gui+left_shift": "Hyper",
"left_ctrl+left_alt+left_shift": "Meh",
}

# run C preprocessor on ZMK keymaps
preprocess: bool = True

Expand Down Expand Up @@ -269,6 +291,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_"]
Expand Down
11 changes: 10 additions & 1 deletion keymap_drawer/keymap.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from collections import defaultdict
from functools import partial
from itertools import chain
from typing import Iterable, Literal
from typing import Iterable, Literal, Callable

from pydantic import BaseModel, Field, field_validator, model_serializer, model_validator

Expand Down Expand Up @@ -50,6 +50,15 @@ def full_serializer(self) -> dict[str, str]:
"""Custom serializer that always outputs a dict."""
return {k: v for k in ("tap", "hold", "shifted", "type") if (v := getattr(self, k))}

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, populate_by_name=True, extra="forbid"):
"""
Expand Down
46 changes: 46 additions & 0 deletions keymap_drawer/parse/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -33,6 +36,49 @@ 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") *\( *(.*) *\)"
)
if (mod_map := self.cfg.modifier_fn_map) is not None:
self._mod_combs_lookup = {
frozenset(mods.split("+")): val for mods, val in mod_map.special_combinations.items()
}

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

if (combo_str := self._mod_combs_lookup.get(frozenset(modifiers))) is not None:
fns_str = combo_str
else:
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.mod_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]
Expand Down
43 changes: 42 additions & 1 deletion keymap_drawer/parse/qmk.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,43 @@ class QmkJsonParser(KeymapParser):
_osl_re = re.compile(r"OSL\((\d+)\)")
_tt_re = re.compile(r"TT\((\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,
Expand All @@ -47,9 +84,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
Expand Down
13 changes: 13 additions & 0 deletions keymap_drawer/parse/zmk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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():
Expand Down

0 comments on commit b5bf711

Please sign in to comment.