Skip to content

Application Layer

Business logic and processing components.

Keymap Generator

keymap_generator

Main orchestration module for keymap image generation.

This module provides the primary entry point for generating Svalboard keymap visualization images. It coordinates the loading of configuration and keymap files, transformation of keycodes to display labels, rendering of SVG drawings, and export to image files.

The generation pipeline follows these steps: 1. Load configuration (with gradient generation for layer colors) 2. Load keymap from file or stdin 3. Transform keycodes to renderable target keys 4. Draw SVG images for requested layers 5. Export drawings to output directory

Example
>>> from pathlib import Path
>>> from skim.data.cli import InputFiles, OutputFiles, KeymapGeneratorTargets
>>> from skim.application.keymap_generator import generate_keymap
>>> inputs = InputFiles(keymap=Path("keymap.kbi"))
>>> outputs = OutputFiles(output_dir=Path("./images"), output_format="png")
>>> targets = KeymapGeneratorTargets(all_layers=True, overview=True)
>>> generate_keymap(inputs, outputs, targets)

logger module-attribute

logger = getLogger(__name__)

Module-level logger for keymap generation operations.

generate_keymap

generate_keymap(
    inputs: InputFiles,
    outputs: OutputFiles,
    targets: KeymapGeneratorTargets,
    show_special_keys_legend: bool = True,
    show_symbol_legend: bool = True,
    symbol_legend_flow: str | None = None,
    symbol_legend_columns: int | None = None,
    macros_scale: float | None = None,
    tap_dances_scale: float | None = None,
    symbols_scale: float | None = None,
    title: str | None = None,
    copyright_text: str | None = None,
    double_south: bool = False,
    width: float | None = None,
    adjust_lightness: float | None = None,
    adjust_saturation: float | None = None,
) -> None

Generate keymap visualization images.

Main entry point for the keymap generation pipeline. Loads configuration and keymap data, transforms keycodes to display labels, renders SVG drawings, and exports them to the specified output directory.

Parameters:

Name Type Description Default
inputs InputFiles

Configuration for input files (keymap path, config path, stdin flag).

required
outputs OutputFiles

Configuration for output (directory, format, overwrite flag).

required
targets KeymapGeneratorTargets

Specification of which layers and views to generate.

required

Raises:

Type Description
SystemExit

If the output directory path exists but is not a directory.

Note

The output directory is created if it doesn't exist. Existing files may be overwritten depending on the outputs.force_overwrite setting.

Source code in src/skim/application/keymap_generator.py
def generate_keymap(
    inputs: InputFiles,
    outputs: OutputFiles,
    targets: KeymapGeneratorTargets,
    show_special_keys_legend: bool = True,
    show_symbol_legend: bool = True,
    symbol_legend_flow: str | None = None,
    symbol_legend_columns: int | None = None,
    macros_scale: float | None = None,
    tap_dances_scale: float | None = None,
    symbols_scale: float | None = None,
    title: str | None = None,
    copyright_text: str | None = None,
    double_south: bool = False,
    width: float | None = None,
    adjust_lightness: float | None = None,
    adjust_saturation: float | None = None,
) -> None:
    """Generate keymap visualization images.

    Main entry point for the keymap generation pipeline. Loads configuration
    and keymap data, transforms keycodes to display labels, renders SVG
    drawings, and exports them to the specified output directory.

    Args:
        inputs: Configuration for input files (keymap path, config path,
            stdin flag).
        outputs: Configuration for output (directory, format, overwrite flag).
        targets: Specification of which layers and views to generate.

    Raises:
        SystemExit: If the output directory path exists but is not a directory.

    Note:
        The output directory is created if it doesn't exist. Existing files
        may be overwritten depending on the outputs.force_overwrite setting.
    """
    if not outputs.output_dir.is_dir():
        logger.error(f"The specified output directory is not a directory: {outputs.output_dir}")
        exit(1)

    if not outputs.output_dir.exists():
        outputs.output_dir.mkdir()

    keymap_for_defaults = None if inputs.force_stdin_keymap else inputs.keymap
    config: SkimConfig = _get_config(
        inputs.config,
        outputs.use_system_fonts,
        keymap_for_defaults=keymap_for_defaults,
        show_special_keys_legend=show_special_keys_legend,
        show_symbol_legend=show_symbol_legend,
        symbol_legend_flow=symbol_legend_flow,
        symbol_legend_columns=symbol_legend_columns,
        macros_scale=macros_scale,
        tap_dances_scale=tap_dances_scale,
        symbols_scale=symbols_scale,
        title_override=title,
        copyright_override=copyright_text,
        double_south_override=double_south,
        width_override=width,
        adjust_lightness=adjust_lightness,
        adjust_saturation=adjust_saturation,
    )
    input_keymap = _get_input_keymap(inputs, config)
    keymap = _resolve_keymap(config, input_keymap)
    keycode_mappings = load_keycode_mappings(config.keycodes)
    drawings = draw_keymap(
        config,
        keymap,
        targets,
        raw_keymap=input_keymap,
        keycode_mappings=keycode_mappings,
    )
    save_drawings(outputs, drawings, outputs.render_engine)

Config Generator

config_generator

Configuration generator for creating skim config YAML files.

Provides :class:ConfigGenerator which can produce default configuration templates or extract metadata from Keybard (.kbi) files to generate pre-populated configuration files.

Example

Generate default config::

generator = ConfigGenerator()
print(generator.generate_default())

Generate from Keybard file::

generator = ConfigGenerator()
yaml_config = generator.generate_from_keybard(
    Path("layout.kbi").read_text(),
    adjust_lightness=0.31,
)
Path("skim-config.yaml").write_text(yaml_config)

ConfigGenerator

Generates skim configuration YAML from defaults or source files.

generate_default

generate_default() -> str

Generate YAML from SkimConfig defaults.

Returns:

Type Description
str

YAML string containing the default skim configuration.

Source code in src/skim/application/config_generator.py
def generate_default(self) -> str:
    """Generate YAML from SkimConfig defaults.

    Returns:
        YAML string containing the default skim configuration.
    """
    config = SkimConfig()
    data = config.model_dump(mode="json")
    return yaml.dump(data, sort_keys=False, default_flow_style=False)

generate_from_keybard

generate_from_keybard(
    keybard_content: str,
    adjust_lightness: float | None = None,
    adjust_saturation: float | None = None,
) -> str

Generate config YAML by extracting metadata from a Keybard file.

Parses the .kbi JSON to extract layer colors, layer names, and custom keycode definitions. Optionally applies color adjustments.

Parameters:

Name Type Description Default
keybard_content str

JSON string from a .kbi file.

required
adjust_lightness float | None

Target lightness (0.0-1.0) for extracted colors.

None
adjust_saturation float | None

Max saturation (0.0-1.0) for extracted colors.

None

Returns:

Type Description
str

YAML string containing the generated skim configuration.

Raises:

Type Description
ValueError

If keybard_content is not valid JSON.

Source code in src/skim/application/config_generator.py
def generate_from_keybard(
    self,
    keybard_content: str,
    adjust_lightness: float | None = None,
    adjust_saturation: float | None = None,
) -> str:
    """Generate config YAML by extracting metadata from a Keybard file.

    Parses the .kbi JSON to extract layer colors, layer names, and
    custom keycode definitions. Optionally applies color adjustments.

    Args:
        keybard_content: JSON string from a .kbi file.
        adjust_lightness: Target lightness (0.0-1.0) for extracted colors.
        adjust_saturation: Max saturation (0.0-1.0) for extracted colors.

    Returns:
        YAML string containing the generated skim configuration.

    Raises:
        ValueError: If keybard_content is not valid JSON.
    """
    from skim.application.loaders.keymap_loader import is_empty_layer
    from skim.domain import KeymapType
    from skim.domain.adapters import KeymapJsonAdapter

    try:
        data = json.loads(keybard_content)
    except json.JSONDecodeError as e:
        raise ValueError(f"Invalid JSON in Keybard file: {e}") from e

    layer_colors_raw = data.get("layer_colors", [])
    layer_names = data.get("cosmetic", {}).get("layer", {})
    custom_keycodes = data.get("custom_keycodes", [])

    # Drop empty layers (all KC_NO/KC_TRNS) the same way the vial/c2json
    # path does in ``generate_from_keymap``. Without this, every empty
    # slot still gets a config entry and load_keymap's zip(indices,
    # non_empty) silently shifts every layer past the first empty one
    # — so e.g. ``MO(14)`` resolves to the wrong rendered layer because
    # skim's index 14 was paired with a layer that came from kbi[N>14].
    keymap_raw = data.get("keymap", [])
    normalized = KeymapJsonAdapter.transform(keymap_raw, KeymapType.KEYBARD)
    active_indices = [i for i, layer in enumerate(normalized) if not is_empty_layer(layer)]

    def apply_adjustment(hex_c: str) -> str:
        if adjust_lightness is not None or adjust_saturation is not None:
            return adjust_color(hex_c, adjust_lightness, adjust_saturation)
        return hex_c

    keyboard_layers = self._build_layers_for_indices(active_indices, layer_names)
    palette_layers = self._build_palette_layers_for_indices(
        active_indices, layer_colors_raw, apply_adjustment
    )
    keycode_overrides = self._build_keycode_overrides(custom_keycodes)

    config_dict: dict[str, Any] = SkimConfig().model_dump(mode="json")
    config_dict["keyboard"]["layers"] = keyboard_layers
    config_dict["keycodes"]["overrides"] = keycode_overrides
    config_dict["output"]["style"]["palette"]["layers"] = palette_layers

    # Populate macros and tap_dances from the parsed keymap.
    from skim.application.loaders.keycode_mappings_loader import (
        load_keycode_mappings,
    )
    from skim.application.loaders.keymap_loader import load_keymap_json

    parsed = load_keymap_json(keybard_content)
    validated = SkimConfig.model_validate(config_dict)
    adapter = KeycodeLabelAdapter(validated.keyboard, load_keycode_mappings(validated.keycodes))
    config_dict["keycodes"]["macros"] = [
        entry.model_dump(mode="json")
        for macro in parsed.macros
        if (entry := macro_to_config_entry(macro, adapter)) is not None
    ]
    config_dict["keycodes"]["tap_dances"] = [
        entry.model_dump(mode="json")
        for td in parsed.tap_dances
        if (entry := tap_dance_to_config_entry(td, adapter)) is not None
    ]

    return yaml.dump(config_dict, sort_keys=False, default_flow_style=False)

generate_from_keymap

generate_from_keymap(content: str) -> str

Generate config YAML from a Vial or c2json keymap file.

Detects the keymap format, counts layers to create default layer entries with auto-generated colors, scans for non-standard keycodes to populate overrides, and emits keycodes.macros and keycodes.tap_dances populated from the parsed keymap.

For Keybard files, delegates to generate_from_keybard() which extracts richer metadata (layer names, colors, custom keycodes).

Parameters:

Name Type Description Default
content str

JSON string from a keymap file (.vil or .json).

required

Returns:

Type Description
str

YAML string containing the generated skim configuration.

Raises:

Type Description
ValueError

If content is not valid JSON or format is unknown.

Source code in src/skim/application/config_generator.py
def generate_from_keymap(self, content: str) -> str:
    """Generate config YAML from a Vial or c2json keymap file.

    Detects the keymap format, counts layers to create default layer
    entries with auto-generated colors, scans for non-standard
    keycodes to populate overrides, and emits ``keycodes.macros`` and
    ``keycodes.tap_dances`` populated from the parsed keymap.

    For Keybard files, delegates to generate_from_keybard() which
    extracts richer metadata (layer names, colors, custom keycodes).

    Args:
        content: JSON string from a keymap file (.vil or .json).

    Returns:
        YAML string containing the generated skim configuration.

    Raises:
        ValueError: If content is not valid JSON or format is unknown.
    """
    from skim.application.loaders.keycode_mappings_loader import (
        load_keycode_mappings,
    )
    from skim.application.loaders.keymap_loader import (
        _detect_keymap_from_json,
        is_empty_layer,
        load_keymap_json,
    )
    from skim.domain import KeymapType

    try:
        data = json.loads(content)
    except json.JSONDecodeError as e:
        raise ValueError(f"Invalid JSON in keymap file: {e}") from e

    keymap_type = _detect_keymap_from_json(data)

    if keymap_type == KeymapType.KEYBARD:
        return self.generate_from_keybard(content)

    if keymap_type == KeymapType.VIAL:
        raw_layers = data.get("layout", [])
    else:
        raw_layers = data.get("layers", [])

    flat_layers = self._flatten_keymap_layers(raw_layers, keymap_type)
    active_indices = [i for i, layer in enumerate(flat_layers) if not is_empty_layer(layer)]

    keyboard_layers = [
        {"index": idx, "name": f"Layer {idx}", "id": None, "variant": None}
        for idx in active_indices
    ]
    palette_layers = self._build_default_palette_layers_for_indices(active_indices)

    active_flat = [flat_layers[i] for i in active_indices]
    standard = self._load_standard_keycodes()
    keycode_overrides = self._find_non_standard_keycodes(active_flat, standard)

    config_dict: dict[str, Any] = SkimConfig().model_dump(mode="json")
    config_dict["keyboard"]["layers"] = keyboard_layers
    config_dict["keycodes"]["overrides"] = keycode_overrides
    config_dict["output"]["style"]["palette"]["layers"] = palette_layers

    # Populate macros and tap_dances from the parsed keymap.
    parsed = load_keymap_json(content)
    validated = SkimConfig.model_validate(config_dict)
    adapter = KeycodeLabelAdapter(validated.keyboard, load_keycode_mappings(validated.keycodes))
    config_dict["keycodes"]["macros"] = [
        entry.model_dump(mode="json")
        for macro in parsed.macros
        if (entry := macro_to_config_entry(macro, adapter)) is not None
    ]
    config_dict["keycodes"]["tap_dances"] = [
        entry.model_dump(mode="json")
        for td in parsed.tap_dances
        if (entry := tap_dance_to_config_entry(td, adapter)) is not None
    ]

    return yaml.dump(config_dict, sort_keys=False, default_flow_style=False)

macro_preview

macro_preview(
    macro: SvalboardMacro[str], adapter: KeycodeLabelAdapter
) -> str

Format a macro as a single-line preview string.

" | "-separated actions. Keys inside an action are resolved through the keycode-label adapter and joined with ",". Text and delay actions use raw NerdFont markers (resolved to glyphs at TUI display time).

Source code in src/skim/application/config_generator.py
def macro_preview(macro: SvalboardMacro[str], adapter: KeycodeLabelAdapter) -> str:
    """Format a macro as a single-line preview string.

    ``" | "``-separated actions. Keys inside an action are resolved
    through the keycode-label adapter and joined with ``","``. Text and
    delay actions use raw NerdFont markers (resolved to glyphs at TUI
    display time).
    """
    formatted = [_format_macro_action(a, adapter) for a in macro.actions]
    return " | ".join(formatted)

tap_dance_preview

tap_dance_preview(
    tap_dance: SvalboardTapDance[str],
    adapter: KeycodeLabelAdapter,
) -> str

Format a tap dance as a single-line preview string.

Initials in fixed order (t:, h:, dt:, th:), space joined, omitting fields whose value is None. The tapping term is not shown.

Source code in src/skim/application/config_generator.py
def tap_dance_preview(tap_dance: SvalboardTapDance[str], adapter: KeycodeLabelAdapter) -> str:
    """Format a tap dance as a single-line preview string.

    Initials in fixed order (``t:``, ``h:``, ``dt:``, ``th:``), space
    joined, omitting fields whose value is ``None``. The tapping term
    is not shown.
    """
    parts: list[str] = []
    for attr, prefix in _TAP_DANCE_FIELD_ORDER:
        value = getattr(tap_dance, attr)
        if value is None:
            continue
        parts.append(f"{prefix}:{_resolve_key_label(value, adapter)}")
    return " ".join(parts)

macro_to_config_entry

macro_to_config_entry(
    macro: SvalboardMacro[str], adapter: KeycodeLabelAdapter
) -> Macro | None

Convert a parsed macro into a config-ready Macro model.

Returns None when macro.actions is empty so the bootstrap can skip placeholder rows that source formats reserve.

Source code in src/skim/application/config_generator.py
def macro_to_config_entry(
    macro: SvalboardMacro[str], adapter: KeycodeLabelAdapter
) -> _MacroConfig | None:
    """Convert a parsed macro into a config-ready ``Macro`` model.

    Returns ``None`` when ``macro.actions`` is empty so the bootstrap
    can skip placeholder rows that source formats reserve.
    """
    if not macro.actions:
        return None
    return _MacroConfig(id=macro.id, name=None, preview=macro_preview(macro, adapter))

tap_dance_to_config_entry

tap_dance_to_config_entry(
    tap_dance: SvalboardTapDance[str],
    adapter: KeycodeLabelAdapter,
) -> TapDance | None

Convert a parsed tap dance into a config-ready TapDance model.

Returns None when all four key fields are None so the bootstrap can skip placeholder rows.

Source code in src/skim/application/config_generator.py
def tap_dance_to_config_entry(
    tap_dance: SvalboardTapDance[str], adapter: KeycodeLabelAdapter
) -> _TapDanceConfig | None:
    """Convert a parsed tap dance into a config-ready ``TapDance`` model.

    Returns ``None`` when all four key fields are ``None`` so the
    bootstrap can skip placeholder rows.
    """
    if (
        tap_dance.tap is None
        and tap_dance.hold is None
        and tap_dance.double_tap is None
        and tap_dance.tap_then_hold is None
    ):
        return None
    return _TapDanceConfig(
        id=tap_dance.id, name=None, preview=tap_dance_preview(tap_dance, adapter)
    )

Loaders

loaders

Data loading modules for keymaps, configuration, and assets.

This package provides loaders for: - Keymap files (C2JSON, Vial, Keybard) - Skim configuration (YAML) - Keycode mappings - Nerd Font glyphs

__all__ module-attribute

__all__ = [
    "load_keycode_mappings",
    "load_keymap",
    "load_nerdfont_glyphs",
    "load_skim_config",
]

load_keycode_mappings cached

load_keycode_mappings(
    keycodes_config: Keycodes,
) -> KeycodeMappings

Load and merge keycode mappings.

Loads the bundled keycode-mappings.yaml file and applies any user-provided pre-processing and override mappings from the skim configuration.

Parameters:

Name Type Description Default
keycodes_config Keycodes

The keycode overrides from the skim configuration (SkimConfig.keycodes).

required

Returns:

Type Description
KeycodeMappings

Dictionary containing merged keycode mappings with keys:

KeycodeMappings
  • keycodes: Keycode to label mappings
KeycodeMappings
  • pre_processing: Keycode normalization rules
KeycodeMappings
  • macro_functions: Macro template definitions
KeycodeMappings
  • modifier_union: Modifier constant mappings
KeycodeMappings
  • symbol_descriptions: Flat keycode-to-description map
KeycodeMappings
  • symbol_categories: Keycode-to-category-name map
KeycodeMappings
  • symbol_category_order: Category names in YAML insertion order
KeycodeMappings
  • function_descriptions: Flat function-name-to-description map
KeycodeMappings
  • function_categories: Function-name-to-category-name map
KeycodeMappings
  • function_category_order: Category names in YAML insertion order
Source code in src/skim/application/loaders/keycode_mappings_loader.py
@lru_cache(maxsize=1)
def load_keycode_mappings(keycodes_config: Keycodes) -> KeycodeMappings:
    """Load and merge keycode mappings.

    Loads the bundled keycode-mappings.yaml file and applies any
    user-provided pre-processing and override mappings from the
    skim configuration.

    Args:
        keycodes_config: The keycode overrides from the skim configuration
            (SkimConfig.keycodes).

    Returns:
        Dictionary containing merged keycode mappings with keys:
        - ``keycodes``: Keycode to label mappings
        - ``pre_processing``: Keycode normalization rules
        - ``macro_functions``: Macro template definitions
        - ``modifier_union``: Modifier constant mappings
        - ``symbol_descriptions``: Flat keycode-to-description map
        - ``symbol_categories``: Keycode-to-category-name map
        - ``symbol_category_order``: Category names in YAML insertion order
        - ``function_descriptions``: Flat function-name-to-description map
        - ``function_categories``: Function-name-to-category-name map
        - ``function_category_order``: Category names in YAML insertion order
    """
    mapping_path = ASSETS.keycode_mappings
    mapping = yaml.safe_load(mapping_path.read_text())
    for keycode in keycodes_config.pre_process:
        mapping["pre_processing"][keycode.keycode] = keycode.target
    for keycode in keycodes_config.overrides:
        mapping["keycodes"][keycode.keycode] = keycode.target

    # Merge user-provided symbol/function descriptions and aliases before
    # flattening so the category structure is preserved through the merge.
    mapping["symbol_descriptions"] = _merge_nested_descriptions(
        mapping.get("symbol_descriptions", {}),
        keycodes_config.symbol_descriptions,
    )
    mapping["function_descriptions"] = _merge_nested_descriptions(
        mapping.get("function_descriptions", {}),
        keycodes_config.function_descriptions,
    )
    # Aliases are flat — shallow merge
    aliases = dict(mapping.get("symbol_legend_aliases", {}))
    aliases.update(keycodes_config.symbol_legend_aliases)
    mapping["symbol_legend_aliases"] = aliases

    # Flatten nested symbol_descriptions and function_descriptions, building
    # parallel category maps for use by the symbol legend renderer.
    raw_symbol = mapping["symbol_descriptions"]
    flat_symbol, sym_cats, sym_order = _flatten_descriptions(raw_symbol)
    mapping["symbol_descriptions"] = flat_symbol
    mapping["symbol_categories"] = sym_cats
    mapping["symbol_category_order"] = sym_order

    raw_func = mapping["function_descriptions"]
    flat_func, func_cats, func_order = _flatten_descriptions(raw_func)
    mapping["function_descriptions"] = flat_func
    mapping["function_categories"] = func_cats
    mapping["function_category_order"] = func_order

    return MappingProxyType(mapping)

load_keymap

load_keymap(
    file_path: Path | None,
    layer_indices: list[int] | None = None,
) -> SvalboardKeymap[str]

Load a complete keymap from file or stdin.

Source code in src/skim/application/loaders/keymap_loader.py
def load_keymap(
    file_path: Path | None,
    layer_indices: list[int] | None = None,
) -> SvalboardKeymap[str]:
    """Load a complete keymap from file or stdin."""
    parsed: ParsedKeymap | None = None
    if file_path is None:
        parsed = load_keymap_from_stdin()
    elif file_path.is_file():
        parsed = load_keymap_file(file_path)

    if parsed is not None:
        non_empty = [layer for layer in parsed.layers if not is_empty_layer(layer)]
        indices = layer_indices or list(range(len(non_empty)))
        return SvalboardKeymap(
            layers={
                idx: SvalboardLayout[str].from_sequence(layer)
                for idx, layer in zip(indices, non_empty, strict=False)
            },
            tap_dances=parsed.tap_dances,
            macros=parsed.macros,
        )

    raise ValueError("The provided keymap file path does not exist")

load_nerdfont_glyphs cached

load_nerdfont_glyphs() -> dict[str, str]

Load Nerd Font glyph mappings from the bundled JSON file.

Reads the nerd_glyphnames.json file from the assets directory and transforms it into a dictionary mapping prefixed glyph names to their corresponding Unicode characters.

The function uses lru_cache to cache the loaded mappings, ensuring the JSON file is only read once per process lifetime.

Returns:

Type Description
dict[str, str]

Dictionary mapping glyph names (prefixed with "nf-") to their

dict[str, str]

corresponding Unicode characters. For example:

dict[str, str]

{"nf-md-home": "", "nf-fa-star": "", ...}

Raises:

Type Description
FileNotFoundError

If the nerd_glyphnames.json file is not found in the assets/data directory.

Example
>>> glyphs = load_nerdfont_glyphs()
>>> char = glyphs.get("nf-md-home")
>>> # char contains the Unicode home icon character
Source code in src/skim/application/loaders/nerdfont_glyphs_loader.py
@lru_cache(maxsize=1)
def load_nerdfont_glyphs() -> dict[str, str]:
    """Load Nerd Font glyph mappings from the bundled JSON file.

    Reads the nerd_glyphnames.json file from the assets directory and
    transforms it into a dictionary mapping prefixed glyph names to their
    corresponding Unicode characters.

    The function uses ``lru_cache`` to cache the loaded mappings, ensuring
    the JSON file is only read once per process lifetime.

    Returns:
        Dictionary mapping glyph names (prefixed with "nf-") to their
        corresponding Unicode characters. For example:
        ``{"nf-md-home": "\uf015", "nf-fa-star": "\uf005", ...}``

    Raises:
        FileNotFoundError: If the nerd_glyphnames.json file is not found
            in the assets/data directory.

    Example:
        ```pycon
        >>> glyphs = load_nerdfont_glyphs()
        >>> char = glyphs.get("nf-md-home")
        >>> # char contains the Unicode home icon character

        ```
    """
    json_path = ASSETS.nerd_font_glyphs

    with json_path.open("r", encoding="utf-8") as f:
        data = json.load(f)
    data.pop("METADATA", None)
    return {f"nf-{class_name}": info["char"] for class_name, info in data.items()}

load_skim_config

load_skim_config(
    config_path: Path | None = None,
) -> SkimConfig

Load skim configuration from a YAML file.

Loads and validates configuration from the specified YAML file. If no path is provided or the path doesn't point to an existing file, returns a default SkimConfig with all default values.

Parameters:

Name Type Description Default
config_path Path | None

Optional path to a YAML configuration file. If None or if the file doesn't exist, returns default configuration.

None

Returns:

Type Description
SkimConfig

A validated SkimConfig instance, either loaded from the file or

SkimConfig

with default values.

Raises:

Type Description
ValidationError

If the YAML content doesn't match the expected configuration schema.

YAMLError

If the file content is not valid YAML.

Example
>>> # Load from file
>>> config = load_skim_config(Path("custom-config.yaml"))

>>> # Get default config
>>> config = load_skim_config()
>>> config.output.layout.width
800
Source code in src/skim/application/loaders/skim_config_loader.py
def load_skim_config(config_path: Path | None = None) -> SkimConfig:
    """Load skim configuration from a YAML file.

    Loads and validates configuration from the specified YAML file. If no
    path is provided or the path doesn't point to an existing file, returns
    a default SkimConfig with all default values.

    Args:
        config_path: Optional path to a YAML configuration file. If None
            or if the file doesn't exist, returns default configuration.

    Returns:
        A validated SkimConfig instance, either loaded from the file or
        with default values.

    Raises:
        pydantic.ValidationError: If the YAML content doesn't match the
            expected configuration schema.
        yaml.YAMLError: If the file content is not valid YAML.

    Example:
        ```pycon
        >>> # Load from file
        >>> config = load_skim_config(Path("custom-config.yaml"))

        >>> # Get default config
        >>> config = load_skim_config()
        >>> config.output.layout.width
        800

        ```
    """
    path = config_path or Path("")
    if path.is_file():
        return SkimConfig.model_validate(
            yaml.safe_load(path.read_text()), strict=True, extra="forbid"
        )
    return SkimConfig()

Render

render

Rendering module for generating keymap visualizations.

This package owns every image entry point — the draw_* functions that take a config + keymap and return a drawsvg.Drawing. Each one builds a :class:RenderContext, constructs the corresponding document composable inside it, and hands the result to :func:render. Composable modules (keymap_layer.py, keymap_overview.py, macros.py, tap_dance.py, symbols.py, etc.) own only the composables themselves; the entry-point shims live here.

Public surface:

  • :func:draw_keymap — top-level orchestrator. Picks which images to render based on :class:KeymapGeneratorTargets and dispatches to the per-image entry points below.
  • :func:draw_overview — multi-layer overview image.
  • :func:draw_macros_image — standalone macros image.
  • :func:draw_tap_dances_image — standalone tap-dances image.
  • :func:draw_special_keys_image — combined macros + tap-dances.
  • :func:draw_symbols_image — standalone symbols image.

logger module-attribute

logger = getLogger(__name__)

__all__ module-attribute

__all__ = [
    "draw_keymap",
    "draw_macros_image",
    "draw_overview",
    "draw_special_keys_image",
    "draw_symbols_image",
    "draw_tap_dances_image",
    "make_gradient",
]

make_gradient

make_gradient(
    base_color: str, base_index: int = 2
) -> tuple[str, str, str, str, str, str]

Generate a 6-color gradient with base color at specified index.

Creates a gradient that interpolates from dark to light colors, with the base color appearing at the specified index position.

Parameters:

Name Type Description Default
base_color str

The base color in hexadecimal format.

required
base_index int

Position (0-5) where base color should appear. Colors before this index will be darker, colors after will be lighter. Default: 2

2

Returns:

Type Description
tuple[str, str, str, str, str, str]

List of 6 hexadecimal color strings forming a gradient.

Examples:

>>> grad = make_gradient("#347156", base_index=2)
>>> len(grad)
6
>>> gradient[2]  # Base color at index 2
'#347156'
Source code in src/skim/application/render/styling.py
def make_gradient(base_color: str, base_index: int = 2) -> tuple[str, str, str, str, str, str]:
    """Generate a 6-color gradient with base color at specified index.

    Creates a gradient that interpolates from dark to light colors,
    with the base color appearing at the specified index position.

    Args:
        base_color: The base color in hexadecimal format.
        base_index: Position (0-5) where base color should appear.
                   Colors before this index will be darker,
                   colors after will be lighter. Default: 2

    Returns:
        List of 6 hexadecimal color strings forming a gradient.

    Examples:
        ```pycon
        >>> grad = make_gradient("#347156", base_index=2)
        >>> len(grad)
        6
        >>> gradient[2]  # Base color at index 2
        '#347156'

        ```
    """
    red, green, blue = str_to_rgb(base_color)
    hue, lightness, saturation = colorsys.rgb_to_hls(red, green, blue)

    num_colors = 6
    lightness_values = []

    for i in range(num_colors):
        if i < base_index:
            progress = i / base_index if base_index > 0 else 0
            target_l = lightness * (0.5 + 0.5 * progress)
        elif i == base_index:
            target_l = lightness
        else:
            remaining = num_colors - 1 - base_index
            progress = (i - base_index) / remaining if remaining > 0 else 0
            max_lightness = min(0.85, lightness * 1.9)
            target_l = lightness + (max_lightness - lightness) * progress

        lightness_values.append(min(1.0, target_l))

    gradient = []
    for target_l in lightness_values:
        adjusted_s = saturation
        if target_l > 0.7:
            saturation_factor = 1.0 - (target_l - 0.7) * 0.5
            adjusted_s = saturation * saturation_factor

        r_new, g_new, b_new = colorsys.hls_to_rgb(hue, target_l, adjusted_s)
        gradient.append(hex_str(r_new, g_new, b_new))

    return tuple(gradient)

draw_overview

draw_overview(
    config: SkimConfig,
    keymap: SvalboardKeymap[SvalboardTargetKey],
    raw_keymap: SvalboardKeymap[str] | None = None,
    keycode_mappings: KeycodeMappings | None = None,
    selected_layers: set[int] | None = None,
) -> Drawing

Render the multi-layer overview image.

Resolves every section's contents up-front so the document composable receives ready-to-paint data: all macros / tap-dances sorted, plus the union of symbol entries across the rendered layers.

selected_layers, when not None, restricts the overview to that subset of QMK layer indices (matching the per-layer image filter behavior of -l/--layer). None keeps the legacy "render every configured layer present in the keymap" behavior.

Source code in src/skim/application/render/__init__.py
def draw_overview(
    config: SkimConfig,
    keymap: SvalboardKeymap[SvalboardTargetKey],
    raw_keymap: SvalboardKeymap[str] | None = None,
    keycode_mappings: KeycodeMappings | None = None,
    selected_layers: set[int] | None = None,
) -> draw.Drawing:
    """Render the multi-layer overview image.

    Resolves every section's contents up-front so the document
    composable receives ready-to-paint data: all macros / tap-dances
    sorted, plus the union of symbol entries across the rendered
    layers.

    ``selected_layers``, when not ``None``, restricts the overview to
    that subset of QMK layer indices (matching the per-layer image
    filter behavior of ``-l/--layer``). ``None`` keeps the legacy
    "render every configured layer present in the keymap" behavior.
    """
    legends = config.output.style.legend_tables
    overview_macros = all_macros(keymap.macros) if legends.macros.show else []
    overview_tap_dances = all_tap_dances(keymap.tap_dances) if legends.tap_dances.show else []
    if legends.symbols.show:
        symbol_entries = _overview_symbol_entries(
            config, keymap, raw_keymap, keycode_mappings, selected_layers=selected_layers
        )
    else:
        symbol_entries = []
    with using_render_context(RenderContext.build(config, keymap)):
        return render(
            KeymapOverviewDocument(
                keymap=keymap,
                title=_resolve_image_title(config),
                copyright=config.output.copyright or "",
                macros=overview_macros,
                tap_dances=overview_tap_dances,
                symbol_entries=symbol_entries,
                selected_layers=selected_layers,
            )
        )

draw_macros_image

draw_macros_image(
    config: SkimConfig,
    keymap: SvalboardKeymap[SvalboardTargetKey],
) -> Drawing

Render the standalone macros image.

Body-scale is read from config.output.style.legend_tables.macros.scale (the CLI --macros-scale flag updates that field upstream). Body chips and pills scale by this factor; the chrome (title, footer, outer padding) stays at the unscaled per-image size.

Source code in src/skim/application/render/__init__.py
def draw_macros_image(
    config: SkimConfig,
    keymap: SvalboardKeymap[SvalboardTargetKey],
) -> draw.Drawing:
    """Render the standalone macros image.

    Body-scale is read from
    ``config.output.style.legend_tables.macros.scale`` (the CLI
    ``--macros-scale`` flag updates that field upstream). Body chips
    and pills scale by this factor; the chrome (title, footer, outer
    padding) stays at the unscaled per-image size.
    """
    with using_render_context(RenderContext.build(config, keymap)):
        return render(
            KeymapMacroDocument(
                macros=all_macros(keymap.macros),
                title=_resolve_image_title(config),
                copyright=config.output.copyright,
                scale=config.output.style.legend_tables.macros.scale,
            )
        )

draw_tap_dances_image

draw_tap_dances_image(
    config: SkimConfig,
    keymap: SvalboardKeymap[SvalboardTargetKey],
) -> Drawing

Render the standalone tap-dances image.

Body-scale is read from config.output.style.legend_tables.tap_dances.scale (the CLI --tap-dances-scale flag updates that field upstream).

Source code in src/skim/application/render/__init__.py
def draw_tap_dances_image(
    config: SkimConfig,
    keymap: SvalboardKeymap[SvalboardTargetKey],
) -> draw.Drawing:
    """Render the standalone tap-dances image.

    Body-scale is read from
    ``config.output.style.legend_tables.tap_dances.scale`` (the CLI
    ``--tap-dances-scale`` flag updates that field upstream).
    """
    with using_render_context(RenderContext.build(config, keymap)):
        return render(
            KeymapTapDanceDocument(
                tap_dances=all_tap_dances(keymap.tap_dances),
                title=_resolve_image_title(config),
                copyright=config.output.copyright,
                scale=config.output.style.legend_tables.tap_dances.scale,
            )
        )

draw_special_keys_image

draw_special_keys_image(
    config: SkimConfig,
    keymap: SvalboardKeymap[SvalboardTargetKey],
) -> Drawing

Render the combined special-keys image (macros left, tap-dances right).

Source code in src/skim/application/render/__init__.py
def draw_special_keys_image(
    config: SkimConfig,
    keymap: SvalboardKeymap[SvalboardTargetKey],
) -> draw.Drawing:
    """Render the combined special-keys image (macros left, tap-dances right)."""
    with using_render_context(RenderContext.build(config, keymap)):
        return render(
            KeymapSpecialKeysDocument(
                macros=all_macros(keymap.macros),
                tap_dances=all_tap_dances(keymap.tap_dances),
                title=_resolve_image_title(config),
                copyright=config.output.copyright,
            )
        )

draw_symbols_image

draw_symbols_image(
    config: SkimConfig,
    keymap: SvalboardKeymap[SvalboardTargetKey],
    entries: list[SymbolLegendEntry],
) -> Drawing

Render the standalone symbols image from a pre-collected entry set.

Caller is expected to gate on entries being non-empty (and on raw_keymap / keycode_mappings availability) — this entry point doesn't surface "skipping" warnings of its own. The sibling :func:draw_keymap orchestrator does that gating for the bundled-image case.

Body-scale is read from config.output.style.legend_tables.symbols.scale (the CLI --symbols-scale flag updates that field upstream).

Source code in src/skim/application/render/__init__.py
def draw_symbols_image(
    config: SkimConfig,
    keymap: SvalboardKeymap[SvalboardTargetKey],
    entries: list[SymbolLegendEntry],
) -> draw.Drawing:
    """Render the standalone symbols image from a pre-collected entry set.

    Caller is expected to gate on ``entries`` being non-empty (and on
    ``raw_keymap`` / ``keycode_mappings`` availability) — this entry
    point doesn't surface "skipping" warnings of its own. The
    sibling :func:`draw_keymap` orchestrator does that gating for
    the bundled-image case.

    Body-scale is read from
    ``config.output.style.legend_tables.symbols.scale`` (the CLI
    ``--symbols-scale`` flag updates that field upstream).
    """
    symbols_cfg = config.output.style.legend_tables.symbols
    flow_str = symbols_cfg.flow
    flow: FlowDirection = "row" if flow_str == "row" else "column"

    with using_render_context(RenderContext.build(config, keymap)):
        return render(
            KeymapSymbolDocument(
                entries=entries,
                title=_resolve_image_title(config),
                copyright=config.output.copyright,
                flow=flow,
                scale=symbols_cfg.scale,
            )
        )

draw_keymap

draw_keymap(
    config: SkimConfig,
    keymap: SvalboardKeymap[SvalboardTargetKey],
    targets: KeymapGeneratorTargets,
    raw_keymap: SvalboardKeymap[str] | None = None,
    keycode_mappings: KeycodeMappings | None = None,
) -> dict[str, Drawing]

Top-level dispatch: render every image the targets request.

Returns a {filename_stem: Drawing} dict the caller writes to disk. Per-image gating logic (skip when there are no macros to render, etc.) lives here so the per-image entry points stay config → Drawing without None-returning paths.

Source code in src/skim/application/render/__init__.py
def draw_keymap(
    config: SkimConfig,
    keymap: SvalboardKeymap[SvalboardTargetKey],
    targets: KeymapGeneratorTargets,
    raw_keymap: SvalboardKeymap[str] | None = None,
    keycode_mappings: KeycodeMappings | None = None,
) -> dict[str, draw.Drawing]:
    """Top-level dispatch: render every image the targets request.

    Returns a ``{filename_stem: Drawing}`` dict the caller writes to
    disk. Per-image gating logic (skip when there are no macros to
    render, etc.) lives here so the per-image entry points stay
    config → Drawing without ``None``-returning paths.
    """
    keymap_images: dict[str, draw.Drawing] = {}
    for qmk_idx, pos, layer in _selected_layers(keymap, targets, config):
        # Flatten the raw keycodes for this layer (if available) so the
        # per-layer document can collect symbol-legend entries from the
        # actual painted layer.
        raw_layer_keycodes: list[str] | None = None
        if raw_keymap is not None and qmk_idx in raw_keymap.layers:
            raw_layer = raw_keymap.layers[qmk_idx]
            raw_layer_keycodes = [k for k in raw_layer if k is not None]
        keymap_images[f"keymap-layer-{qmk_idx}"] = _draw_layer(
            config,
            keymap,
            layer,
            pos,
            qmk_idx,
            macros=keymap.macros,
            tap_dances=keymap.tap_dances,
            raw_layer_keycodes=raw_layer_keycodes,
            raw_keymap=raw_keymap,
            keycode_mappings=keycode_mappings,
        )

    if targets.overview:
        # When the user combined ``-l <indices>`` with ``-l overview``,
        # the overview should restrict itself to that same subset; an
        # empty ``selected_layers`` (no per-layer filter) means render
        # every configured layer the keymap defines.
        overview_filter = set(targets.selected_layers) if targets.selected_layers else None
        keymap_images["keymap-overview"] = draw_overview(
            config,
            keymap,
            raw_keymap=raw_keymap,
            keycode_mappings=keycode_mappings,
            selected_layers=overview_filter,
        )

    has_macros = bool(keymap.macros)
    has_tap_dances = bool(keymap.tap_dances)

    if targets.macros:
        if has_macros:
            keymap_images["keymap-macros"] = draw_macros_image(config, keymap)
        else:
            logger.warning("Skipping macros image: no macros are defined in the keymap.")

    if targets.tap_dances:
        if has_tap_dances:
            keymap_images["keymap-tap-dances"] = draw_tap_dances_image(config, keymap)
        else:
            logger.warning("Skipping tap-dances image: no tap-dances are defined in the keymap.")

    if targets.special_keys:
        if has_macros or has_tap_dances:
            keymap_images["keymap-special-keys"] = draw_special_keys_image(config, keymap)
        else:
            logger.warning(
                "Skipping special-keys image: no macros nor tap-dances are defined in the keymap."
            )

    if targets.symbols:
        if raw_keymap is None or keycode_mappings is None:
            logger.warning("Skipping symbols image: keycode mappings are not available.")
        else:
            symbol_entries = _image_symbol_entries(config, raw_keymap, keycode_mappings)
            if symbol_entries:
                keymap_images["keymap-symbols"] = draw_symbols_image(config, keymap, symbol_entries)
            else:
                logger.warning("Skipping symbols image: no resolvable symbols found in the keymap.")

    # If the only thing the user asked for were special-key images and the
    # keymap has neither macros nor tap-dances, surface a single overall
    # warning so the message lands even when individual per-image warnings
    # blur together.
    only_special_keys_requested = (
        not targets.selected_layers
        and not targets.all_layers
        and not targets.overview
        and (targets.macros or targets.tap_dances or targets.special_keys)
    )
    if only_special_keys_requested and not keymap_images:
        logger.warning(
            "No macros nor tap-dances are defined in the keymap; no images will be created."
        )

    return keymap_images

Exporter

exporter

Image exporter with support for Playwright and Cairo backends.

is_playwright_available

is_playwright_available() -> bool
Source code in src/skim/application/exporter/__init__.py
def is_playwright_available() -> bool:
    return _PLAYWRIGHT_AVAILABLE

is_cairo_available

is_cairo_available() -> bool
Source code in src/skim/application/exporter/__init__.py
def is_cairo_available() -> bool:
    return _CAIRO_AVAILABLE

get_available_export_formats

get_available_export_formats() -> list[str]
Source code in src/skim/application/exporter/__init__.py
def get_available_export_formats() -> list[str]:
    formats = ["svg"]
    if _PLAYWRIGHT_AVAILABLE or _CAIRO_AVAILABLE:
        formats.extend(["png", "jpeg", "webp", "avif"])
    return formats

get_available_render_engines

get_available_render_engines() -> list[RenderEngine]
Source code in src/skim/application/exporter/__init__.py
def get_available_render_engines() -> list[RenderEngine]:
    engines = []
    if _PLAYWRIGHT_AVAILABLE:
        engines.append(RenderEngine.CHROMIUM)
    if _CAIRO_AVAILABLE:
        engines.append(RenderEngine.CAIRO)
    return engines

save_drawings

save_drawings(
    outputs: OutputFiles,
    drawings: dict[str, Drawing],
    render_engine: RenderEngine | None = None,
)
Source code in src/skim/application/exporter/__init__.py
def save_drawings(
    outputs: OutputFiles,
    drawings: dict[str, draw.Drawing],
    render_engine: RenderEngine | None = None,
):
    if not outputs.force_overwrite:
        existing_files = []
        for file in drawings:
            file_path = outputs.output_dir / f"{file}.{outputs.output_format}"
            if file_path.exists():
                existing_files.append(file_path)
        if existing_files:
            file_names = ", ".join(f.name for f in existing_files)
            prompt = f"Files already exist: {file_names}. Do you want to overwrite?"
            if not sys.stdin.isatty():
                _confirm_via_tty(prompt)
            else:
                click.confirm(prompt, abort=True)

    # Determine if we need to convert text to paths
    # Cairo always needs text-to_paths because CairoSVG has poor font fallback and baseline support
    convert_text_to_paths = render_engine == RenderEngine.CAIRO or (
        render_engine is None and _CAIRO_AVAILABLE and not _PLAYWRIGHT_AVAILABLE
    )

    _save_keymap_images(
        drawings,
        outputs.output_dir,
        outputs.output_format,
        render_engine,
        convert_text_to_paths,
        outputs.use_system_fonts,
    )

Doctor

doctor

Doctor command logic to verify system environment and installation integrity.

CheckResult dataclass

CheckResult(
    name: str,
    passed: bool,
    message: str,
    details: str | None = None,
)

Result of a doctor check.

name instance-attribute

name: str

passed instance-attribute

passed: bool

message instance-attribute

message: str

details class-attribute instance-attribute

details: str | None = None

check_installation_integrity

check_installation_integrity() -> CheckResult

Verify that all bundled assets are present.

Source code in src/skim/application/doctor.py
def check_installation_integrity() -> CheckResult:
    """Verify that all bundled assets are present."""
    missing = []
    assets_to_check = [
        ("Keycode Mappings", "keycode_mappings"),
        ("Nerd Font Glyphs", "nerd_font_glyphs"),
        ("Roboto Regular", "font_roboto_regular"),
        ("Roboto Black", "font_roboto_black"),
        ("Roboto Thin", "font_roboto_thin"),
        ("Symbols Nerd Font", "font_symbols_nerd"),
        ("Svalboard Logo", "logo_svalboard"),
    ]

    for name, attr in assets_to_check:
        try:
            # Accessing the property triggers the check in BundleAssets
            getattr(ASSETS, attr)
        except FileNotFoundError:
            missing.append(name)
        except Exception as e:
            missing.append(f"{name} (Error: {e})")

    if missing:
        return CheckResult(
            name="Installation Integrity",
            passed=False,
            message="Missing bundled assets.",
            details=f"Missing: {', '.join(missing)}",
        )

    return CheckResult(
        name="Installation Integrity",
        passed=True,
        message="All bundled assets are present.",
    )

check_render_engines

check_render_engines() -> Generator[
    CheckResult, None, None
]

Check availability of render engines.

Source code in src/skim/application/doctor.py
def check_render_engines() -> Generator[CheckResult, None, None]:
    """Check availability of render engines."""
    # Playwright
    pw_available = check_playwright_available()
    yield CheckResult(
        name="Playwright (Chromium)",
        passed=pw_available,
        message="Available" if pw_available else "Not available",
        details="Required for PNG/JPEG/WEBP export using Chromium." if not pw_available else None,
    )

    # Cairo
    cairo_available = check_cairo_available()
    yield CheckResult(
        name="Cairo Graphics",
        passed=cairo_available,
        message="Available" if cairo_available else "Not available",
        details="Required for PNG/JPEG/WEBP export using Cairo (faster than Chromium)."
        if not cairo_available
        else None,
    )

check_system_fonts

check_system_fonts() -> Generator[CheckResult, None, None]

Check for presence of specific system fonts.

Source code in src/skim/application/doctor.py
def check_system_fonts() -> Generator[CheckResult, None, None]:
    """Check for presence of specific system fonts."""
    fonts_to_check = [
        "Roboto-Regular.ttf",
        "Roboto-Black.ttf",
        "Roboto-Thin.ttf",
        "SymbolsNerdFont-Regular.ttf",
    ]

    # Common font directories based on OS
    font_dirs = []
    if sys.platform == "darwin":
        font_dirs = [
            Path("/Library/Fonts"),
            Path("/System/Library/Fonts"),
            Path.home() / "Library/Fonts",
        ]
    elif sys.platform == "linux":
        font_dirs = [
            Path("/usr/share/fonts"),
            Path("/usr/local/share/fonts"),
            Path.home() / ".local/share/fonts",
            Path.home() / ".fonts",
        ]
    elif sys.platform == "win32":
        import os

        windir = os.environ.get("WINDIR", "C:\\Windows")
        font_dirs = [Path(windir) / "Fonts"]

    for font_filename in fonts_to_check:
        found = False
        for directory in font_dirs:
            # Simple recursive search could be slow, so we just check direct or use glob if needed.
            # Most users install fonts at the top level of these dirs or one level deep.
            # Let's do a quick rglob for the filename.
            if not directory.exists():
                continue

            try:
                # Use rglob to find the file in subdirectories
                if any(directory.rglob(font_filename)):
                    found = True
                    break
            except OSError:
                continue

        yield CheckResult(
            name=f"System Font: {font_filename}",
            passed=found,
            message="Found" if found else "Not found",
            details="System font usage requires this font to be installed." if not found else None,
        )

check_textual_available

check_textual_available() -> bool

Check if textual TUI library is available.

Source code in src/skim/application/doctor.py
def check_textual_available() -> bool:
    """Check if textual TUI library is available."""
    try:
        import textual  # noqa: F401  # pyright: ignore[reportUnusedImport]

        return True
    except ImportError:
        return False

run_doctor_checks

run_doctor_checks() -> Generator[CheckResult, None, None]

Run all doctor checks.

Source code in src/skim/application/doctor.py
def run_doctor_checks() -> Generator[CheckResult, None, None]:
    """Run all doctor checks."""
    yield check_installation_integrity()
    yield from check_render_engines()
    yield from check_system_fonts()

    # Optional TUI dependency
    textual_available = check_textual_available()
    yield CheckResult(
        name="Textual (TUI)",
        passed=textual_available,
        message="Available" if textual_available else "Not available",
        details="Required for interactive configuration editor (skim configure)."
        if not textual_available
        else None,
    )