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 Mar 12, 2024
1 parent 4aaa9b0 commit 6b4c523
Show file tree
Hide file tree
Showing 5 changed files with 122 additions and 2 deletions.
18 changes: 18 additions & 0 deletions keymap_drawer/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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_"]
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, Mapping, Sequence
from typing import Iterable, Literal, Mapping, Sequence, Callable

from pydantic import BaseModel, Field, root_validator, validator

Expand Down Expand Up @@ -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):
"""
Expand Down
39 changes: 39 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,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]
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 @@ -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,
Expand All @@ -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
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 6b4c523

Please sign in to comment.