Skip to content

UI Layer

Command-line interface and user interaction components.

CLI

cli

Command-line interface for the Svalboard Keymap Image Maker tool.

This module provides the CLI entry points for the skim tool using Click. It defines the main command group and subcommands for generating keymap images and configuration files.

Commands
  • skim generate: Generate keymap visualization images
  • skim configure: Generate or output configuration files
Example

Generate images from a keymap file::

$ skim generate --keymap layout.kbi --output-dir ./images

Generate with custom configuration::

$ skim -v INFO generate -k layout.vil -c config.yaml -f png

Create configuration from Keybard file::

$ skim configure -k layout.kbi -o skim-config.yaml

Read keymap from stdin::

$ qmk c2json -kb svalboard/trackball/pmw3389/right -km vial --no-cpp $QMK_ROOT/keyboards/svalboard/keymaps/vial/keymap.c | skim generate - -o ./out

AliasedGroup

Bases: Group

Click Group that supports command name abbreviation.

Allows users to invoke commands using unique prefixes instead of full command names. For example, skim gen matches generate if no other command starts with "gen".

Example

$ skim gen --keymap foo.kbi # Matches 'generate' $ skim conf -k bar.kbi # Matches 'configure'

get_command

get_command(ctx: Context, cmd_name: str) -> Command | None

Resolve a command by name or unique prefix.

Parameters:

Name Type Description Default
ctx Context

Click context.

required
cmd_name str

Command name or prefix to resolve.

required

Returns:

Type Description
Command | None

Matching Command object, or None if not found.

Raises:

Type Description
UsageError

If prefix matches multiple commands.

Source code in src/skim/cli.py
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
    """Resolve a command by name or unique prefix.

    Args:
        ctx: Click context.
        cmd_name: Command name or prefix to resolve.

    Returns:
        Matching Command object, or None if not found.

    Raises:
        click.UsageError: If prefix matches multiple commands.
    """
    rv = click.Group.get_command(self, ctx, cmd_name)
    if rv is not None:
        return rv
    matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)]
    if not matches:
        return None
    if len(matches) == 1:
        return click.Group.get_command(self, ctx, matches[0])
    ctx.fail(f"Too many matches: {', '.join(sorted(matches))}")

resolve_command

resolve_command(ctx: Context, args: list[str]) -> tuple

Resolve command and return full name for help text.

Source code in src/skim/cli.py
def resolve_command(self, ctx: click.Context, args: list[str]) -> tuple:
    """Resolve command and return full name for help text."""
    cmd_name, cmd, args = super().resolve_command(ctx, args)
    return cmd.name if cmd else cmd_name, cmd, args

main

main(verbosity: str, quiet: bool) -> None

Svalboard Keymap Image Maker (skim).

Generate visual keyboard layout images from keymap configuration files. Supports Keybard (.kbi), Vial (.vil), and QMK c2json formats.

Use --verbosity to control output detail level

DEBUG: Detailed debug information INFO: Progress updates and summaries WARNING: Only warnings and errors (default) ERROR: Only errors CRITICAL: Only critical errors NONE: Silence all output

Source code in src/skim/cli.py
@click.group(cls=AliasedGroup)
@click.version_option(version=__version__, prog_name=__prog_name__)
@click.option(
    "--verbosity",
    "-v",
    default="WARNING",
    type=click.Choice(
        ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "NONE"], case_sensitive=False
    ),
    help="Logging verbosity level.",
)
@click.option("--quiet", "-q", is_flag=True, help="Silence all output (overrides --verbosity).")
def main(verbosity: str, quiet: bool) -> None:
    """Svalboard Keymap Image Maker (skim).

    Generate visual keyboard layout images from keymap configuration files.
    Supports Keybard (.kbi), Vial (.vil), and QMK c2json formats.

    Use --verbosity to control output detail level:
        DEBUG: Detailed debug information
        INFO: Progress updates and summaries
        WARNING: Only warnings and errors (default)
        ERROR: Only errors
        CRITICAL: Only critical errors
        NONE: Silence all output
    """
    setup_logging(verbosity, quiet)

doctor

doctor() -> None

Check system environment and dependencies.

Source code in src/skim/cli.py
@main.command()
def doctor() -> None:
    """Check system environment and dependencies."""
    click.echo(f"Running doctor checks for {__prog_name__} v{__version__}...\n")

    all_passed = True
    for result in run_doctor_checks():
        if result.passed:
            status = click.style("PASS", fg="green", bold=True)
            click.echo(f"[{status}] {result.name}: {result.message}")
        else:
            status = click.style("FAIL", fg="red", bold=True)
            # Some failures might be warnings (like system fonts which are optional)
            if (
                "System Font" in result.name
                or "Cairo" in result.name
                or "Playwright" in result.name
                or "Textual" in result.name
            ):
                status = click.style("WARN", fg="yellow", bold=True)

            click.echo(f"[{status}] {result.name}: {result.message}")
            if result.details:
                click.echo(f"       Details: {result.details}")

            # System fonts, optional render engines, and optional TUI dep don't fail the whole check
            if (
                "System Font" not in result.name
                and "Cairo" not in result.name
                and "Playwright" not in result.name
                and "Textual" not in result.name
            ):
                all_passed = False

    click.echo("")
    if all_passed:
        click.secho("Everything looks good!", fg="green")
    else:
        click.secho("Some checks failed or warned. See details above.", fg="yellow")

generate

generate(
    config: Path | None,
    keymap: Path | None,
    output_dir: Path,
    output_format: str,
    layer: tuple,
    force: bool,
    use_system_fonts: bool,
    render_engine: str | None,
    no_special_keys: bool,
    no_symbols: bool,
    symbol_legend_flow: str | None,
    symbol_legend_columns: int | None,
    macros_scale: float | None,
    tap_dances_scale: float | None,
    symbols_scale: float | None,
    title: str | None,
    copyright_text: str | None,
    double_south: bool,
    width: float | None,
    adjust_lightness: float | None,
    adjust_saturation: float | None,
    stdin_marker: str | None,
) -> None

Generate keymap visualization images.

Parses a keymap file and generates a keymap image for each layer. Optionally generates an overview image showing all layers.

The supported output formats depend on the dependencies installed. For non-vector images (PNG, JPEG, WEBP, and AVIF), the Playwright Chromium Browser, or the Cairo library must be installed in the system.

STDIN_MARKER: Pass '-' to read keymap from stdin instead of file.

 Layer selection examples: -l overview Generate only the overview image -l macros Generate only the macros image -l tap-dances Generate only the tap-dances image -l special-keys Generate only the macros + tap-dances combined image -l symbols Generate only the symbols image -l 1 Generate only layer 1 -l 1-3 Generate layers 1, 2, and 3 -l 1 -l 3 -l 5 Generate layers 1, 3, and 5 -l all-layers Generate every individual layer -l all Generate every layer + overview + macros + tap-dances + symbols (skips the combined special-keys image; opt in explicitly with -l special-keys) (no -l) Generate every layer plus overview

Source code in src/skim/cli.py
@main.command()
@click.option(
    "--config",
    "-c",
    type=click.Path(exists=True, path_type=Path),
    help="Configuration file path.",
)
@click.option(
    "--keymap",
    "-k",
    type=click.Path(exists=True, path_type=Path),
    help="Keymap file path (.vil, .kbi, .json).",
)
@click.option(
    "--output-dir",
    "-o",
    type=click.Path(path_type=Path),
    default=Path.cwd(),
    help="Output directory for generated images.",
)
@click.option(
    "--format",
    "-f",
    "output_format",
    type=click.Choice(get_available_export_formats()),
    default="svg",
    help="Output format. Raster formats (png, jpeg, webp, avif) require a render engine.",
)
@click.option(
    "--layer",
    "-l",
    multiple=True,
    help=(
        "Layers/images to generate (all, all-layers, overview, macros, "
        "tap-dances, special-keys, symbols, N, N-M)."
    ),
)
@click.option(
    "--use-system-fonts",
    "-F",
    is_flag=True,
    help="Use system fonts instead of embedding fonts in SVG.",
)
@click.option(
    "--render-engine",
    "-e",
    type=click.Choice(["chromium", "cairo"]),
    default=None,
    help="Render engine for non-vector formats. 'chromium' uses Playwright, 'cairo' uses Cairo library. Only shown when both are available.",
)
@click.option(
    "--force",
    is_flag=True,
    help="Overwrite existing files without confirmation.",
)
@click.option(
    "-N",
    "--no-special-keys",
    "no_special_keys",
    is_flag=True,
    default=False,
    help="Omit the macro and tap-dance legend tables from the rendered SVGs.",
)
@click.option(
    "-Y",
    "--no-symbols",
    "no_symbols",
    is_flag=True,
    default=False,
    help="Omit the symbol legend from the rendered SVGs.",
)
@click.option(
    "--symbol-legend-flow",
    type=click.Choice(["row", "column"], case_sensitive=False),
    default=None,
    help=(
        "Flow direction for the symbol legend table. "
        "'row' fills rows first; 'column' fills columns first. "
        "Default: column."
    ),
)
@click.option(
    "--symbol-columns",
    "symbol_legend_columns",
    type=click.IntRange(min=1),
    default=None,
    help=(
        "Force the standalone symbols image to lay out at exactly N "
        "columns; the canvas shrinks to fit the resulting natural width. "
        "Without this flag the table picks the largest column count that "
        "fits the canvas budget."
    ),
)
@click.option(
    "--macros-scale",
    type=click.FloatRange(min=0.1),
    default=None,
    help=(
        "Body-scale multiplier for the standalone macros image (chips and "
        "pills scale by this factor; chrome stays at the unscaled per-image "
        "size). Default: 1.5."
    ),
)
@click.option(
    "--tap-dances-scale",
    type=click.FloatRange(min=0.1),
    default=None,
    help=("Body-scale multiplier for the standalone tap-dances image. Default: 1.5."),
)
@click.option(
    "--symbols-scale",
    type=click.FloatRange(min=0.1),
    default=None,
    help=("Body-scale multiplier for the standalone symbols image. Default: 1.5."),
)
@click.option(
    "--title",
    "-t",
    type=str,
    default=None,
    help="Override the overview keymap title (output.keymap_title).",
)
@click.option(
    "--copyright",
    "-r",
    "copyright_text",
    type=str,
    default=None,
    help="Override the copyright notice (output.copyright).",
)
@click.option(
    "--double-south",
    "-d",
    is_flag=True,
    default=False,
    help="Force the double-south keyboard feature on (keyboard.features.double_south).",
)
@click.option(
    "--width",
    "-w",
    type=float,
    default=None,
    help="Override the keymap canvas width in SVG units (output.layout.width).",
)
@click.option(
    "--adjust-lightness",
    "-L",
    type=float,
    default=None,
    help=(
        "Target lightness (0.0-1.0) applied to every layer base colour. "
        "Ignored when --config is provided."
    ),
)
@click.option(
    "--adjust-saturation",
    "-S",
    type=float,
    default=None,
    help=(
        "Cap saturation (0.0-1.0) on every layer base colour. Ignored when --config is provided."
    ),
)
@click.argument("stdin_marker", required=False, type=click.STRING)
def generate(
    config: Path | None,
    keymap: Path | None,
    output_dir: Path,
    output_format: str,
    layer: tuple,
    force: bool,
    use_system_fonts: bool,
    render_engine: str | None,
    no_special_keys: bool,
    no_symbols: bool,
    symbol_legend_flow: str | None,
    symbol_legend_columns: int | None,
    macros_scale: float | None,
    tap_dances_scale: float | None,
    symbols_scale: float | None,
    title: str | None,
    copyright_text: str | None,
    double_south: bool,
    width: float | None,
    adjust_lightness: float | None,
    adjust_saturation: float | None,
    stdin_marker: str | None,
) -> None:
    """Generate keymap visualization images.

    Parses a keymap file and generates a keymap image for each layer.
    Optionally generates an overview image showing all layers.

    The supported output formats depend on the dependencies installed. For
    non-vector images (PNG, JPEG, WEBP, and AVIF), the Playwright Chromium
    Browser, or the Cairo library must be installed in the system.

    STDIN_MARKER: Pass '-' to read keymap from stdin instead of file.

    \b
    Layer selection examples:
        -l overview       Generate only the overview image
        -l macros         Generate only the macros image
        -l tap-dances     Generate only the tap-dances image
        -l special-keys   Generate only the macros + tap-dances combined image
        -l symbols        Generate only the symbols image
        -l 1              Generate only layer 1
        -l 1-3            Generate layers 1, 2, and 3
        -l 1 -l 3 -l 5    Generate layers 1, 3, and 5
        -l all-layers     Generate every individual layer
        -l all            Generate every layer + overview + macros + tap-dances
                          + symbols
                          (skips the combined special-keys image; opt in
                          explicitly with -l special-keys)
        (no -l)           Generate every layer plus overview
    """

    try:
        inputs = InputFiles(config, keymap, stdin_marker == "-")
        engine = RenderEngine(render_engine) if render_engine else None
        outputs = OutputFiles(output_dir, output_format, force, use_system_fonts, engine)
        targets = KeymapGeneratorTargets.from_args(layer, partial(click.echo, err=True))
        if config is not None and (adjust_lightness is not None or adjust_saturation is not None):
            click.echo(
                "Note: --adjust-lightness/--adjust-saturation are ignored when --config is provided.",
                err=True,
            )
        generate_keymap(
            inputs,
            outputs,
            targets,
            show_special_keys_legend=not no_special_keys,
            show_symbol_legend=not no_symbols,
            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=title,
            copyright_text=copyright_text,
            double_south=double_south,
            width=width,
            adjust_lightness=adjust_lightness,
            adjust_saturation=adjust_saturation,
        )
    except click.Abort as e:
        click.echo(f"Aborted: {e}", err=True)
        sys.exit(1)
    except (ValueError, FileNotFoundError, json.JSONDecodeError, OSError) as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)

configure

configure(
    ctx: Context,
    interactive: bool,
    config: Path | None,
    keymap: Path | None,
    output: Path | None,
    force: bool,
    adjust_lightness: float | None,
    adjust_saturation: float | None,
    title: str | None,
    copyright: str | None,
    double_south: bool,
    width: float | None,
    layer_count: int | None,
) -> None

Generate or edit a configuration file.

With no flags, shows this help message.

Use -i/--interactive to launch the TUI configuration editor. Optionally pass -c/--config to load an existing config file into the editor.

Use -k to extract metadata (layer colors, names, custom keycodes) from a Keybard file.

Color adjustments (--adjust-lightness, --adjust-saturation) are applied to all extracted colors to ensure readable contrast in generated images.

Source code in src/skim/cli.py
@main.command()
@click.option(
    "--interactive",
    "-i",
    is_flag=True,
    help="Launch interactive configuration editor (TUI).",
)
@click.option(
    "--config",
    "-c",
    type=click.Path(exists=True, path_type=Path),
    help="Load an existing configuration file (interactive mode).",
)
@click.option(
    "--keymap",
    "-k",
    type=click.Path(exists=True, path_type=Path),
    help="Keymap file path (.kbi, .vil, .json).",
)
@click.option(
    "--output",
    "-o",
    type=click.Path(path_type=Path),
    help="Output configuration file path.",
)
@click.option("--force", is_flag=True, help="Overwrite existing file.")
@click.option(
    "--adjust-lightness",
    "-L",
    type=float,
    help="Adjust lightness (0.0-1.0) (non-interactive).",
)
@click.option(
    "--adjust-saturation",
    "-S",
    type=float,
    help="Adjust saturation (0.0-1.0) (non-interactive).",
)
@click.option("--title", "-t", type=str, help="Set the keymap title (output.keymap_title).")
@click.option("--copyright", "-r", type=str, help="Set the copyright notice (output.copyright).")
@click.option(
    "--double-south",
    "-d",
    is_flag=True,
    default=False,
    help="Enable the double-south keyboard feature (keyboard.features.double_south).",
)
@click.option(
    "--width",
    "-w",
    type=float,
    default=None,
    help="Set the keymap canvas width in SVG units (output.layout.width).",
)
@click.option(
    "--layer-count",
    "-n",
    type=int,
    help="Number of layers to pre-create with defaults (interactive mode).",
)
@click.pass_context
def configure(
    ctx: click.Context,
    interactive: bool,
    config: Path | None,
    keymap: Path | None,
    output: Path | None,
    force: bool,
    adjust_lightness: float | None,
    adjust_saturation: float | None,
    title: str | None,
    copyright: str | None,
    double_south: bool,
    width: float | None,
    layer_count: int | None,
) -> None:
    """Generate or edit a configuration file.

    With no flags, shows this help message.

    Use -i/--interactive to launch the TUI configuration editor.
    Optionally pass -c/--config to load an existing config file into the editor.

    Use -k to extract metadata (layer colors, names, custom keycodes) from a
    Keybard file.

    Color adjustments (--adjust-lightness, --adjust-saturation) are applied
    to all extracted colors to ensure readable contrast in generated images.
    """
    from skim.application.config_generator import ConfigGenerator

    # No flags at all: show help
    has_config_overrides = (
        title is not None or copyright is not None or double_south or width is not None
    )
    if not interactive and not keymap and not has_config_overrides:
        click.echo(ctx.get_help())
        return

    try:
        config_data: dict[str, Any] = {}

        if has_config_overrides and not interactive:
            import yaml

            config_data = _load_initial_config(config)
            if title is not None:
                config_data["output"]["keymap_title"] = title
            if copyright is not None:
                config_data["output"]["copyright"] = copyright
            if double_south:
                config_data.setdefault("keyboard", {}).setdefault("features", {})[
                    "double_south"
                ] = True
            if width is not None:
                config_data["output"].setdefault("layout", {})["width"] = width
            if layer_count is not None:
                _apply_layer_count(config_data, layer_count)
            content = yaml.dump(config_data, sort_keys=False, default_flow_style=False)
            if output:
                _write_config(output, content, force)
            else:
                click.echo(content)
            return

        generator = ConfigGenerator()

        # Generate config data from keymap if provided
        if keymap:
            import yaml as _yaml

            raw_content = keymap.read_text()

            from skim.application.loaders.keymap_loader import (
                _detect_format_from_path,
            )
            from skim.domain import KeymapType

            detected = _detect_format_from_path(keymap)

            if detected == KeymapType.KEYBARD:
                content = generator.generate_from_keybard(
                    raw_content, adjust_lightness, adjust_saturation
                )
            else:
                content = generator.generate_from_keymap(raw_content)

            config_data = _yaml.safe_load(content)

            # If -c is also provided, treat the keymap-derived config as a
            # scaffold and override it with the user's saved config so reloads
            # keep their edits (colors, titles, etc.).
            if config and config.is_file():
                loaded = _yaml.safe_load(config.read_text())
                if loaded:
                    config_data = _deep_merge(config_data, loaded)

            if not interactive:
                merged = _yaml.dump(config_data, sort_keys=False, default_flow_style=False)
                if output:
                    _write_config(output, merged, force)
                else:
                    click.echo(merged)
                return

        # Interactive mode
        if interactive:
            try:
                from skim.tui import launch_tui

                if not keymap:
                    config_data = _load_initial_config(config)
                if title is not None:
                    config_data["output"]["keymap_title"] = title
                if copyright is not None:
                    config_data["output"]["copyright"] = copyright
                if double_south:
                    config_data.setdefault("keyboard", {}).setdefault("features", {})[
                        "double_south"
                    ] = True
                if width is not None:
                    config_data["output"].setdefault("layout", {})["width"] = width
                if layer_count is not None:
                    _apply_layer_count(config_data, layer_count)
                launch_tui(
                    config_data=config_data,
                    output_path=output,
                    config_path=config,
                    force=force,
                )
                return
            except ImportError:
                click.echo(
                    "Error: The TUI requires the 'textual' package. Install it with:\n"
                    "    pip install qmk-skim[tui]",
                    err=True,
                )
                sys.exit(1)

    except (ValueError, OSError) as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)

Logging Configuration

logging_config

Logging configuration for Skim CLI output.

This module provides logging setup with colored output and emoji indicators for different log levels. The configuration is optimized for CLI usage with progress feedback and debug information.

Log level formatting
  • DEBUG: Green with bug emoji (🐞)
  • INFO: Normal with contextual emoji (from record.emoji)
  • WARNING: Yellow with warning emoji (⚠️)
  • ERROR/CRITICAL: Red with error emoji (🚨)
Example
>>> from skim.ui.logging_config import setup_logging
>>> setup_logging("INFO", quiet=False)
>>> import logging
>>> logger = logging.getLogger(__name__)
>>> logger.info("Processing...", extra={"emoji": "🔄"})
🔄 Processing...

ColoredFormatter

Bases: Formatter

Custom log formatter with ANSI colors and emoji support.

Applies color coding based on log level and prepends optional emoji indicators to log messages. The emoji can be specified per-record using the "emoji" extra field.

ANSI color codes are used for terminal output
  • DEBUG: Green
  • INFO: Default (reset)
  • WARNING: Yellow
  • ERROR+: Red

Attributes:

Name Type Description
GREY

ANSI code for gray text.

GREEN

ANSI code for green text.

YELLOW

ANSI code for yellow text.

RED

ANSI code for red text.

BOLD_RED

ANSI code for bold red text.

BLUE

ANSI code for blue text.

RESET

ANSI code to reset formatting.

Example

formatter = ColoredFormatter() handler.setFormatter(formatter)

GREY class-attribute instance-attribute

GREY = '\x1b[38;5;240m'

GREEN class-attribute instance-attribute

GREEN = '\x1b[32m'

YELLOW class-attribute instance-attribute

YELLOW = '\x1b[33m'

RED class-attribute instance-attribute

RED = '\x1b[31m'

BOLD_RED class-attribute instance-attribute

BOLD_RED = '\x1b[31;1m'

BLUE class-attribute instance-attribute

BLUE = '\x1b[34m'

RESET class-attribute instance-attribute

RESET = '\x1b[0m'

format

format(record: LogRecord) -> str

Format a log record with color and emoji.

Determines the appropriate color based on log level and prepends an emoji if specified in the record's extra data.

Parameters:

Name Type Description Default
record LogRecord

The log record to format.

required

Returns:

Type Description
str

Formatted string with ANSI color codes and optional emoji.

Source code in src/skim/application/logging_config.py
def format(self, record: logging.LogRecord) -> str:
    """Format a log record with color and emoji.

    Determines the appropriate color based on log level and
    prepends an emoji if specified in the record's extra data.

    Args:
        record: The log record to format.

    Returns:
        Formatted string with ANSI color codes and optional emoji.
    """
    if record.levelno == logging.DEBUG:
        color = self.GREEN
    elif record.levelno == logging.INFO:
        color = self.RESET
    elif record.levelno == logging.WARNING:
        color = self.YELLOW
    elif record.levelno >= logging.ERROR:
        color = self.RED
    else:
        color = self.RESET

    emoji = getattr(record, "emoji", None)
    if emoji is None:
        if record.levelno == logging.DEBUG:
            emoji = "🐞"
        elif record.levelno == logging.WARNING:
            emoji = "⚠️ "
        elif record.levelno >= logging.ERROR:
            emoji = "🚨"
        else:
            emoji = ""

    msg = super().format(record)
    if emoji:
        return f"{color}{emoji} {msg}{self.RESET}"
    return f"{color}{msg}{self.RESET}"

setup_logging

setup_logging(verbosity: str, quiet: bool) -> None

Configure the logging system for CLI output.

Sets up the root logger with colored output to stderr. The verbosity level can be specified as a string matching standard logging levels.

Parameters:

Name Type Description Default
verbosity str

Log level as string. Valid values: "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "NONE"

required
quiet bool

If True, suppresses all log output regardless of verbosity. Equivalent to setting verbosity to "NONE".

required
Example
>>> setup_logging("INFO", quiet=False)
>>> logging.info("This will be shown")

>>> setup_logging("WARNING", quiet=False)
>>> logging.info("This will NOT be shown")

>>> setup_logging("DEBUG", quiet=True)
>>> logging.debug("This will NOT be shown (quiet=True)")
Source code in src/skim/application/logging_config.py
def setup_logging(verbosity: str, quiet: bool) -> None:
    """Configure the logging system for CLI output.

    Sets up the root logger with colored output to stderr. The verbosity
    level can be specified as a string matching standard logging levels.

    Args:
        verbosity: Log level as string. Valid values:
            "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "NONE"
        quiet: If True, suppresses all log output regardless of verbosity.
            Equivalent to setting verbosity to "NONE".

    Example:
        ```pycon
        >>> setup_logging("INFO", quiet=False)
        >>> logging.info("This will be shown")

        >>> setup_logging("WARNING", quiet=False)
        >>> logging.info("This will NOT be shown")

        >>> setup_logging("DEBUG", quiet=True)
        >>> logging.debug("This will NOT be shown (quiet=True)")

        ```
    """
    if quiet or verbosity == "NONE":
        logging.getLogger().setLevel(logging.CRITICAL + 1)
        return

    level = getattr(logging, verbosity.upper(), logging.WARNING)

    root_logger = logging.getLogger()
    root_logger.setLevel(level)

    handler = logging.StreamHandler(sys.stderr)
    handler.setFormatter(ColoredFormatter())

    if root_logger.hasHandlers():
        root_logger.handlers.clear()

    root_logger.addHandler(handler)

TUI Configurator

app

Main TUI application for skim configuration editing.

LayerAdded

LayerAdded(index: int, source_tab: str)

Bases: Message

Posted when a layer is added in either tab.

Source code in src/skim/tui/app.py
def __init__(self, index: int, source_tab: str) -> None:
    super().__init__()
    self.index = index
    self.source_tab = source_tab

index instance-attribute

index = index

source_tab instance-attribute

source_tab = source_tab

LayerRemoved

LayerRemoved(index: int, source_tab: str)

Bases: Message

Posted when a layer is removed in either tab.

Source code in src/skim/tui/app.py
def __init__(self, index: int, source_tab: str) -> None:
    super().__init__()
    self.index = index
    self.source_tab = source_tab

index instance-attribute

index = index

source_tab instance-attribute

source_tab = source_tab

LayerUpdated

LayerUpdated(source_tab: str)

Bases: Message

Posted when a layer's metadata (name, label, etc.) is changed.

Source code in src/skim/tui/app.py
def __init__(self, source_tab: str) -> None:
    super().__init__()
    self.source_tab = source_tab

source_tab instance-attribute

source_tab = source_tab

QuitConfirmScreen

Bases: ModalScreen[str | None]

Modal dialog for save-on-quit with unsaved changes.

Returns "save" to save and quit, "discard" to quit without saving, or None if dismissed.

BINDINGS class-attribute instance-attribute

BINDINGS = [
    Binding(
        key="s",
        action="save_quit",
        description="Save & Quit",
        show=False,
    ),
    Binding(
        key="d",
        action="discard",
        description="Discard",
        show=False,
    ),
    Binding(
        key="escape",
        action="dismiss_dialog",
        description="Cancel",
        show=False,
    ),
]

compose

compose() -> ComposeResult
Source code in src/skim/tui/app.py
def compose(self) -> ComposeResult:
    with Vertical(id="quit-dialog"):
        yield Label(
            "You have unsaved changes.\nDo you want to save before quitting?",
            id="question",
        )
        with Horizontal(id="quit-buttons"):
            yield SkimButton("Save & Quit (s)", variant="success", id="save")
            yield SkimButton("Discard (d)", variant="error", id="discard")

on_button_pressed

on_button_pressed(event: Pressed) -> None
Source code in src/skim/tui/app.py
def on_button_pressed(self, event: Button.Pressed) -> None:
    self.dismiss(event.button.id)

action_save_quit

action_save_quit() -> None
Source code in src/skim/tui/app.py
def action_save_quit(self) -> None:
    self.dismiss("save")

action_discard

action_discard() -> None
Source code in src/skim/tui/app.py
def action_discard(self) -> None:
    self.dismiss("discard")

action_dismiss_dialog

action_dismiss_dialog() -> None
Source code in src/skim/tui/app.py
def action_dismiss_dialog(self) -> None:
    self.dismiss(None)

SaveTargetScreen

SaveTargetScreen(config_path: Path)

Bases: ModalScreen[str | None]

Modal dialog to choose where to save when -c was used without -o.

Returns "overwrite" to save back to the config file, "default" to save to skim-config.yaml in cwd, or None if dismissed.

Source code in src/skim/tui/app.py
def __init__(self, config_path: Path) -> None:
    super().__init__()
    self.config_path = config_path

BINDINGS class-attribute instance-attribute

BINDINGS = [
    Binding(
        key="escape",
        action="dismiss_dialog",
        description="Cancel",
        show=False,
    )
]

config_path instance-attribute

config_path = config_path

compose

compose() -> ComposeResult
Source code in src/skim/tui/app.py
def compose(self) -> ComposeResult:
    with Vertical(id="save-target-dialog"):
        yield Label(
            "Where do you want to save?",
            id="question",
        )
        with Vertical(id="save-target-buttons"):
            yield SkimButton(
                f"Overwrite {self.config_path.name} (o)",
                variant="warning",
                id="overwrite",
            )
            yield SkimButton(
                f"Create ./{_DEFAULT_CONFIG_NAME} (c)",
                variant="success",
                id="default",
            )

on_button_pressed

on_button_pressed(event: Pressed) -> None
Source code in src/skim/tui/app.py
def on_button_pressed(self, event: Button.Pressed) -> None:
    self.dismiss(event.button.id)

on_key

on_key(event) -> None
Source code in src/skim/tui/app.py
def on_key(self, event) -> None:
    if event.key == "o":
        self.dismiss("overwrite")
    elif event.key == "c":
        self.dismiss("default")

action_dismiss_dialog

action_dismiss_dialog() -> None
Source code in src/skim/tui/app.py
def action_dismiss_dialog(self) -> None:
    self.dismiss(None)

OverwriteConfirmScreen

OverwriteConfirmScreen(
    path: Path, display_name: str | None = None
)

Bases: ModalScreen[bool]

Modal dialog to confirm overwriting an existing file.

Returns True to overwrite, False to cancel.

Source code in src/skim/tui/app.py
def __init__(self, path: Path, display_name: str | None = None) -> None:
    super().__init__()
    self.path = path
    self._display_name = display_name or str(path)

BINDINGS class-attribute instance-attribute

BINDINGS = [
    Binding(
        key="escape",
        action="dismiss_dialog",
        description="Cancel",
        show=False,
    )
]

path instance-attribute

path = path

compose

compose() -> ComposeResult
Source code in src/skim/tui/app.py
def compose(self) -> ComposeResult:
    with Vertical(id="overwrite-dialog"):
        yield Label(
            f"File '{self._display_name}' already exists.\nOverwrite?",
            id="question",
        )
        with Horizontal(id="overwrite-buttons"):
            yield SkimButton("Overwrite (y)", variant="warning", id="confirm")
            yield SkimButton("Cancel (n)", variant="default", id="cancel")

on_button_pressed

on_button_pressed(event: Pressed) -> None
Source code in src/skim/tui/app.py
def on_button_pressed(self, event: Button.Pressed) -> None:
    self.dismiss(event.button.id == "confirm")

on_key

on_key(event) -> None
Source code in src/skim/tui/app.py
def on_key(self, event) -> None:
    if event.key == "y":
        self.dismiss(True)
    elif event.key == "n":
        self.dismiss(False)

action_dismiss_dialog

action_dismiss_dialog() -> None
Source code in src/skim/tui/app.py
def action_dismiss_dialog(self) -> None:
    self.dismiss(False)

ErrorDialog

ErrorDialog(message: str)

Bases: ModalScreen[None]

Modal dialog to show an error message.

Source code in src/skim/tui/app.py
def __init__(self, message: str) -> None:
    super().__init__()
    self.message = message

BINDINGS class-attribute instance-attribute

BINDINGS = [
    Binding(
        key="escape",
        action="dismiss_dialog",
        description="OK",
        show=False,
    )
]

message instance-attribute

message = message

compose

compose() -> ComposeResult
Source code in src/skim/tui/app.py
def compose(self) -> ComposeResult:
    with Vertical(id="error-dialog"):
        yield Label(self.message, id="error-message")
        with Horizontal(id="error-buttons"):
            yield SkimButton("OK", variant="primary", id="ok")

on_button_pressed

on_button_pressed(event: Pressed) -> None
Source code in src/skim/tui/app.py
def on_button_pressed(self, event: Button.Pressed) -> None:
    self.dismiss(None)

action_dismiss_dialog

action_dismiss_dialog() -> None
Source code in src/skim/tui/app.py
def action_dismiss_dialog(self) -> None:
    self.dismiss(None)

HelpScreen

HelpScreen(content: str)

Bases: ModalScreen[None]

Modal dialog to show contextual help as rendered markdown.

Source code in src/skim/tui/app.py
def __init__(self, content: str) -> None:
    super().__init__()
    self.content = content

BINDINGS class-attribute instance-attribute

BINDINGS = [
    Binding(
        key="escape",
        action="dismiss_help",
        description="Close",
        show=False,
    ),
    Binding(
        key="q",
        action="dismiss_help",
        description="Close",
        show=False,
    ),
]

content instance-attribute

content = content

compose

compose() -> ComposeResult
Source code in src/skim/tui/app.py
def compose(self) -> ComposeResult:
    with Vertical(id="help-dialog"):
        yield _HelpMarkdown(self.content)

on_mount

on_mount() -> None
Source code in src/skim/tui/app.py
def on_mount(self) -> None:
    md = self.query_one(_HelpMarkdown)
    md.can_focus = True
    md.focus()

on_key

on_key(event: Key) -> None
Source code in src/skim/tui/app.py
def on_key(self, event: events.Key) -> None:
    md = self.query_one(_HelpMarkdown)
    key = event.key
    if key == "j":
        md.scroll_down(animate=False)
    elif key == "k":
        md.scroll_up(animate=False)
    elif key in ("ctrl+d", "ctrl+f"):
        md.scroll_page_down(animate=False)
    elif key in ("ctrl+u", "ctrl+b"):
        md.scroll_page_up(animate=False)
    elif key == "G":
        md.scroll_end(animate=False)
    elif key == "g":
        md.scroll_home(animate=False)
    elif key == "ctrl+q":
        self.dismiss(None)
        self.app.call_later(self.app.action_request_quit)  # type: ignore[reportAttributeAccessIssue]
    else:
        return
    event.stop()

action_dismiss_help

action_dismiss_help() -> None
Source code in src/skim/tui/app.py
def action_dismiss_help(self) -> None:
    self.dismiss(None)

SkimConfigApp

SkimConfigApp(
    config_data: dict[str, Any],
    output_path: Path | None = None,
    config_path: Path | None = None,
    force: bool = False,
)

Bases: App

Interactive skim configuration editor.

Source code in src/skim/tui/app.py
def __init__(
    self,
    config_data: dict[str, Any],
    output_path: Path | None = None,
    config_path: Path | None = None,
    force: bool = False,
) -> None:
    super().__init__()
    self.config_data = config_data
    self.saved_data = copy.deepcopy(config_data)
    self._tab_focus: dict[str, str] = {}
    self.output_path = output_path
    self.config_path = config_path
    self.force = force
    self._last_nav_time: dict[str, float] = {}  # direction -> monotonic timestamp

ENABLE_COMMAND_PALETTE class-attribute instance-attribute

ENABLE_COMMAND_PALETTE = False

TITLE class-attribute instance-attribute

TITLE = 'skim configure'

CSS class-attribute instance-attribute

CSS = "\n    QuitConfirmScreen, SaveTargetScreen, OverwriteConfirmScreen, ErrorDialog, HelpScreen {\n        align: center middle;\n    }\n    #quit-dialog, #save-target-dialog, #error-dialog {\n        padding: 1 2;\n        width: 55;\n        height: auto;\n        border: thick $background 80%;\n        background: $surface;\n    }\n    #overwrite-dialog {\n        padding: 1 2;\n        width: 82;\n        height: auto;\n        border: thick $background 80%;\n        background: $surface;\n    }\n    #question {\n        text-align: center;\n        width: 100%;\n        height: auto;\n        margin-bottom: 1;\n    }\n    #error-message {\n        text-align: center;\n        width: 100%;\n        height: auto;\n        margin-bottom: 1;\n    }\n    #quit-buttons, #overwrite-buttons, #error-buttons {\n        width: 100%;\n        height: auto;\n        align-horizontal: center;\n    }\n    #quit-buttons Button, #overwrite-buttons Button, #error-buttons Button {\n        margin: 0 1;\n        padding: 0 3;\n    }\n    #help-dialog {\n        padding: 0;\n        width: 70;\n        height: auto;\n        max-height: 80%;\n        border: thick $background 80%;\n        background: $surface;\n    }\n    #help-dialog _HelpMarkdown {\n        padding: 1 3;\n        overflow-y: auto;\n        max-height: 100%;\n    }\n    #help-dialog MarkdownH1 {\n        background: transparent;\n        margin: 0 0 1 0;\n    }\n    #help-dialog MarkdownH2,\n    #help-dialog MarkdownH3 {\n        background: transparent;\n    }\n    #save-target-buttons {\n        width: 100%;\n        height: auto;\n        align-horizontal: center;\n    }\n    #save-target-buttons Button {\n        width: 100%;\n        margin: 0 0 1 0;\n    }\n    /* Global compact styling */\n    Input {\n        height: 3;\n        width: 1fr;\n        margin: 0;\n    }\n    Switch {\n        height: auto;\n        min-height: 1;\n    }\n    Select {\n        width: 1fr;\n        max-width: 30;\n    }\n    .field-row {\n        height: auto;\n        margin: 0;\n        padding: 0;\n    }\n    .field-label {\n        width: 22;\n        height: 3;\n        padding: 1 0 0 0;\n    }\n    .section-title {\n        text-style: bold;\n        color: $accent;\n        margin: 1 0 0 0;\n    }\n    .section-title-first {\n        margin: 0;\n    }\n    AutoComplete {\n        & AutoCompleteList {\n            border-left: wide $accent;\n        }\n    }\n    ListItem {\n        layout: horizontal;\n    }\n    ListItem > Static {\n        text-wrap: nowrap;\n        text-overflow: ellipsis;\n        width: 1fr;\n    }\n    ListItem.moving {\n        background: $accent 30%;\n    }\n    ListItem.moving > Static {\n        color: $accent;\n    }\n    ListItem > .lc-swatch {\n        width: 4;\n    }\n    ListItem > .move-indicator {\n        dock: right;\n        width: 3;\n        color: $accent;\n    }\n    .list-buttons {\n        height: auto;\n    }\n    .list-buttons Button {\n        min-width: 12;\n        margin: 0 1 0 0;\n    }\n    "

BINDINGS class-attribute instance-attribute

BINDINGS = [
    Binding(
        key="ctrl+q",
        action="request_quit",
        description="Quit",
        key_display="⌃Q",
    ),
    Binding(
        key="ctrl+s",
        action="save",
        description="Save",
        key_display="⌃S",
    ),
    Binding(
        key="ctrl+p",
        action="previous_tab",
        description="Previous Tab",
        key_display="⌃P",
        priority=True,
    ),
    Binding(
        key="ctrl+n",
        action="next_tab",
        description="Next Tab",
        key_display="⌃N",
        priority=True,
    ),
    Binding(
        key="up",
        action="focus_direction('up')",
        show=False,
        priority=True,
    ),
    Binding(
        key="down",
        action="focus_direction('down')",
        show=False,
        priority=True,
    ),
    Binding(
        key="left",
        action="focus_direction('left')",
        show=False,
        priority=True,
    ),
    Binding(
        key="right",
        action="focus_direction('right')",
        show=False,
        priority=True,
    ),
    Binding(
        key="ctrl+e",
        action="scroll_view('down')",
        description="Scroll down",
        key_display="⌃E",
        priority=True,
    ),
    Binding(
        key="ctrl+y",
        action="scroll_view('up')",
        description="Scroll up",
        key_display="⌃Y",
        priority=True,
    ),
    Binding(
        key="f1",
        action="show_help",
        description="Help",
        key_display="F1,⌥H",
        priority=True,
    ),
    Binding(
        key="alt+h",
        action="show_help",
        show=False,
        priority=True,
    ),
]

config_data instance-attribute

config_data = config_data

saved_data instance-attribute

saved_data = deepcopy(config_data)

output_path instance-attribute

output_path = output_path

config_path instance-attribute

config_path = config_path

force instance-attribute

force = force

has_unsaved_changes property

has_unsaved_changes: bool

compose

compose() -> ComposeResult
Source code in src/skim/tui/app.py
def compose(self) -> ComposeResult:
    from skim.tui.keyboard_tab import KeyboardTab

    with TabbedContent(initial="keyboard-tab"):
        with TabPane("Keyboard", id="keyboard-tab"):
            yield KeyboardTab(config_data=self.config_data)
        with TabPane("Keycodes", id="keycodes-tab"):
            from skim.tui.keycodes_tab import KeycodesTab

            yield KeycodesTab(config_data=self.config_data)
        with TabPane("Output", id="output-tab"):
            from skim.tui.output_tab import OutputTab

            yield OutputTab(config_data=self.config_data)
    yield SkimFooter()

action_request_quit

action_request_quit() -> None
Source code in src/skim/tui/app.py
def action_request_quit(self) -> None:
    if self.has_unsaved_changes:
        self.push_screen(QuitConfirmScreen(), self._handle_quit_confirm)
    else:
        self.exit()

action_show_help

action_show_help() -> None

Show contextual help for the currently focused widget.

Source code in src/skim/tui/app.py
def action_show_help(self) -> None:
    """Show contextual help for the currently focused widget."""
    if isinstance(self.screen, HelpScreen):
        return
    widget = self.focused
    help_key = None
    while widget is not None:
        if hasattr(widget, "help_key") and widget.help_key:  # type: ignore[reportAttributeAccessIssue]
            help_key = widget.help_key  # type: ignore[reportAttributeAccessIssue]
            break
        widget = widget.parent
    content = ASSETS.help_text(help_key or "general")
    self.push_screen(HelpScreen(content))

action_previous_tab

action_previous_tab() -> None
Source code in src/skim/tui/app.py
def action_previous_tab(self) -> None:
    self._save_current_tab_focus()
    self.query_one(Tabs).action_previous_tab()
    self.call_after_refresh(self._restore_tab_focus)

action_next_tab

action_next_tab() -> None
Source code in src/skim/tui/app.py
def action_next_tab(self) -> None:
    self._save_current_tab_focus()
    self.query_one(Tabs).action_next_tab()
    self.call_after_refresh(self._restore_tab_focus)

action_save

action_save(*, exit_after: bool = False) -> None
Source code in src/skim/tui/app.py
def action_save(self, *, exit_after: bool = False) -> None:
    try:
        SkimConfig.model_validate(self.config_data)
    except Exception as e:
        self.notify(f"Validation error: {e}", severity="error")
        return

    if self.output_path is not None:
        # -o was provided: save directly to that path
        path = self.output_path
        if path.is_dir():
            path = path / _DEFAULT_CONFIG_NAME
        self._save_to_path(path, exit_after=exit_after)
    elif self.config_path is not None:
        # -c was provided without -o: ask where to save
        self.push_screen(
            SaveTargetScreen(self.config_path),
            lambda result: self._handle_save_target(result, exit_after=exit_after),
        )
    else:
        # No -o or -c: save to default in cwd
        path = Path.cwd() / _DEFAULT_CONFIG_NAME
        self._save_to_path(path, prettify_name=True, exit_after=exit_after)

on_layer_added

on_layer_added(event: LayerAdded) -> None
Source code in src/skim/tui/app.py
def on_layer_added(self, event: LayerAdded) -> None:
    from skim.tui.keyboard_tab import KeyboardTab
    from skim.tui.output_tab import OutputTab

    if event.source_tab == "keyboard":
        self.query_one(OutputTab).sync_layer_added(event.index)
    elif event.source_tab == "style":
        self.query_one(KeyboardTab).sync_layer_added(event.index)

on_layer_updated

on_layer_updated(event: LayerUpdated) -> None
Source code in src/skim/tui/app.py
def on_layer_updated(self, event: LayerUpdated) -> None:
    from skim.tui.keyboard_tab import KeyboardTab
    from skim.tui.output_tab import OutputTab

    # Only rebuild the *other* tab's list. The source tab already holds
    # the post-commit state — rebuilding it would clear list_view.index
    # and snap the cursor back to the top.
    if event.source_tab != "keyboard":
        self.query_one(KeyboardTab)._rebuild_layer_list()
    if event.source_tab != "style":
        self.query_one(OutputTab)._rebuild_layer_colors_list()

on_layer_removed

on_layer_removed(event: LayerRemoved) -> None
Source code in src/skim/tui/app.py
def on_layer_removed(self, event: LayerRemoved) -> None:
    from skim.tui.keyboard_tab import KeyboardTab
    from skim.tui.output_tab import OutputTab

    if event.source_tab == "keyboard":
        self.query_one(OutputTab).sync_layer_removed(event.index)
    elif event.source_tab == "style":
        self.query_one(KeyboardTab).sync_layer_removed(event.index)

action_scroll_view

action_scroll_view(direction: str) -> None

Scroll the VerticalScroll in the active tab (skip ListViews).

Source code in src/skim/tui/app.py
def action_scroll_view(self, direction: str) -> None:
    """Scroll the VerticalScroll in the active tab (skip ListViews)."""
    if isinstance(self.screen, HelpScreen):
        scroll = self.screen.query_one(_HelpMarkdown)
        if direction == "up":
            scroll.scroll_up(animate=False)
        else:
            scroll.scroll_down(animate=False)
        return
    if isinstance(self.screen, ModalScreen):
        return

    from skim.tui.widgets import SkimVerticalScroll

    focused = self.focused
    if focused is None:
        return

    # Walk up from the focused widget, skipping ListViews.
    scroll = self._scroll_ancestor(focused)
    while scroll is not None and isinstance(scroll, ListView):
        scroll = self._scroll_ancestor(scroll)

    # When focused on the tab bar (or no scroll ancestor found),
    # look for the SkimVerticalScroll inside the active pane.
    if scroll is None:
        tabbed = self.query_one(TabbedContent)
        pane = tabbed.active_pane
        if pane is not None:
            results = pane.query(SkimVerticalScroll)
            if results:
                scroll = results.first()

    if scroll is None:
        return
    if direction == "down":
        scroll.scroll_down(animate=False)
    else:
        scroll.scroll_up(animate=False)

action_focus_direction

action_focus_direction(direction: str) -> None

Move focus to the nearest focusable widget in the given direction.

Source code in src/skim/tui/app.py
def action_focus_direction(self, direction: str) -> None:
    """Move focus to the nearest focusable widget in the given direction."""
    focused = self.focused
    if focused is None:
        return

    # HelpScreen: scroll content instead of navigating.
    if isinstance(self.screen, HelpScreen):
        if direction in ("up", "down"):
            scroll = self.screen.query_one(_HelpMarkdown)
            if direction == "up":
                scroll.scroll_up(animate=False)
            else:
                scroll.scroll_down(animate=False)
        return

    # Modal screens: navigate among the modal's own widgets only.
    if isinstance(self.screen, ModalScreen):
        current = focused.region
        if not current.width or not current.height:
            return
        target = self._best_in_direction(
            current,
            direction,
            self.screen.query("*"),
            focused,
        )
        if target is not None:
            target.focus()
        return

    # OptionList: don't navigate away from Select/AutoComplete overlays
    if isinstance(focused, OptionList):
        raise SkipAction()

    # ListView: allow escape at edges unless it's a hold-down repeat.
    if isinstance(focused, ListView):
        if direction in ("left", "right"):
            raise SkipAction()
        at_edge = False
        if direction == "up" and focused.index == 0:
            at_edge = True
        elif direction == "down" and focused.index is not None:
            at_edge = focused.index >= len(focused._nodes) - 1
        if not at_edge:
            self._record_nav(direction)
            raise SkipAction()  # Normal cursor movement within list
        # At the edge — block if this is a hold-down repeat.
        if self._is_hold_repeat(direction):
            self._record_nav(direction)
            raise SkipAction()
        self._record_nav(direction)
        # Fall through to spatial navigation (escape the list).

    # Input: left/right must move the cursor, not navigate
    elif isinstance(focused, Input) and direction in ("left", "right"):
        raise SkipAction()

    # Input: when an autocomplete dropdown is visible, let it handle
    # up/down to navigate the completion list.
    elif isinstance(focused, Input) and direction in ("up", "down"):
        if self._has_visible_autocomplete(focused):
            raise SkipAction()

    # Tab bar: left/right must switch tabs, not navigate
    elif isinstance(focused, Tabs) and direction in ("left", "right"):
        raise SkipAction()

    current = focused.region
    if not current.width or not current.height:
        return

    # If inside an editing ListDetailPane, trap arrows within the
    # detail pane — only Tab/Shift-Tab can leave.
    from skim.tui.list_detail_pane import ListDetailPane

    node = focused.parent
    while node is not None:
        if isinstance(node, ListDetailPane) and node._editing:
            detail = node.query_one(f"#{node.pane_id}-detail")
            target = self._best_in_direction(
                current,
                direction,
                detail.query("*"),
                focused,
            )
            if target is not None:
                target.focus()
            return  # Never escape the edit pane via arrows
        node = node.parent

    tabbed = self.query_one(TabbedContent)
    pane = tabbed.active_pane
    if pane is None:
        return

    # If inside a scrollable container, try to stay within it first.
    scroll = self._scroll_ancestor(focused)
    if scroll is not None and direction in ("up", "down"):
        inner = self._best_in_direction(
            current,
            direction,
            scroll.query("*"),
            focused,
        )
        if inner is not None:
            self._record_nav(direction)
            inner.focus()
            self._maybe_select_edge(inner, direction)
            return
        # No widget inside the scroll container in that direction.
        # Only leave if the container is fully scrolled to the edge
        # AND this is not the first rapid press (fly-out prevention).
        at_scroll_edge = (direction == "down" and scroll.scroll_y >= scroll.max_scroll_y) or (
            direction == "up" and scroll.scroll_y <= 0
        )
        if not at_scroll_edge or self._is_hold_repeat(direction):
            self._record_nav(direction)
            return
        self._record_nav(direction)

    # Search the full pane + the tab bar.
    tabs_widget = tabbed.query_one(Tabs)
    all_candidates = list(pane.query("*"))
    all_candidates.append(tabs_widget)

    target = self._best_in_direction(current, direction, all_candidates, focused)
    if target is not None:
        target.focus()
        self._maybe_select_edge(target, direction)

on_descendant_focus

on_descendant_focus(event: DescendantFocus) -> None
Source code in src/skim/tui/app.py
def on_descendant_focus(self, event: events.DescendantFocus) -> None:
    widget = event.widget
    if (
        isinstance(widget, (ListView, SkimListView))
        and widget.index is None
        and len(widget.children) > 0
    ):
        widget.index = 0

TUI Widgets

widgets

Shared reusable TUI widgets for skim configuration editor.

SkimFooter

Bases: Footer

Footer with controlled binding order and paired key display.

compose

compose() -> ComposeResult
Source code in src/skim/tui/widgets.py
def compose(self) -> ComposeResult:
    if not self._bindings_ready:
        return

    active = self.screen.active_bindings

    # Collect one representative binding per action.
    by_action: dict[str, tuple[Binding, bool, str]] = {}
    for _key, (_node, binding, enabled, tooltip) in active.items():
        if not binding.show:
            continue
        if binding.action not in by_action:
            by_action[binding.action] = (binding, enabled, tooltip)

    default_order = max(_ACTION_ORDER.values()) + 1
    sorted_actions = sorted(
        by_action,
        key=lambda a: _ACTION_ORDER.get(a, default_order),
    )

    for action in sorted_actions:
        if action in _PAIR_SECONDS:
            continue

        binding, enabled, tooltip = by_action[action]

        if action in _PAIRS:
            second_action, description = _PAIRS[action]
            if second_action in by_action:
                second_binding = by_action[second_action][0]
                key_display = (
                    self.app.get_key_display(binding)
                    + "/"
                    + self.app.get_key_display(second_binding)
                )
                yield self._yield_key(
                    binding.key,
                    key_display,
                    description,
                    binding.action,
                    enabled=enabled,
                )
                continue

        yield self._yield_key(
            binding.key,
            self.app.get_key_display(binding),
            binding.description,
            binding.action,
            enabled=enabled,
            tooltip=tooltip,
        )

    # Inject tab-bar bindings when a Tabs widget is focused.
    focused = self.screen.focused
    if isinstance(focused, Tabs):
        yield self._yield_key("down", "\u2193", "Next field", "")
        yield self._yield_key(
            "left",
            "\u2190/\u2192",
            "Prev/Next tab",
            "",
        )

SkimStandaloneInput

SkimStandaloneInput(
    *args: Any, help_key: str | None = None, **kwargs: Any
)

Bases: Input

Input for standalone fields outside edit panes.

Source code in src/skim/tui/widgets.py
def __init__(self, *args: Any, help_key: str | None = None, **kwargs: Any) -> None:
    super().__init__(*args, **kwargs)
    self.help_key = help_key

BINDINGS class-attribute instance-attribute

BINDINGS = [
    Binding(
        "tab",
        "focus_next",
        "Next field",
        key_display="↓,⇥",
        show=True,
    ),
    Binding(
        "shift+tab",
        "focus_previous",
        "Previous field",
        key_display="↑,⇤",
        show=True,
    ),
]

help_key instance-attribute

help_key = help_key

ColorInput

ColorInput(
    *args: Any, help_key: str | None = None, **kwargs: Any
)

Bases: SkimStandaloneInput

SkimStandaloneInput with shortcuts to nudge the color's HSL channels.

alt+up / alt+down increase / decrease saturation; alt+right / alt+left increase / decrease lightness. Each press applies a 0.05 delta clamped into [0, 1]. Non-hex values (empty input, named CSS colors, malformed strings) are silently ignored — the binding is a no-op.

The footer renders two grouped cells using SkimFooter._PAIRS: one for the saturation pair and one for the lightness pair, with the standard yellow-key / white-description styling.

Source code in src/skim/tui/widgets.py
def __init__(self, *args: Any, help_key: str | None = None, **kwargs: Any) -> None:
    super().__init__(*args, **kwargs)
    self.help_key = help_key

BINDINGS class-attribute instance-attribute

BINDINGS = list(_COLOR_NUDGE_BINDINGS)

action_nudge_saturation_up

action_nudge_saturation_up() -> None
Source code in src/skim/tui/widgets.py
def action_nudge_saturation_up(self) -> None:
    _nudge_color(self, saturation_delta=_HSL_STEP)

action_nudge_saturation_down

action_nudge_saturation_down() -> None
Source code in src/skim/tui/widgets.py
def action_nudge_saturation_down(self) -> None:
    _nudge_color(self, saturation_delta=-_HSL_STEP)

action_nudge_lightness_up

action_nudge_lightness_up() -> None
Source code in src/skim/tui/widgets.py
def action_nudge_lightness_up(self) -> None:
    _nudge_color(self, lightness_delta=_HSL_STEP)

action_nudge_lightness_down

action_nudge_lightness_down() -> None
Source code in src/skim/tui/widgets.py
def action_nudge_lightness_down(self) -> None:
    _nudge_color(self, lightness_delta=-_HSL_STEP)

SkimInput

SkimInput(
    *args: Any, help_key: str | None = None, **kwargs: Any
)

Bases: Input

Input with footer bindings for edit-pane field navigation.

Source code in src/skim/tui/widgets.py
def __init__(self, *args: Any, help_key: str | None = None, **kwargs: Any) -> None:
    super().__init__(*args, **kwargs)
    self.help_key = help_key

BINDINGS class-attribute instance-attribute

BINDINGS = [
    Binding(
        "tab",
        "focus_next",
        "Next field",
        key_display="↓,⇥",
        show=True,
    ),
    Binding(
        "shift+tab",
        "focus_previous",
        "Previous field",
        key_display="↑,⇤",
        show=True,
    ),
    Binding(
        "enter",
        "submit",
        "Confirm changes",
        key_display="⏎",
        show=True,
    ),
    Binding(
        "escape",
        "cancel_edit",
        "Discard changes",
        key_display="\U000f12b7",
        show=True,
    ),
]

help_key instance-attribute

help_key = help_key

action_cancel_edit

action_cancel_edit() -> None

No-op — handled by ListDetailPane.on_key via event bubbling.

Source code in src/skim/tui/widgets.py
def action_cancel_edit(self) -> None:
    """No-op — handled by ListDetailPane.on_key via event bubbling."""

LayerColorInput

LayerColorInput(
    *args: Any, help_key: str | None = None, **kwargs: Any
)

Bases: SkimInput

SkimInput variant with HSL-nudge shortcuts (Layer Colors edit pane).

Source code in src/skim/tui/widgets.py
def __init__(self, *args: Any, help_key: str | None = None, **kwargs: Any) -> None:
    super().__init__(*args, **kwargs)
    self.help_key = help_key

BINDINGS class-attribute instance-attribute

BINDINGS = list(_COLOR_NUDGE_BINDINGS)

action_nudge_saturation_up

action_nudge_saturation_up() -> None
Source code in src/skim/tui/widgets.py
def action_nudge_saturation_up(self) -> None:
    _nudge_color(self, saturation_delta=_HSL_STEP)

action_nudge_saturation_down

action_nudge_saturation_down() -> None
Source code in src/skim/tui/widgets.py
def action_nudge_saturation_down(self) -> None:
    _nudge_color(self, saturation_delta=-_HSL_STEP)

action_nudge_lightness_up

action_nudge_lightness_up() -> None
Source code in src/skim/tui/widgets.py
def action_nudge_lightness_up(self) -> None:
    _nudge_color(self, lightness_delta=_HSL_STEP)

action_nudge_lightness_down

action_nudge_lightness_down() -> None
Source code in src/skim/tui/widgets.py
def action_nudge_lightness_down(self) -> None:
    _nudge_color(self, lightness_delta=-_HSL_STEP)

SkimListView

SkimListView(
    *args: Any, help_key: str | None = None, **kwargs: Any
)

Bases: ListView

ListView with footer bindings for navigation and edit.

Source code in src/skim/tui/widgets.py
def __init__(self, *args: Any, help_key: str | None = None, **kwargs: Any) -> None:
    super().__init__(*args, **kwargs)
    self.help_key = help_key

BINDINGS class-attribute instance-attribute

BINDINGS = [
    Binding("up", "cursor_up", "Prev item", show=True),
    Binding("down", "cursor_down", "Next item", show=True),
    Binding(
        "enter",
        "select_cursor",
        "Edit",
        key_display="⏎",
        show=True,
    ),
    Binding("m", "move_mode", "Move", show=True),
    Binding("up", "move_up", "Move up", show=True),
    Binding("down", "move_down", "Move down", show=True),
    Binding(
        "enter",
        "confirm_move",
        "Confirm position",
        key_display="⏎",
        show=True,
    ),
    Binding(
        "escape",
        "cancel_move",
        "Discard changes",
        key_display="\U000f12b7",
        show=True,
    ),
]

help_key instance-attribute

help_key = help_key

check_action

check_action(
    action: str, parameters: tuple[object, ...]
) -> bool | None
Source code in src/skim/tui/widgets.py
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
    pane = self._parent_pane()
    moving = pane is not None and pane._moving
    move_supported = pane is not None and pane.move_enabled
    normal_actions = {"cursor_up", "cursor_down", "select_cursor"}
    move_actions = {"move_up", "move_down", "confirm_move", "cancel_move"}
    if action == "move_mode":
        return move_supported and not moving
    if action in normal_actions:
        return not moving
    if action in move_actions:
        return moving
    return True

action_move_mode

action_move_mode() -> None

No-op — handled by LayerListPane.on_key via event bubbling.

Source code in src/skim/tui/widgets.py
def action_move_mode(self) -> None:
    """No-op — handled by LayerListPane.on_key via event bubbling."""

action_move_up

action_move_up() -> None

No-op — handled by LayerListPane.on_key via event bubbling.

Source code in src/skim/tui/widgets.py
def action_move_up(self) -> None:
    """No-op — handled by LayerListPane.on_key via event bubbling."""

action_move_down

action_move_down() -> None

No-op — handled by LayerListPane.on_key via event bubbling.

Source code in src/skim/tui/widgets.py
def action_move_down(self) -> None:
    """No-op — handled by LayerListPane.on_key via event bubbling."""

action_confirm_move

action_confirm_move() -> None

No-op — handled by LayerListPane.on_key via event bubbling.

Source code in src/skim/tui/widgets.py
def action_confirm_move(self) -> None:
    """No-op — handled by LayerListPane.on_key via event bubbling."""

action_cancel_move

action_cancel_move() -> None

No-op — handled by LayerListPane.on_key via event bubbling.

Source code in src/skim/tui/widgets.py
def action_cancel_move(self) -> None:
    """No-op — handled by LayerListPane.on_key via event bubbling."""

SkimButton

SkimButton(
    *args: Any, help_key: str | None = None, **kwargs: Any
)

Bases: Button

Button that responds to both Enter and Space.

Source code in src/skim/tui/widgets.py
def __init__(self, *args: Any, help_key: str | None = None, **kwargs: Any) -> None:
    super().__init__(*args, **kwargs)
    self.help_key = help_key

BINDINGS class-attribute instance-attribute

BINDINGS = [
    Binding(
        "enter",
        "press",
        "Activate",
        key_display="⏎,␣",
        show=True,
    ),
    Binding("space", "press", "Activate", show=False),
]

help_key instance-attribute

help_key = help_key

SkimSwitch

SkimSwitch(
    *args: Any, help_key: str | None = None, **kwargs: Any
)

Bases: Switch

Switch with footer binding for toggle action.

Source code in src/skim/tui/widgets.py
def __init__(self, *args: Any, help_key: str | None = None, **kwargs: Any) -> None:
    super().__init__(*args, **kwargs)
    self.help_key = help_key

BINDINGS class-attribute instance-attribute

BINDINGS = [
    Binding(
        "enter",
        "toggle_switch",
        "Toggle",
        key_display="⏎,␣",
        show=True,
    ),
    Binding("space", "toggle_switch", "Toggle", show=False),
]

help_key instance-attribute

help_key = help_key

SkimSelect

SkimSelect(
    *args: Any, help_key: str | None = None, **kwargs: Any
)

Bases: Select

Select with footer binding for menu action.

Source code in src/skim/tui/widgets.py
def __init__(self, *args: Any, help_key: str | None = None, **kwargs: Any) -> None:
    super().__init__(*args, **kwargs)
    self.help_key = help_key

BINDINGS class-attribute instance-attribute

BINDINGS = [
    Binding(
        "enter",
        "show_overlay",
        "Show options",
        key_display="⏎,␣",
        show=True,
    ),
    Binding(
        "space", "show_overlay", "Show options", show=False
    ),
    Binding(
        "tab",
        "focus_next",
        "Next field",
        key_display="↓,⇥",
        show=True,
    ),
    Binding(
        "shift+tab",
        "focus_previous",
        "Previous field",
        key_display="↑,⇤",
        show=True,
    ),
    Binding(
        "escape",
        "cancel_edit",
        "Discard changes",
        key_display="\U000f12b7",
        show=True,
    ),
    Binding("up", "skip_arrow", show=False),
    Binding("down", "skip_arrow", show=False),
]

help_key instance-attribute

help_key = help_key

action_cancel_edit

action_cancel_edit() -> None

No-op — handled by ListDetailPane.on_key via event bubbling.

Source code in src/skim/tui/widgets.py
def action_cancel_edit(self) -> None:
    """No-op — handled by ListDetailPane.on_key via event bubbling."""

action_skip_arrow

action_skip_arrow() -> None

Yield arrow keys to app-level spatial navigation.

Source code in src/skim/tui/widgets.py
def action_skip_arrow(self) -> None:
    """Yield arrow keys to app-level spatial navigation."""
    raise SkipAction()

compose

compose() -> ComposeResult
Source code in src/skim/tui/widgets.py
def compose(self) -> ComposeResult:
    yield SelectCurrent(self.prompt)
    yield _SkimSelectOverlay(type_to_search=self._type_to_search).data_bind(
        compact=Select.compact
    )

SkimVerticalScroll

Bases: VerticalScroll

VerticalScroll that yields arrow keys for spatial focus navigation.

Arrow keys raise SkipAction so the app-level directional focus handler receives them. Page Up/Down and Home/End still scroll normally.

action_scroll_up

action_scroll_up() -> None
Source code in src/skim/tui/widgets.py
def action_scroll_up(self) -> None:
    raise SkipAction()

action_scroll_down

action_scroll_down() -> None
Source code in src/skim/tui/widgets.py
def action_scroll_down(self) -> None:
    raise SkipAction()

action_scroll_left

action_scroll_left() -> None
Source code in src/skim/tui/widgets.py
def action_scroll_left(self) -> None:
    raise SkipAction()

action_scroll_right

action_scroll_right() -> None
Source code in src/skim/tui/widgets.py
def action_scroll_right(self) -> None:
    raise SkipAction()

List Detail Pane

list_detail_pane

Reusable list/detail pane base class for the skim TUI.

ListDetailPane

ListDetailPane(
    pane_id: str,
    list_help_key: str | None = None,
    **kwargs: Any,
)

Bases: Widget

Base class for a list/detail split pane.

Provides: horizontal layout with a list column (35%) and a detail pane, edit mode lifecycle (enter/exit/commit/cancel), snapshot/rollback, focus-out commit, and key handling (Enter/Escape/a/d).

Subclasses implement the abstract methods to supply data and fields.

Source code in src/skim/tui/list_detail_pane.py
def __init__(self, pane_id: str, list_help_key: str | None = None, **kwargs: Any) -> None:
    super().__init__(**kwargs)
    self.pane_id = pane_id
    self.list_help_key = list_help_key
    self._selected: int = 0
    self._editing: bool = False
    self._snapshot: dict | None = None
    self._adding: bool = False
    self._moving: bool = False
    self._move_snapshots: list[tuple[list[dict], list[dict]]] | None = None

DEFAULT_CSS class-attribute instance-attribute

DEFAULT_CSS = "\n    ListDetailPane {\n        height: auto;\n    }\n    ListDetailPane .ldp-container {\n        height: auto;\n    }\n    ListDetailPane .ldp-list-col {\n        width: 35%;\n        min-width: 25;\n        height: 100%;\n    }\n    ListDetailPane .ldp-list {\n        height: 1fr;\n        border: solid $accent 50%;\n    }\n    ListDetailPane .ldp-buttons {\n        height: auto;\n        width: 100%;\n    }\n    ListDetailPane .ldp-buttons Button {\n        width: 50%;\n    }\n    ListDetailPane .ldp-detail {\n        padding: 0 1;\n        height: auto;\n        overflow-x: hidden;\n        border: solid $accent 30%;\n    }\n    ListDetailPane .ldp-detail:focus-within {\n        border: solid $accent;\n    }\n    "

pane_id instance-attribute

pane_id = pane_id

list_help_key instance-attribute

list_help_key = list_help_key

move_enabled property

move_enabled: bool

Whether this pane supports move mode. Override to enable.

EntryAdded

EntryAdded(index: int)

Bases: Message

Posted after an entry is added.

Source code in src/skim/tui/list_detail_pane.py
def __init__(self, index: int) -> None:
    super().__init__()
    self.index = index
index instance-attribute
index = index

EntryRemoved

EntryRemoved(index: int)

Bases: Message

Posted after an entry is removed.

Source code in src/skim/tui/list_detail_pane.py
def __init__(self, index: int) -> None:
    super().__init__()
    self.index = index
index instance-attribute
index = index

EntryUpdated

Bases: Message

Posted after a successful commit.

compose

compose() -> ComposeResult
Source code in src/skim/tui/list_detail_pane.py
def compose(self) -> ComposeResult:
    with Horizontal(classes="ldp-container"):
        with Vertical(classes="ldp-list-col"):
            yield SkimListView(
                id=f"{self.pane_id}-list",
                classes="ldp-list",
                help_key=self.list_help_key,
            )
            with Horizontal(classes="ldp-buttons"):
                yield SkimButton("+ Add (a)", id=f"{self.pane_id}-add", variant="success")
                yield SkimButton("- Delete (d)", id=f"{self.pane_id}-remove", variant="error")
        with Vertical(id=f"{self.pane_id}-detail", classes="ldp-detail"):
            yield from self.compose_detail_fields()

get_entries abstractmethod

get_entries() -> list[dict]

Return the mutable list of data entries.

Source code in src/skim/tui/list_detail_pane.py
@abstractmethod
def get_entries(self) -> list[dict]:
    """Return the mutable list of data entries."""

format_entry abstractmethod

format_entry(index: int, entry: dict) -> str

Format entry text for the list display.

Source code in src/skim/tui/list_detail_pane.py
@abstractmethod
def format_entry(self, index: int, entry: dict) -> str:
    """Format entry text for the list display."""

compose_detail_fields abstractmethod

compose_detail_fields() -> ComposeResult

Yield the detail field widgets.

Source code in src/skim/tui/list_detail_pane.py
@abstractmethod
def compose_detail_fields(self) -> ComposeResult:
    """Yield the detail field widgets."""

detail_field_ids abstractmethod

detail_field_ids() -> set[str]

Return the set of Input IDs in the detail pane.

Source code in src/skim/tui/list_detail_pane.py
@abstractmethod
def detail_field_ids(self) -> set[str]:
    """Return the set of Input IDs in the detail pane."""

refresh_fields abstractmethod

refresh_fields(entry: dict) -> None

Populate fields from entry data.

Source code in src/skim/tui/list_detail_pane.py
@abstractmethod
def refresh_fields(self, entry: dict) -> None:
    """Populate fields from entry data."""

clear_fields abstractmethod

clear_fields() -> None

Clear all fields.

Source code in src/skim/tui/list_detail_pane.py
@abstractmethod
def clear_fields(self) -> None:
    """Clear all fields."""

create_entry abstractmethod

create_entry(index: int) -> dict

Create a new default entry for the given insertion index.

Source code in src/skim/tui/list_detail_pane.py
@abstractmethod
def create_entry(self, index: int) -> dict:
    """Create a new default entry for the given insertion index."""

validate_and_apply

validate_and_apply(entry: dict) -> bool

Validate the edit, apply field values to the entry dict.

Return True if valid, False to revert and show error. Default implementation is a no-op that returns True.

Source code in src/skim/tui/list_detail_pane.py
def validate_and_apply(self, entry: dict) -> bool:
    """Validate the edit, apply field values to the entry dict.

    Return True if valid, False to revert and show error.
    Default implementation is a no-op that returns True.
    """
    return True

rebuild_list

rebuild_list() -> None

Rebuild the entire list from get_entries().

Source code in src/skim/tui/list_detail_pane.py
def rebuild_list(self) -> None:
    """Rebuild the entire list from get_entries()."""
    entries = self.get_entries()
    list_view = self.query_one(f"#{self.pane_id}-list", SkimListView)
    list_view.clear()
    for i, entry in enumerate(entries):
        list_view.append(self._make_list_item(i, entry))

update_all_list_items

update_all_list_items() -> None

Update the text of all list items in place.

Source code in src/skim/tui/list_detail_pane.py
def update_all_list_items(self) -> None:
    """Update the text of all list items in place."""
    entries = self.get_entries()
    list_view = self.query_one(f"#{self.pane_id}-list", SkimListView)
    for i, child in enumerate(list_view.children):
        if i < len(entries) and isinstance(child, ListItem):
            self._update_list_item_content(child, i, entries[i])

update_selected_list_item

update_selected_list_item() -> None

Update only the selected list item.

Source code in src/skim/tui/list_detail_pane.py
def update_selected_list_item(self) -> None:
    """Update only the selected list item."""
    entries = self.get_entries()
    if self._selected >= len(entries):
        return
    list_view = self.query_one(f"#{self.pane_id}-list", SkimListView)
    if self._selected < len(list_view.children):
        child = list_view.children[self._selected]
        if isinstance(child, ListItem):
            self._update_list_item_content(child, self._selected, entries[self._selected])

move_paired_lists

move_paired_lists() -> list[list[dict]]

Return additional lists that must be swapped in sync with entries.

Subclasses override this to return lists (e.g. palette layers, keyboard layers) that are paired 1:1 with get_entries().

Source code in src/skim/tui/list_detail_pane.py
def move_paired_lists(self) -> list[list[dict]]:
    """Return additional lists that must be swapped in sync with entries.

    Subclasses override this to return lists (e.g. palette layers,
    keyboard layers) that are paired 1:1 with ``get_entries()``.
    """
    return []

on_move_swap

on_move_swap(
    entries: list[dict],
    pos: int,
    target: int,
    direction: int,
) -> None

Hook called before swapping entries at pos and target.

Subclasses override this to adjust entry data (e.g. QMK indices) before the positional swap happens.

Source code in src/skim/tui/list_detail_pane.py
def on_move_swap(
    self,
    entries: list[dict],
    pos: int,
    target: int,
    direction: int,
) -> None:
    """Hook called before swapping entries at *pos* and *target*.

    Subclasses override this to adjust entry data (e.g. QMK indices)
    before the positional swap happens.
    """

on_button_pressed

on_button_pressed(event: Pressed) -> None
Source code in src/skim/tui/list_detail_pane.py
def on_button_pressed(self, event: Button.Pressed) -> None:
    if event.button.id == f"{self.pane_id}-add":
        self._add_entry()
    elif event.button.id == f"{self.pane_id}-remove":
        self._remove_entry()

on_list_view_selected

on_list_view_selected(event: Selected) -> None
Source code in src/skim/tui/list_detail_pane.py
def on_list_view_selected(self, event: SkimListView.Selected) -> None:
    if event.list_view.id != f"{self.pane_id}-list":
        return
    index = event.list_view.index
    if index is not None:
        self._selected = index
        self.refresh_fields(self.get_entries()[index])
        self._enter_edit_mode()

on_list_view_highlighted

on_list_view_highlighted(event: Highlighted) -> None
Source code in src/skim/tui/list_detail_pane.py
def on_list_view_highlighted(self, event: SkimListView.Highlighted) -> None:
    if event.list_view.id != f"{self.pane_id}-list":
        return
    index = event.list_view.index
    if index is not None:
        self._selected = index
        entries = self.get_entries()
        if index < len(entries):
            self.refresh_fields(entries[index])

on_key

on_key(event) -> None

Handle move mode, Enter/Escape in edit mode, a/d/m shortcuts on list.

Source code in src/skim/tui/list_detail_pane.py
def on_key(self, event) -> None:
    """Handle move mode, Enter/Escape in edit mode, a/d/m shortcuts on list."""
    if self._moving:
        if event.key == "up":
            self._move_entry(-1)
        elif event.key == "down":
            self._move_entry(1)
        elif event.key == "enter":
            self._exit_move_mode(commit=True)
        elif event.key == "escape":
            self._exit_move_mode(commit=False)
        event.prevent_default()
        event.stop()
        return

    if self._editing:
        if event.key == "enter":
            event.prevent_default()
            event.stop()
            self._exit_edit_mode(commit=True)
        elif event.key == "escape":
            event.prevent_default()
            event.stop()
            self._exit_edit_mode(commit=False)
        return

    focused = self.app.focused
    if isinstance(focused, SkimListView) and focused.id == f"{self.pane_id}-list":
        if event.key == "a":
            event.prevent_default()
            event.stop()
            self.query_one(f"#{self.pane_id}-add", Button).press()
        elif event.key == "d":
            event.prevent_default()
            event.stop()
            self.query_one(f"#{self.pane_id}-remove", Button).press()
        elif event.key == "m" and self.move_enabled:
            event.prevent_default()
            event.stop()
            self._enter_move_mode()

on_descendant_blur

on_descendant_blur(event: DescendantBlur) -> None

Commit edit when focus leaves the editing pane.

Source code in src/skim/tui/list_detail_pane.py
def on_descendant_blur(self, event: DescendantBlur) -> None:
    """Commit edit when focus leaves the editing pane."""
    if not self._editing:
        return
    self.set_timer(0.05, self._check_focus_commit)

Keyboard Tab

keyboard_tab

Keyboard tab widget for the skim TUI configuration editor.

LayerListPane

LayerListPane(config_data: dict[str, Any], **kwargs: Any)

Bases: ListDetailPane

List/detail pane for keyboard layers.

Source code in src/skim/tui/keyboard_tab.py
def __init__(self, config_data: dict[str, Any], **kwargs: Any) -> None:
    super().__init__(pane_id="layer", list_help_key="keyboard-layer-list", **kwargs)
    self.config_data = config_data

DEFAULT_CSS class-attribute instance-attribute

DEFAULT_CSS = "\n    LayerListPane {\n        height: auto;\n    }\n    "

config_data instance-attribute

config_data = config_data

move_enabled property

move_enabled: bool

get_entries

get_entries() -> list[dict]
Source code in src/skim/tui/keyboard_tab.py
def get_entries(self) -> list[dict]:
    return self.config_data.get("keyboard", {}).get("layers", [])

format_entry

format_entry(index: int, entry: dict) -> str
Source code in src/skim/tui/keyboard_tab.py
def format_entry(self, index: int, entry: dict) -> str:
    layers = self.get_entries()
    col0_w = max((len(self._col0_text(i, layer)) for i, layer in enumerate(layers)), default=0)
    col0 = self._col0_text(index, entry)
    col2 = self._col2_text(entry)
    return f"{col0:<{col0_w}}  {col2}"

compose_detail_fields

compose_detail_fields() -> ComposeResult
Source code in src/skim/tui/keyboard_tab.py
def compose_detail_fields(self) -> ComposeResult:
    with Horizontal(classes="field-row"):
        yield Label("Index:", classes="field-label")
        yield SkimInput(
            value="",
            id="layer-index",
            placeholder="e.g. 0",
            disabled=True,
            help_key="keyboard-layer-index",
        )
    with Horizontal(classes="field-row"):
        yield Label("ID:", classes="field-label")
        yield SkimInput(
            value="",
            id="layer-id",
            placeholder="e.g. _BASE (optional)",
            disabled=True,
            help_key="keyboard-layer-id",
        )
    with Horizontal(classes="field-row"):
        yield Label("Name:", classes="field-label")
        yield SkimInput(
            value="",
            id="layer-name",
            placeholder="e.g. Letters",
            disabled=True,
            help_key="keyboard-layer-name",
        )
    with Horizontal(classes="field-row"):
        yield Label("Variant:", classes="field-label")
        yield SkimInput(
            value="",
            id="layer-variant",
            placeholder="e.g. COLEMAK (optional)",
            disabled=True,
            help_key="keyboard-layer-variant",
        )

detail_field_ids

detail_field_ids() -> set[str]
Source code in src/skim/tui/keyboard_tab.py
def detail_field_ids(self) -> set[str]:
    return set(_FIELD_MAP.keys())

refresh_fields

refresh_fields(entry: dict) -> None
Source code in src/skim/tui/keyboard_tab.py
def refresh_fields(self, entry: dict) -> None:
    self.query_one("#layer-index", Input).value = str(entry.get("index", self._selected))
    self.query_one("#layer-name", Input).value = entry.get("name", "") or ""
    self.query_one("#layer-id", Input).value = entry.get("id", "") or ""
    self.query_one("#layer-variant", Input).value = entry.get("variant", "") or ""

clear_fields

clear_fields() -> None
Source code in src/skim/tui/keyboard_tab.py
def clear_fields(self) -> None:
    self.query_one("#layer-index", Input).value = ""
    self.query_one("#layer-name", Input).value = ""
    self.query_one("#layer-id", Input).value = ""
    self.query_one("#layer-variant", Input).value = ""

create_entry

create_entry(index: int) -> dict
Source code in src/skim/tui/keyboard_tab.py
def create_entry(self, index: int) -> dict:
    layers = self.get_entries()
    used_indices = {
        layer.get("index", i) for i, layer in enumerate(layers) if layer is not layers[-1]
    }
    # When called from _add_entry, the new entry is already appended,
    # so exclude it from used_indices. But we also need to handle the
    # case where it hasn't been appended yet.
    next_index = 0
    while next_index in used_indices:
        next_index += 1
    return {
        "index": next_index,
        "name": f"Layer {next_index}",
        "id": None,
        "variant": None,
    }

validate_and_apply

validate_and_apply(entry: dict) -> bool

Validate index (0-31, no duplicates), apply fields, re-sort.

Source code in src/skim/tui/keyboard_tab.py
def validate_and_apply(self, entry: dict) -> bool:
    """Validate index (0-31, no duplicates), apply fields, re-sort."""
    index_str = self.query_one("#layer-index", Input).value.strip()
    try:
        new_index = int(index_str)
    except (ValueError, TypeError):
        self._revert_and_show_error("Index must be a valid integer.")
        return False
    if new_index < 0 or new_index > 31:
        self._revert_and_show_error("Index must be between 0 and 31.")
        return False
    layers = self.get_entries()
    for i, other in enumerate(layers):
        if i != self._selected and other.get("index", i) == new_index:
            self._revert_and_show_error(f"Index {new_index} is already used by another layer.")
            return False
    entry["index"] = new_index
    # Sort layers and palette.layers by index
    palette_layers = (
        self.config_data.get("output", {}).get("style", {}).get("palette", {}).get("layers", [])
    )
    if palette_layers and len(palette_layers) == len(layers):
        paired = list(zip(layers, palette_layers, strict=False))
        paired.sort(key=lambda p: p[0].get("index", 0))
        layers[:] = [p[0] for p in paired]
        palette_layers[:] = [p[1] for p in paired]
    else:
        layers.sort(key=lambda layer: layer.get("index", 0))
    self._selected = layers.index(entry)
    return True

on_input_changed

on_input_changed(event: Changed) -> None
Source code in src/skim/tui/keyboard_tab.py
def on_input_changed(self, event: Input.Changed) -> None:
    input_id = event.input.id
    if input_id not in _FIELD_MAP:
        return
    config_key = _FIELD_MAP[input_id]
    if config_key == "index":
        return  # validated on commit
    layers = self.get_entries()
    if self._selected >= len(layers):
        return
    value: str | None = event.value
    if config_key in ("id", "variant") and value == "":
        value = None
    layers[self._selected][config_key] = value
    self.update_selected_list_item()

move_paired_lists

move_paired_lists() -> list[list[dict]]
Source code in src/skim/tui/keyboard_tab.py
def move_paired_lists(self) -> list[list[dict]]:
    palette = self._palette_layers()
    return [palette] if palette else []

on_move_swap

on_move_swap(
    entries: list[dict],
    pos: int,
    target: int,
    direction: int,
) -> None
Source code in src/skim/tui/keyboard_tab.py
def on_move_swap(
    self,
    entries: list[dict],
    pos: int,
    target: int,
    direction: int,
) -> None:
    moved = entries[pos]
    neighbor = entries[target]
    old_moved_index = moved["index"]
    moved["index"] = neighbor["index"]
    neighbor["index"] = self._next_adjacent_index(
        neighbor["index"],
        direction,
        old_moved_index,
    )

KeyboardTab

KeyboardTab(config_data: dict[str, Any], **kwargs: Any)

Bases: Widget

Keyboard configuration tab.

Shows an Information section, a Features section, and a Layers section with a LayerListPane for editing individual layer metadata.

Source code in src/skim/tui/keyboard_tab.py
def __init__(self, config_data: dict[str, Any], **kwargs: Any) -> None:
    super().__init__(**kwargs)
    self.config_data = config_data

DEFAULT_CSS class-attribute instance-attribute

DEFAULT_CSS = "\n    KeyboardTab {\n        height: 1fr;\n        padding: 0 1;\n    }\n    KeyboardTab #info-section {\n        height: auto;\n    }\n    KeyboardTab #features-section {\n        height: auto;\n    }\n    KeyboardTab #features-row {\n        height: auto;\n    }\n    KeyboardTab #layers-section {\n        height: auto;\n    }\n    "

config_data instance-attribute

config_data = config_data

compose

compose() -> ComposeResult
Source code in src/skim/tui/keyboard_tab.py
def compose(self) -> ComposeResult:
    features = self.config_data.get("keyboard", {}).get("features", {})
    double_south = features.get("double_south", False)
    keymap_title = self.config_data.get("output", {}).get("keymap_title") or ""
    copyright_text = self.config_data.get("output", {}).get("copyright") or ""

    with SkimVerticalScroll(can_focus=False):
        with Vertical(id="info-section"):
            yield Static(
                "Information",
                id="keyboard-info-section",
                classes="section-title section-title-first",
            )
            with Horizontal(classes="field-row"):
                yield Label("Keymap Title:", classes="field-label")
                yield SkimStandaloneInput(
                    value=keymap_title,
                    id="keymap-title-text",
                    placeholder="e.g. My Keymap (leave empty for auto)",
                    help_key="keyboard-info-title",
                )
            with Horizontal(classes="field-row"):
                yield Label("Copyright:", classes="field-label")
                yield SkimStandaloneInput(
                    value=copyright_text,
                    id="copyright-text",
                    placeholder="e.g. (c) 2024 Your Name (leave empty for none)",
                    help_key="keyboard-info-copyright",
                )

        with Vertical(id="features-section"):
            yield Static(
                "Features",
                id="keyboard-feature-section",
                classes="section-title",
            )
            with Horizontal(id="features-row"):
                yield Label("Double South: ", classes="field-label")
                yield SkimSwitch(
                    value=double_south,
                    id="double-south",
                    help_key="keyboard-feature-double-south",
                )

        yield Static(
            "Layers",
            id="keyboard-layer-section",
            classes="section-title",
        )
        yield LayerListPane(config_data=self.config_data)

on_mount

on_mount() -> None
Source code in src/skim/tui/keyboard_tab.py
def on_mount(self) -> None:
    pane = self.query_one(LayerListPane)
    pane.rebuild_list()
    entries = pane.get_entries()
    if entries:
        pane._selected = 0
        pane.refresh_fields(entries[0])
    pane._update_list_state()

on_switch_changed

on_switch_changed(event: Changed) -> None
Source code in src/skim/tui/keyboard_tab.py
def on_switch_changed(self, event: SkimSwitch.Changed) -> None:
    if event.switch.id == "double-south":
        self.config_data.setdefault("keyboard", {}).setdefault("features", {})
        self.config_data["keyboard"]["features"]["double_south"] = event.value

on_input_changed

on_input_changed(event: Changed) -> None
Source code in src/skim/tui/keyboard_tab.py
def on_input_changed(self, event: Input.Changed) -> None:
    input_id = event.input.id
    if input_id == "keymap-title-text":
        self.config_data["output"]["keymap_title"] = event.value if event.value else None
    elif input_id == "copyright-text":
        self.config_data["output"]["copyright"] = event.value if event.value else None

on_list_detail_pane_entry_added

on_list_detail_pane_entry_added(event: EntryAdded) -> None
Source code in src/skim/tui/keyboard_tab.py
def on_list_detail_pane_entry_added(self, event: ListDetailPane.EntryAdded) -> None:
    self.post_message(LayerAdded(event.index, "keyboard"))

on_list_detail_pane_entry_removed

on_list_detail_pane_entry_removed(
    event: EntryRemoved,
) -> None
Source code in src/skim/tui/keyboard_tab.py
def on_list_detail_pane_entry_removed(self, event: ListDetailPane.EntryRemoved) -> None:
    self.post_message(LayerRemoved(event.index, "keyboard"))

on_list_detail_pane_entry_updated

on_list_detail_pane_entry_updated(
    event: EntryUpdated,
) -> None
Source code in src/skim/tui/keyboard_tab.py
def on_list_detail_pane_entry_updated(self, event: ListDetailPane.EntryUpdated) -> None:
    self.post_message(LayerUpdated(source_tab="keyboard"))

sync_layer_added

sync_layer_added(index: int) -> None

Called when a layer color is added in the Style tab.

Source code in src/skim/tui/keyboard_tab.py
def sync_layer_added(self, index: int) -> None:
    """Called when a layer color is added in the Style tab."""
    pane = self.query_one(LayerListPane)
    layers = pane.get_entries()
    used_indices = {layer.get("index", i) for i, layer in enumerate(layers)}
    next_index = 0
    while next_index in used_indices:
        next_index += 1
    new_layer = {
        "index": next_index,
        "name": f"Layer {next_index}",
        "id": None,
        "variant": None,
    }
    layers.insert(index, new_layer)
    pane.rebuild_list()
    pane._selected = index
    pane.refresh_fields(new_layer)
    pane._update_list_state()

sync_layer_removed

sync_layer_removed(index: int) -> None

Called when a layer color is removed in the Style tab.

Source code in src/skim/tui/keyboard_tab.py
def sync_layer_removed(self, index: int) -> None:
    """Called when a layer color is removed in the Style tab."""
    pane = self.query_one(LayerListPane)
    layers = pane.get_entries()
    if index >= len(layers):
        return
    layers.pop(index)
    pane.rebuild_list()
    if layers:
        pane._selected = min(pane._selected, len(layers) - 1)
        pane.refresh_fields(layers[pane._selected])
    else:
        pane._selected = 0
        pane.clear_fields()
    pane._update_list_state()

Keycodes Tab

keycodes_tab

Keycodes tab widget for the skim TUI configuration editor.

KeycodeAutoComplete

KeycodeAutoComplete(*args: Any, **kwargs: Any)

Bases: AutoComplete

Token-aware autocomplete for keycode/macro fields.

Splits input on separator characters so autocomplete works inside macro arguments (e.g. LSFT(KC_SPC)). Macro completions like LSFT() are inserted as LSFT( so the user can continue filling in the argument.

Source code in src/skim/tui/keycodes_tab.py
def __init__(self, *args: Any, **kwargs: Any) -> None:
    super().__init__(*args, **kwargs)
    self._just_completed: bool = False

get_search_string

get_search_string(target_state: TargetState) -> str
Source code in src/skim/tui/keycodes_tab.py
def get_search_string(self, target_state: TargetState) -> str:
    start = self._token_start(target_state.text, target_state.cursor_position)
    return target_state.text[start : target_state.cursor_position]

apply_completion

apply_completion(value: str, state: TargetState) -> None
Source code in src/skim/tui/keycodes_tab.py
def apply_completion(self, value: str, state: TargetState) -> None:
    start = self._token_start(state.text, state.cursor_position)
    # For macros ending with (), insert only the opening paren
    if value.endswith("()"):
        value = value[:-1]
    before = state.text[:start]
    after = state.text[state.cursor_position :]
    self.target.value = f"{before}{value}{after}"
    self.target.cursor_position = start + len(value)

should_show_dropdown

should_show_dropdown(search_string: str) -> bool
Source code in src/skim/tui/keycodes_tab.py
def should_show_dropdown(self, search_string: str) -> bool:
    option_list = self.option_list
    if option_list.option_count == 0:
        return False
    if not search_string:
        # Show all candidates right after a separator like ( or ,
        state = self._get_target_state()
        before = state.text[: state.cursor_position]
        return bool(before) and before[-1] in _KEYCODE_SHOW_AFTER
    if option_list.option_count <= 1:
        return False
    return super().should_show_dropdown(search_string)

post_completion

post_completion() -> None
Source code in src/skim/tui/keycodes_tab.py
def post_completion(self) -> None:
    self._just_completed = True
    super().post_completion()
    self.target.post_message(Input.Changed(self.target, self.target.value))

OverrideTargetAutoComplete

OverrideTargetAutoComplete(
    target: Input,
    config_data: dict[str, Any],
    **kwargs: Any,
)

Bases: AutoComplete

Context-aware autocomplete for the Override Target field.

Detects @@ (keycode reference) and %% (NerdFont glyph) prefixes at the cursor position and provides appropriate candidates. On completion, inserts the value with a trailing ;.

Source code in src/skim/tui/keycodes_tab.py
def __init__(self, target: Input, config_data: dict[str, Any], **kwargs: Any) -> None:
    super().__init__(target, candidates=None, **kwargs)
    self._config_data = config_data
    self._just_completed: bool = False

get_search_string

get_search_string(target_state: TargetState) -> str
Source code in src/skim/tui/keycodes_tab.py
def get_search_string(self, target_state: TargetState) -> str:
    result = _find_active_prefix(target_state.text, target_state.cursor_position)
    if result is None:
        return ""
    marker, pos = result
    return target_state.text[pos + len(marker) : target_state.cursor_position]

get_candidates

get_candidates(
    target_state: TargetState,
) -> list[DropdownItem]
Source code in src/skim/tui/keycodes_tab.py
def get_candidates(self, target_state: TargetState) -> list[DropdownItem]:
    result = _find_active_prefix(target_state.text, target_state.cursor_position)
    if result is None:
        return []
    marker, pos = result
    if marker == "@@":
        return [DropdownItem(main=name) for name in _all_keycode_names(self._config_data)]
    # %% — use prefix lookup on the sorted names list
    search = target_state.text[pos + len(marker) : target_state.cursor_position]
    if not search:
        return _NERDFONT_ITEMS[:_NERDFONT_MAX_RESULTS]
    return _nerdfont_prefix_items(search)

apply_completion

apply_completion(value: str, state: TargetState) -> None
Source code in src/skim/tui/keycodes_tab.py
def apply_completion(self, value: str, state: TargetState) -> None:
    result = _find_active_prefix(state.text, state.cursor_position)
    if result is None:
        return
    marker, pos = result
    # For NerdFont items, extract just the name (before the glyph preview)
    if marker == "%%":
        value = value.split()[0] if value.strip() else value
    before = state.text[:pos]
    after = state.text[state.cursor_position :]
    completed = f"{before}{marker}{value};{after}"
    self.target.value = completed
    self.target.cursor_position = pos + len(marker) + len(value) + 1  # after ';'

should_show_dropdown

should_show_dropdown(search_string: str) -> bool
Source code in src/skim/tui/keycodes_tab.py
def should_show_dropdown(self, search_string: str) -> bool:
    # Show dropdown as soon as @@ or %% is typed (even with empty search)
    state = self._get_target_state()
    if not search_string and _find_active_prefix(state.text, state.cursor_position) is None:
        return False
    option_list = self.option_list
    if option_list.option_count == 0:
        return False
    if option_list.option_count == 1:
        first_option = option_list.get_option_at_index(0).prompt
        plain = first_option.plain if hasattr(first_option, "plain") else str(first_option)  # type: ignore[union-attr]
        if plain.split()[0] == search_string:
            return False
    return True

post_completion

post_completion() -> None
Source code in src/skim/tui/keycodes_tab.py
def post_completion(self) -> None:
    self._just_completed = True
    super().post_completion()
    self.target.post_message(Input.Changed(self.target, self.target.value))

PreProcessListPane

PreProcessListPane(
    config_data: dict[str, Any], **kwargs: Any
)

Bases: ListDetailPane

List/detail pane for pre-process keycode entries.

Source code in src/skim/tui/keycodes_tab.py
def __init__(self, config_data: dict[str, Any], **kwargs: Any) -> None:
    super().__init__(pane_id="pre-process", list_help_key="keycodes-pre-proc-list", **kwargs)
    self.config_data = config_data
    self._refreshing: bool = False

config_data instance-attribute

config_data = config_data

get_entries

get_entries() -> list[dict]
Source code in src/skim/tui/keycodes_tab.py
def get_entries(self) -> list[dict]:
    return self.config_data.get("keycodes", {}).get("pre_process", [])

format_entry

format_entry(index: int, entry: dict) -> str
Source code in src/skim/tui/keycodes_tab.py
def format_entry(self, index: int, entry: dict) -> str:
    entries = self.get_entries()
    kw = max((len(e.get("keycode", "")) for e in entries), default=0)
    kc = entry.get("keycode", "")
    return f"{kc:<{kw}}  ->  {entry.get('target', '')}"

compose_detail_fields

compose_detail_fields() -> ComposeResult
Source code in src/skim/tui/keycodes_tab.py
def compose_detail_fields(self) -> ComposeResult:
    suggester = _make_keycode_suggester(self.config_data)
    candidates = _make_keycode_candidates(self.config_data)
    with Horizontal(classes="field-row"):
        yield Label("Keycode:", classes="field-label")
        pp_kc_input = SkimInput(
            value="",
            id="pre-process-keycode",
            placeholder="e.g. MKC_BKTAB",
            disabled=True,
            suggester=suggester,
            help_key="keycodes-pre-proc-keycode",
        )
        yield pp_kc_input
    yield KeycodeAutoComplete(pp_kc_input, candidates=candidates)
    with Horizontal(classes="field-row"):
        yield Label("Target:", classes="field-label")
        pp_tg_input = SkimInput(
            value="",
            id="pre-process-target",
            placeholder="e.g. LSFT(KC_TAB)",
            disabled=True,
            suggester=suggester,
            help_key="keycodes-pre-proc-target",
        )
        yield pp_tg_input
    yield KeycodeAutoComplete(pp_tg_input, candidates=candidates)
    with Horizontal(classes="field-row"):
        yield Label("Preview:", classes="field-label")
        yield SkimInput(
            value="",
            id="pre-process-preview",
            placeholder="resolved label",
            disabled=True,
        )

detail_field_ids

detail_field_ids() -> set[str]
Source code in src/skim/tui/keycodes_tab.py
def detail_field_ids(self) -> set[str]:
    return {"pre-process-keycode", "pre-process-target"}

refresh_fields

refresh_fields(entry: dict) -> None
Source code in src/skim/tui/keycodes_tab.py
def refresh_fields(self, entry: dict) -> None:
    self._refreshing = True
    self.query_one("#pre-process-keycode", Input).value = entry.get("keycode", "") or ""
    self.query_one("#pre-process-target", Input).value = entry.get("target", "") or ""
    self._update_target_preview()
    self._refreshing = False

clear_fields

clear_fields() -> None
Source code in src/skim/tui/keycodes_tab.py
def clear_fields(self) -> None:
    self._refreshing = True
    self.query_one("#pre-process-keycode", Input).value = ""
    self.query_one("#pre-process-target", Input).value = ""
    self.query_one("#pre-process-preview", Input).value = ""
    self._refreshing = False

create_entry

create_entry(index: int) -> dict
Source code in src/skim/tui/keycodes_tab.py
def create_entry(self, index: int) -> dict:
    return {"keycode": "", "target": ""}

on_input_changed

on_input_changed(event: Changed) -> None
Source code in src/skim/tui/keycodes_tab.py
def on_input_changed(self, event: Input.Changed) -> None:
    if self._refreshing:
        return
    input_id = event.input.id or ""
    if input_id.startswith("pre-process-") and input_id != "pre-process-preview":
        field = input_id[len("pre-process-") :]
        entries = self.get_entries()
        if self._selected < len(entries):
            entries[self._selected][field] = event.value
            self.update_all_list_items()
            if input_id == "pre-process-target":
                self._update_target_preview()

OverrideListPane

OverrideListPane(
    config_data: dict[str, Any], **kwargs: Any
)

Bases: ListDetailPane

List/detail pane for override keycode entries.

Source code in src/skim/tui/keycodes_tab.py
def __init__(self, config_data: dict[str, Any], **kwargs: Any) -> None:
    super().__init__(pane_id="override", list_help_key="keycodes-override-list", **kwargs)
    self.config_data = config_data
    self._refreshing: bool = False

config_data instance-attribute

config_data = config_data

get_entries

get_entries() -> list[dict]
Source code in src/skim/tui/keycodes_tab.py
def get_entries(self) -> list[dict]:
    return self.config_data.get("keycodes", {}).get("overrides", [])

format_entry

format_entry(index: int, entry: dict) -> str
Source code in src/skim/tui/keycodes_tab.py
def format_entry(self, index: int, entry: dict) -> str:
    entries = self.get_entries()
    kw = max((len(e.get("keycode", "")) for e in entries), default=0)
    kc = entry.get("keycode", "")
    preview = self._resolve_override_preview(kc)
    return f"{kc:<{kw}}  ->  {preview}"

compose_detail_fields

compose_detail_fields() -> ComposeResult
Source code in src/skim/tui/keycodes_tab.py
def compose_detail_fields(self) -> ComposeResult:
    suggester = _make_keycode_suggester(self.config_data)
    candidates = _make_keycode_candidates(self.config_data)
    with Horizontal(classes="field-row"):
        yield Label("Keycode:", classes="field-label")
        ov_kc_input = SkimInput(
            value="",
            id="override-keycode",
            placeholder="e.g. KC_ESC",
            disabled=True,
            suggester=suggester,
            help_key="keycodes-override-keycode",
        )
        yield ov_kc_input
    yield KeycodeAutoComplete(ov_kc_input, candidates=candidates)
    with Horizontal(classes="field-row"):
        yield Label("Target:", classes="field-label")
        ov_tg_input = SkimInput(
            value="",
            id="override-target",
            placeholder="e.g. @@KC_ESC; or %%nf-md-icon;",
            disabled=True,
            help_key="keycodes-override-target",
        )
        yield ov_tg_input
    yield OverrideTargetAutoComplete(ov_tg_input, config_data=self.config_data)
    with Horizontal(classes="field-row"):
        yield Label("Preview:", classes="field-label")
        yield SkimInput(
            value="",
            id="override-preview",
            placeholder="resolved label",
            disabled=True,
        )

detail_field_ids

detail_field_ids() -> set[str]
Source code in src/skim/tui/keycodes_tab.py
def detail_field_ids(self) -> set[str]:
    return {"override-keycode", "override-target"}

refresh_fields

refresh_fields(entry: dict) -> None
Source code in src/skim/tui/keycodes_tab.py
def refresh_fields(self, entry: dict) -> None:
    self._refreshing = True
    self.query_one("#override-keycode", Input).value = entry.get("keycode", "") or ""
    self.query_one("#override-target", Input).value = entry.get("target", "") or ""
    self._update_override_preview()
    self._refreshing = False

clear_fields

clear_fields() -> None
Source code in src/skim/tui/keycodes_tab.py
def clear_fields(self) -> None:
    self._refreshing = True
    self.query_one("#override-keycode", Input).value = ""
    self.query_one("#override-target", Input).value = ""
    self.query_one("#override-preview", Input).value = ""
    self._refreshing = False

create_entry

create_entry(index: int) -> dict
Source code in src/skim/tui/keycodes_tab.py
def create_entry(self, index: int) -> dict:
    return {"keycode": "", "target": ""}

on_input_changed

on_input_changed(event: Changed) -> None
Source code in src/skim/tui/keycodes_tab.py
def on_input_changed(self, event: Input.Changed) -> None:
    if self._refreshing:
        return
    input_id = event.input.id or ""
    if input_id.startswith("override-") and input_id != "override-preview":
        field = input_id[len("override-") :]
        entries = self.get_entries()
        if self._selected < len(entries):
            entries[self._selected][field] = event.value
            self.update_all_list_items()
            self._update_override_preview()

MacroListPane

MacroListPane(config_data: dict[str, Any], **kwargs: Any)

Bases: ListDetailPane

List/detail pane for macro definitions.

Source code in src/skim/tui/keycodes_tab.py
def __init__(self, config_data: dict[str, Any], **kwargs: Any) -> None:
    super().__init__(pane_id="macro", list_help_key="keycodes-macro-list", **kwargs)
    self.config_data = config_data
    self._refreshing: bool = False

config_data instance-attribute

config_data = config_data

get_entries

get_entries() -> list[dict]
Source code in src/skim/tui/keycodes_tab.py
def get_entries(self) -> list[dict]:
    return self.config_data.get("keycodes", {}).get("macros", [])

format_entry

format_entry(index: int, entry: dict) -> str
Source code in src/skim/tui/keycodes_tab.py
def format_entry(self, index: int, entry: dict) -> str:
    entries = self.get_entries()
    display_ids = [f"M{e.get('id', '') or ''}" for e in entries]
    idw = max((len(d) for d in display_ids), default=0)
    display_id = (
        display_ids[index] if 0 <= index < len(display_ids) else f"M{entry.get('id', '') or ''}"
    )
    name = entry.get("name")
    label = name if name else _resolve_nerdfont_markers(entry.get("preview", "") or "")
    return f"{display_id:<{idw}}  ->  {label}"

compose_detail_fields

compose_detail_fields() -> ComposeResult
Source code in src/skim/tui/keycodes_tab.py
def compose_detail_fields(self) -> ComposeResult:
    with Horizontal(classes="field-row"):
        yield Label("ID:", classes="field-label")
        yield SkimInput(
            value="",
            id="macro-id",
            placeholder="e.g. 0",
            disabled=True,
            help_key="keycodes-macro-id",
        )
    with Horizontal(classes="field-row"):
        yield Label("Name:", classes="field-label")
        yield SkimInput(
            value="",
            id="macro-name",
            placeholder="e.g. Em-dash",
            disabled=True,
            help_key="keycodes-macro-name",
        )
    with Horizontal(classes="field-row"):
        yield Label("Preview:", classes="field-label")
        yield SkimInput(
            value="",
            id="macro-preview",
            placeholder="resolved preview",
            disabled=True,
        )

detail_field_ids

detail_field_ids() -> set[str]
Source code in src/skim/tui/keycodes_tab.py
def detail_field_ids(self) -> set[str]:
    return {"macro-id", "macro-name"}

refresh_fields

refresh_fields(entry: dict) -> None
Source code in src/skim/tui/keycodes_tab.py
def refresh_fields(self, entry: dict) -> None:
    self._refreshing = True
    self.query_one("#macro-id", Input).value = entry.get("id", "") or ""
    self.query_one("#macro-name", Input).value = entry.get("name", "") or ""
    self.query_one("#macro-preview", Input).value = _resolve_nerdfont_markers(
        entry.get("preview", "") or ""
    )
    self._refreshing = False

clear_fields

clear_fields() -> None
Source code in src/skim/tui/keycodes_tab.py
def clear_fields(self) -> None:
    self._refreshing = True
    self.query_one("#macro-id", Input).value = ""
    self.query_one("#macro-name", Input).value = ""
    self.query_one("#macro-preview", Input).value = ""
    self._refreshing = False

create_entry

create_entry(index: int) -> dict
Source code in src/skim/tui/keycodes_tab.py
def create_entry(self, index: int) -> dict:
    entries = self.get_entries()
    used = {e.get("id", "") or "" for e in entries}
    candidate = 1
    while str(candidate) in used:
        candidate += 1
    return {"id": str(candidate), "name": None, "preview": "Undefined"}

validate_and_apply

validate_and_apply(entry: dict) -> bool
Source code in src/skim/tui/keycodes_tab.py
def validate_and_apply(self, entry: dict) -> bool:
    new_id = (self.query_one("#macro-id", Input).value or "").strip()
    if not new_id:
        self._revert_and_show_error("Macro ID cannot be empty.")
        return False
    entries = self.get_entries()
    for i, other in enumerate(entries):
        if i == self._selected:
            continue
        if (other.get("id", "") or "") == new_id:
            self._revert_and_show_error(f"Macro ID '{new_id}' is already used.")
            return False
    entry["id"] = new_id
    entry["name"] = (self.query_one("#macro-name", Input).value or "").strip() or None
    return True

on_input_changed

on_input_changed(event: Changed) -> None
Source code in src/skim/tui/keycodes_tab.py
def on_input_changed(self, event: Input.Changed) -> None:
    if self._refreshing:
        return
    input_id = event.input.id or ""
    if input_id == "macro-id":
        entries = self.get_entries()
        if self._selected < len(entries):
            entries[self._selected]["id"] = event.value
            self.update_all_list_items()
    elif input_id == "macro-name":
        entries = self.get_entries()
        if self._selected < len(entries):
            entries[self._selected]["name"] = event.value or None
            self.update_all_list_items()

TapDanceListPane

TapDanceListPane(
    config_data: dict[str, Any], **kwargs: Any
)

Bases: ListDetailPane

List/detail pane for tap-dance definitions.

Source code in src/skim/tui/keycodes_tab.py
def __init__(self, config_data: dict[str, Any], **kwargs: Any) -> None:
    super().__init__(pane_id="tap-dance", list_help_key="keycodes-tap-dance-list", **kwargs)
    self.config_data = config_data
    self._refreshing: bool = False

config_data instance-attribute

config_data = config_data

get_entries

get_entries() -> list[dict]
Source code in src/skim/tui/keycodes_tab.py
def get_entries(self) -> list[dict]:
    return self.config_data.get("keycodes", {}).get("tap_dances", [])

format_entry

format_entry(index: int, entry: dict) -> str
Source code in src/skim/tui/keycodes_tab.py
def format_entry(self, index: int, entry: dict) -> str:
    entries = self.get_entries()
    display_ids = [f"TD({e.get('id', '') or ''})" for e in entries]
    idw = max((len(d) for d in display_ids), default=0)
    display_id = (
        display_ids[index]
        if 0 <= index < len(display_ids)
        else f"TD({entry.get('id', '') or ''})"
    )
    name = entry.get("name")
    label = name if name else _resolve_nerdfont_markers(entry.get("preview", "") or "")
    return f"{display_id:<{idw}}  ->  {label}"

compose_detail_fields

compose_detail_fields() -> ComposeResult
Source code in src/skim/tui/keycodes_tab.py
def compose_detail_fields(self) -> ComposeResult:
    with Horizontal(classes="field-row"):
        yield Label("ID:", classes="field-label")
        yield SkimInput(
            value="",
            id="tap-dance-id",
            placeholder="e.g. 0",
            disabled=True,
            help_key="keycodes-tap-dance-id",
        )
    with Horizontal(classes="field-row"):
        yield Label("Name:", classes="field-label")
        yield SkimInput(
            value="",
            id="tap-dance-name",
            placeholder="e.g. Quick shift",
            disabled=True,
            help_key="keycodes-tap-dance-name",
        )
    with Horizontal(classes="field-row"):
        yield Label("Preview:", classes="field-label")
        yield SkimInput(
            value="",
            id="tap-dance-preview",
            placeholder="resolved preview",
            disabled=True,
        )

detail_field_ids

detail_field_ids() -> set[str]
Source code in src/skim/tui/keycodes_tab.py
def detail_field_ids(self) -> set[str]:
    return {"tap-dance-id", "tap-dance-name"}

refresh_fields

refresh_fields(entry: dict) -> None
Source code in src/skim/tui/keycodes_tab.py
def refresh_fields(self, entry: dict) -> None:
    self._refreshing = True
    self.query_one("#tap-dance-id", Input).value = entry.get("id", "") or ""
    self.query_one("#tap-dance-name", Input).value = entry.get("name", "") or ""
    self.query_one("#tap-dance-preview", Input).value = _resolve_nerdfont_markers(
        entry.get("preview", "") or ""
    )
    self._refreshing = False

clear_fields

clear_fields() -> None
Source code in src/skim/tui/keycodes_tab.py
def clear_fields(self) -> None:
    self._refreshing = True
    self.query_one("#tap-dance-id", Input).value = ""
    self.query_one("#tap-dance-name", Input).value = ""
    self.query_one("#tap-dance-preview", Input).value = ""
    self._refreshing = False

create_entry

create_entry(index: int) -> dict
Source code in src/skim/tui/keycodes_tab.py
def create_entry(self, index: int) -> dict:
    entries = self.get_entries()
    used = {e.get("id", "") or "" for e in entries}
    candidate = 0
    while str(candidate) in used:
        candidate += 1
    return {"id": str(candidate), "name": None, "preview": "Undefined"}

validate_and_apply

validate_and_apply(entry: dict) -> bool
Source code in src/skim/tui/keycodes_tab.py
def validate_and_apply(self, entry: dict) -> bool:
    new_id = (self.query_one("#tap-dance-id", Input).value or "").strip()
    if not new_id:
        self._revert_and_show_error("Tap-dance ID cannot be empty.")
        return False
    entries = self.get_entries()
    for i, other in enumerate(entries):
        if i == self._selected:
            continue
        if (other.get("id", "") or "") == new_id:
            self._revert_and_show_error(f"Tap-dance ID '{new_id}' is already used.")
            return False
    entry["id"] = new_id
    entry["name"] = (self.query_one("#tap-dance-name", Input).value or "").strip() or None
    return True

on_input_changed

on_input_changed(event: Changed) -> None
Source code in src/skim/tui/keycodes_tab.py
def on_input_changed(self, event: Input.Changed) -> None:
    if self._refreshing:
        return
    input_id = event.input.id or ""
    if input_id == "tap-dance-id":
        entries = self.get_entries()
        if self._selected < len(entries):
            entries[self._selected]["id"] = event.value
            self.update_all_list_items()
    elif input_id == "tap-dance-name":
        entries = self.get_entries()
        if self._selected < len(entries):
            entries[self._selected]["name"] = event.value or None
            self.update_all_list_items()

KeycodesTab

KeycodesTab(config_data: dict[str, Any], **kwargs: Any)

Bases: Widget

Keycodes configuration tab.

Shows four sections -- Pre-process, Overrides, Macros, and Tap-dances -- each using a ListDetailPane subclass for editing individual entries. The whole tab is wrapped in a SkimVerticalScroll so all four sections scroll together.

Source code in src/skim/tui/keycodes_tab.py
def __init__(self, config_data: dict[str, Any], **kwargs: Any) -> None:
    super().__init__(**kwargs)
    self.config_data = config_data

DEFAULT_CSS class-attribute instance-attribute

DEFAULT_CSS = "\n    KeycodesTab {\n        height: 1fr;\n        padding: 0 1;\n    }\n    KeycodesTab .keycodes-section {\n        height: auto;\n    }\n    KeycodesTab .keycodes-section ListDetailPane .ldp-list-col {\n        width: 35%;\n        min-width: 25;\n        height: auto;\n    }\n    KeycodesTab .keycodes-section ListDetailPane .ldp-list {\n        height: 8;\n        border: solid $accent 50%;\n    }\n    KeycodesTab .keycodes-section ListDetailPane .ldp-detail {\n        padding: 0 1;\n        height: auto;\n        overflow-x: hidden;\n        border: solid $accent 30%;\n    }\n    KeycodesTab .keycodes-section ListDetailPane .ldp-detail .field-row {\n        height: auto;\n    }\n    KeycodesTab .keycodes-section ListDetailPane .ldp-detail:focus-within {\n        border: solid $accent;\n    }\n    "

config_data instance-attribute

config_data = config_data

compose

compose() -> ComposeResult
Source code in src/skim/tui/keycodes_tab.py
def compose(self) -> ComposeResult:
    with SkimVerticalScroll(can_focus=False):
        with Vertical(id="pre-process-section", classes="keycodes-section"):
            yield Static(
                "Pre-process",
                id="keycodes-pre-proc-section",
                classes="section-title section-title-first",
            )
            yield PreProcessListPane(config_data=self.config_data)

        with Vertical(id="overrides-section", classes="keycodes-section"):
            yield Static(
                "Overrides",
                id="keycodes-override-section",
                classes="section-title",
            )
            yield OverrideListPane(config_data=self.config_data)

        with Vertical(id="macros-section", classes="keycodes-section"):
            yield Static(
                "Macros",
                id="keycodes-macro-section",
                classes="section-title",
            )
            yield MacroListPane(config_data=self.config_data)

        with Vertical(id="tap-dances-section", classes="keycodes-section"):
            yield Static(
                "Tap-dances",
                id="keycodes-tap-dance-section",
                classes="section-title",
            )
            yield TapDanceListPane(config_data=self.config_data)

on_mount

on_mount() -> None
Source code in src/skim/tui/keycodes_tab.py
def on_mount(self) -> None:
    for pane_cls in (
        PreProcessListPane,
        OverrideListPane,
        MacroListPane,
        TapDanceListPane,
    ):
        pane = self.query_one(pane_cls)
        pane.rebuild_list()
        entries = pane.get_entries()
        if entries:
            pane._selected = 0
            pane.refresh_fields(entries[0])
        pane._update_list_state()

Output Tab

output_tab

Output tab widget for the skim TUI configuration editor.

ColorAutoComplete

ColorAutoComplete(*args: Any, **kwargs: Any)

Bases: AutoComplete

AutoComplete that re-posts Input.Changed after dropdown selection.

The base AutoComplete suppresses Input.Changed during completion via self.prevent(Input.Changed). This subclass re-fires the event so that parent widgets (e.g. swatch updates) react to the new value.

Source code in src/skim/tui/output_tab.py
def __init__(self, *args: Any, **kwargs: Any) -> None:
    super().__init__(*args, **kwargs)
    self._just_completed: bool = False

should_show_dropdown

should_show_dropdown(search_string: str) -> bool
Source code in src/skim/tui/output_tab.py
def should_show_dropdown(self, search_string: str) -> bool:
    option_list = self.option_list
    if option_list.option_count <= 1:
        return False
    return super().should_show_dropdown(search_string)

apply_completion

apply_completion(value: str, state: TargetState) -> None
Source code in src/skim/tui/output_tab.py
def apply_completion(self, value: str, state: TargetState) -> None:
    # value is the full plain text (name + padding + swatch);
    # extract just the color name (first word)
    color_name = value.split()[0] if value.strip() else value
    super().apply_completion(color_name, state)

post_completion

post_completion() -> None
Source code in src/skim/tui/output_tab.py
def post_completion(self) -> None:
    self._just_completed = True
    super().post_completion()
    self.target.post_message(Input.Changed(self.target, self.target.value))

LayerColorListPane

LayerColorListPane(
    config_data: dict[str, Any], **kwargs: Any
)

Bases: ListDetailPane

List/detail pane for layer colors.

Source code in src/skim/tui/output_tab.py
def __init__(self, config_data: dict[str, Any], **kwargs: Any) -> None:
    super().__init__(pane_id="layer-colors", list_help_key="output-layer-color-list", **kwargs)
    self.config_data = config_data
    self._select_active: bool = False

DEFAULT_CSS class-attribute instance-attribute

DEFAULT_CSS = "\n    LayerColorListPane {\n        height: auto;\n    }\n    LayerColorListPane .lc-swatch {\n        width: 4;\n        height: 1;\n        dock: right;\n        margin: 0 0 0 1;\n    }\n    LayerColorListPane .color-swatch {\n        width: 4;\n        height: 1;\n        margin: 1 1 0 0;\n        content-align: center middle;\n    }\n    LayerColorListPane .swatch-spacer {\n        width: 4;\n        height: 1;\n        margin: 1 1 0 0;\n    }\n    LayerColorListPane .gradient-swatch {\n        width: 4;\n        height: 3;\n        content-align: center middle;\n    }\n    LayerColorListPane .gradient-preview {\n        height: 3;\n        width: auto;\n        layout: horizontal;\n        padding: 0 2;\n    }\n    LayerColorListPane .gradient-dark {\n        background: #1b1b1b;\n        margin: 0 0 0 2;\n    }\n    LayerColorListPane .gradient-light {\n        background: #ffffff;\n        margin: 0 0 0 1;\n    }\n    LayerColorListPane .lc-manual-step {\n        display: none;\n    }\n    LayerColorListPane.manual-mode .lc-manual-step {\n        display: block;\n    }\n    LayerColorListPane.manual-mode #lc-dynamic-color {\n        display: none;\n    }\n    LayerColorListPane.manual-mode #lc-dynamic-preview {\n        display: none;\n    }\n    "

config_data instance-attribute

config_data = config_data

move_enabled property

move_enabled: bool

get_entries

get_entries() -> list[dict]
Source code in src/skim/tui/output_tab.py
def get_entries(self) -> list[dict]:
    return (
        self.config_data.get("output", {}).get("style", {}).get("palette", {}).get("layers", [])
    )

format_entry

format_entry(index: int, entry: dict) -> str
Source code in src/skim/tui/output_tab.py
def format_entry(self, index: int, entry: dict) -> str:
    col0_w, col1_w = self._lc_column_widths()
    name = self._layer_name(index)
    qmk_idx = self._layer_qmk_index(index)
    col0 = f"{name} ({qmk_idx})" if name else f"({qmk_idx})"
    color = entry.get("base_color", "")
    return f"{col0:<{col0_w}}  {color:<{col1_w}}"

compose_detail_fields

compose_detail_fields() -> ComposeResult
Source code in src/skim/tui/output_tab.py
def compose_detail_fields(self) -> ComposeResult:
    with Horizontal(classes="field-row"):
        yield Label("Gradient type:", classes="field-label")
        yield Static(" ", classes="swatch-spacer")
        yield SkimSelect(
            options=[("Dynamic", "dynamic"), ("Manual", "manual")],
            value="dynamic",
            id="lc-gradient-type",
            disabled=True,
            help_key="output-layer-color-gradient-type",
        )
    with Horizontal(classes="field-row"):
        yield Label("Main gradient step index:", classes="field-label")
        yield Static(" ", classes="swatch-spacer")
        yield SkimInput(
            value="",
            id="lc-color-index",
            placeholder="2",
            disabled=True,
            help_key="output-layer-color-color-index",
        )
    # --- Dynamic mode fields ---
    with Horizontal(classes="field-row", id="lc-dynamic-color"):
        yield Label("Main gradient step color:", classes="field-label")
        yield Static(
            "\ue0b6\u2588\u2588\ue0b4", classes="color-swatch", id="swatch-lc-base-color"
        )
        lc_color_input = LayerColorInput(
            value="",
            id="lc-base-color",
            placeholder="#RRGGBB",
            disabled=True,
            suggester=_COLOR_SUGGESTER,
            help_key="output-layer-color-base-color",
        )
        yield lc_color_input
    yield ColorAutoComplete(lc_color_input, candidates=_color_candidates)
    with Horizontal(classes="field-row", id="lc-dynamic-preview"):
        yield Label("Layer gradient:", classes="field-label")
        yield Static(" ", classes="swatch-spacer")
        with Horizontal(classes="gradient-preview gradient-dark"):
            for i in range(6):
                yield Static(
                    f"   \n\ue0b6\u2588\ue0b4\n {i} ",
                    classes="gradient-swatch",
                    id=f"gradient-dark-{i}",
                )
        with Horizontal(classes="gradient-preview gradient-light"):
            for i in range(6):
                yield Static(
                    f"   \n\ue0b6\u2588\ue0b4\n {i} ",
                    classes="gradient-swatch",
                    id=f"gradient-light-{i}",
                )
    # --- Manual mode fields (hidden by default) ---
    for i in range(6):
        with Horizontal(classes="field-row lc-manual-step", id=f"lc-manual-step-{i}"):
            yield Label(f"Step {i}:", classes="field-label")
            yield Static(
                "\ue0b6\u2588\u2588\ue0b4",
                classes="color-swatch",
                id=f"swatch-lc-step-{i}",
            )
            step_input = LayerColorInput(
                value="",
                id=f"lc-step-{i}",
                placeholder="#RRGGBB",
                disabled=True,
                suggester=_COLOR_SUGGESTER,
                help_key="output-layer-color-step",
            )
            yield step_input
        yield ColorAutoComplete(step_input, candidates=_color_candidates)

detail_field_ids

detail_field_ids() -> set[str]
Source code in src/skim/tui/output_tab.py
def detail_field_ids(self) -> set[str]:
    ids = {"lc-base-color", "lc-color-index", "lc-gradient-type"}
    for i in range(6):
        ids.add(f"lc-step-{i}")
    return ids

move_paired_lists

move_paired_lists() -> list[list[dict]]
Source code in src/skim/tui/output_tab.py
def move_paired_lists(self) -> list[list[dict]]:
    kl = self._keyboard_layers()
    return [kl] if kl else []

on_move_swap

on_move_swap(
    entries: list[dict],
    pos: int,
    target: int,
    direction: int,
) -> None
Source code in src/skim/tui/output_tab.py
def on_move_swap(
    self,
    entries: list[dict],
    pos: int,
    target: int,
    direction: int,
) -> None:
    kl = self._keyboard_layers()
    if len(kl) > max(pos, target):
        moved = kl[pos]
        neighbor = kl[target]
        old_moved_index = moved["index"]
        moved["index"] = neighbor["index"]
        # Find next free adjacent index toward origin
        used = {layer.get("index", i) for i, layer in enumerate(kl)} - {old_moved_index}
        candidate = neighbor["index"] - direction
        while candidate in used:
            candidate -= direction
        neighbor["index"] = max(0, min(31, candidate))

on_key

on_key(event) -> None

Override to let Select handle keys normally.

Textual's on_key fires during event bubbling BEFORE the App-level binding system runs. We must stop the event (to prevent other handlers from interfering) and manually trigger binding checks.

Source code in src/skim/tui/output_tab.py
def on_key(self, event) -> None:
    """Override to let Select handle keys normally.

    Textual's on_key fires during event bubbling BEFORE the App-level
    binding system runs. We must stop the event (to prevent other
    handlers from interfering) and manually trigger binding checks.
    """
    if self._editing and (self._select_active or self._is_inside_select()):
        if event.key in ("enter", "space", "up", "down"):
            if not self._select_active:
                self._select_active = True
            event.stop()
            event.prevent_default()
            self.call_later(self.app._check_bindings, event.key)
            # Clear flag after selection in case value didn't change
            # (on_select_changed only fires when value actually changes)
            if self._select_active and event.key in ("enter", "space"):
                self.set_timer(0.15, self._clear_select_active)
            return
        if event.key == "escape" and self._select_active:
            event.stop()
            event.prevent_default()
            self._dismiss_select_overlay()
            return
            # Escape on closed Select — fall through to base (cancel edit)
    super().on_key(event)

on_descendant_blur

on_descendant_blur(event: DescendantBlur) -> None

Suppress focus-out commit while Select overlay is open.

Source code in src/skim/tui/output_tab.py
def on_descendant_blur(self, event: DescendantBlur) -> None:
    """Suppress focus-out commit while Select overlay is open."""
    if self._select_active:
        return
    super().on_descendant_blur(event)

on_select_changed

on_select_changed(event: Changed) -> None
Source code in src/skim/tui/output_tab.py
def on_select_changed(self, event: SkimSelect.Changed) -> None:
    self._select_active = False
    if event.select.id == "lc-gradient-type" and event.value is not SkimSelect.BLANK:
        self._on_gradient_type_changed(str(event.value))

refresh_fields

refresh_fields(entry: dict) -> None
Source code in src/skim/tui/output_tab.py
def refresh_fields(self, entry: dict) -> None:
    color = entry.get("base_color", "") or ""
    color_index = entry.get("color_index", 2)
    manual = self._is_manual_mode(entry)

    self._set_mode(manual)
    self.query_one("#lc-gradient-type", SkimSelect).value = "manual" if manual else "dynamic"
    self.query_one("#lc-color-index", Input).value = str(color_index)

    if manual:
        gradient = entry.get("gradient", ()) or ()
        for i in range(6):
            step_color = gradient[i] if i < len(gradient) else ""
            self.query_one(f"#lc-step-{i}", Input).value = step_color
            self._update_swatch(f"swatch-lc-step-{i}", step_color)
    else:
        self.query_one("#lc-base-color", Input).value = color
        self._update_swatch("swatch-lc-base-color", color)
        self._update_gradient_preview(color, color_index)

clear_fields

clear_fields() -> None
Source code in src/skim/tui/output_tab.py
def clear_fields(self) -> None:
    self._set_mode(False)
    self.query_one("#lc-gradient-type", SkimSelect).value = "dynamic"
    self.query_one("#lc-base-color", Input).value = ""
    self.query_one("#lc-color-index", Input).value = ""
    for i in range(6):
        self.query_one(f"#lc-step-{i}", Input).value = ""
        self._update_swatch(f"swatch-lc-step-{i}", "")
        for prefix, label_color in (("gradient-dark", "white"), ("gradient-light", "black")):
            try:
                swatch = self.query_one(f"#{prefix}-{i}", Static)
                swatch.update(self._make_swatch_text(i, "", -1, label_color))
            except Exception:
                pass

create_entry

create_entry(index: int) -> dict
Source code in src/skim/tui/output_tab.py
def create_entry(self, index: int) -> dict:
    return {"base_color": default_layer_color(index), "color_index": 2, "gradient": None}

on_input_changed

on_input_changed(event: Changed) -> None
Source code in src/skim/tui/output_tab.py
def on_input_changed(self, event: Input.Changed) -> None:
    # Only respond to user typing while editing. ``refresh_fields`` writes
    # ``Input.value`` programmatically when loading or reselecting an
    # entry; without this guard, those writes fire ``Input.Changed`` and
    # the lc-step branch silently overwrites ``entry["base_color"]`` with
    # the gradient step at ``color_index`` — corrupting saved data on
    # every reload.
    if not self._editing:
        return
    input_id = event.input.id or ""
    if input_id == "lc-base-color":
        layer_colors = self.get_entries()
        if self._selected < len(layer_colors):
            layer_colors[self._selected]["base_color"] = event.value
            self.update_selected_list_item()
            self._update_swatch("swatch-lc-base-color", event.value)
            self._update_list_swatch(event.value)
            base_color, color_index = self._current_gradient_params()
            self._update_gradient_preview(base_color, color_index)
    elif input_id == "lc-color-index":
        layer_colors = self.get_entries()
        if self._selected < len(layer_colors):
            with contextlib.suppress(ValueError):
                layer_colors[self._selected]["color_index"] = int(event.value)
            base_color, color_index = self._current_gradient_params()
            self._update_gradient_preview(base_color, color_index)
    elif input_id.startswith("lc-step-"):
        # Manual gradient step color changed
        step = int(input_id[len("lc-step-") :])
        layer_colors = self.get_entries()
        if self._selected < len(layer_colors):
            entry = layer_colors[self._selected]
            gradient = entry.get("gradient")
            if gradient is not None:
                if isinstance(gradient, tuple):
                    gradient = list(gradient)
                    entry["gradient"] = gradient
                if step < len(gradient):
                    gradient[step] = event.value
                self._update_swatch(f"swatch-lc-step-{step}", event.value)
                # Update base_color if this is the main step
                color_index = entry.get("color_index", 2)
                if step == color_index:
                    entry["base_color"] = event.value
                    self.update_selected_list_item()
                    self._update_list_swatch(event.value)

OutputTab

OutputTab(config_data: dict[str, Any], **kwargs: Any)

Bases: Widget

Output configuration tab.

Has sections for layout, style, palette, and layer colors.

Source code in src/skim/tui/output_tab.py
def __init__(self, config_data: dict[str, Any], **kwargs: Any) -> None:
    super().__init__(**kwargs)
    self.config_data = config_data

DEFAULT_CSS class-attribute instance-attribute

DEFAULT_CSS = "\n    OutputTab {\n        height: 1fr;\n        padding: 0 1;\n    }\n    OutputTab .section {\n        height: auto;\n        border-bottom: solid $accent 20%;\n    }\n    OutputTab .color-swatch {\n        width: 5;\n        height: 1;\n        margin: 1 1 0 0;\n        content-align: center middle;\n    }\n    OutputTab .swatch-spacer {\n        width: 4;\n        height: 1;\n        margin: 1 1 0 0;\n    }\n    "

config_data instance-attribute

config_data = config_data

compose

compose() -> ComposeResult
Source code in src/skim/tui/output_tab.py
def compose(self) -> ComposeResult:
    output = self.config_data.get("output", {})
    layout = output.get("layout", {})
    spacing = layout.get("spacing", {})
    style = output.get("style", {})
    palette = style.get("palette", {})
    border = style.get("border")
    hold_position = style.get("hold_symbol_position", "outward")

    with SkimVerticalScroll(can_focus=False):
        # --- Page section ---
        with Vertical(classes="section"):
            yield Static(
                "Page",
                id="output-page-section",
                classes="section-title section-title-first",
            )
            with Horizontal(classes="field-row"):
                yield Label("Width:", classes="field-label")
                yield Static(" ", classes="swatch-spacer")
                yield SkimStandaloneInput(
                    value=str(layout.get("width", 800.0)),
                    id="layout-width",
                    placeholder="800.0",
                    help_key="output-page-width",
                )
            with Horizontal(classes="field-row"):
                yield Label("Margin:", classes="field-label")
                yield Static(" ", classes="swatch-spacer")
                yield SkimStandaloneInput(
                    value=str(spacing.get("margin", 0.0)),
                    id="layout-margin",
                    placeholder="0.0",
                    help_key="output-page-margin",
                )
            with Horizontal(classes="field-row"):
                yield Label("Inset:", classes="field-label")
                yield Static(" ", classes="swatch-spacer")
                yield SkimStandaloneInput(
                    value=str(spacing.get("inset", 20.0)),
                    id="layout-inset",
                    placeholder="20.0",
                    help_key="output-page-inset",
                )
            with Horizontal(classes="field-row"):
                yield Label("Border enabled:", classes="field-label")
                yield Static(" ", classes="swatch-spacer")
                yield SkimSwitch(
                    value=border is not None,
                    id="border-enabled",
                    help_key="output-page-border-enabled",
                )
            with Horizontal(classes="field-row"):
                yield Label("Border width:", classes="field-label")
                yield Static(" ", classes="swatch-spacer")
                yield SkimStandaloneInput(
                    value=str(border.get("width", 2.0)) if border else "2.0",
                    id="border-width",
                    placeholder="2.0",
                    help_key="output-page-border-width",
                )
            with Horizontal(classes="field-row"):
                yield Label("Border radius:", classes="field-label")
                yield Static(" ", classes="swatch-spacer")
                yield SkimStandaloneInput(
                    value=str(border.get("radius", 10.0)) if border else "10.0",
                    id="border-radius",
                    placeholder="10.0",
                    help_key="output-page-border-radius",
                )

        # --- Style section ---
        with Vertical(classes="section"):
            yield Static(
                "Style",
                id="output-style-section",
                classes="section-title",
            )
            with Horizontal(classes="field-row"):
                yield Label("Hold symbol position:", classes="field-label")
                yield Static(" ", classes="swatch-spacer")
                yield SkimSelect(
                    options=_HOLD_SYMBOL_OPTIONS,
                    value=hold_position,
                    id="hold-symbol-position",
                    help_key="output-style-hold-symbol-position",
                )
            with Horizontal(classes="field-row"):
                yield Label("Use system fonts:", classes="field-label")
                yield Static(" ", classes="swatch-spacer")
                yield SkimSwitch(
                    value=style.get("use_system_fonts", False),
                    id="use-system-fonts",
                    help_key="output-style-use-system-fonts",
                )
            with Horizontal(classes="field-row"):
                yield Label("Use layer colors on keys:", classes="field-label")
                yield Static(" ", classes="swatch-spacer")
                yield SkimSwitch(
                    value=style.get("use_layer_colors_on_keys", True),
                    id="use-layer-colors",
                    help_key="output-style-use-layer-colors",
                )
            layer_indicator = style.get("layer_indicator", {}) or {}
            overview_block = style.get("overview", {}) or {}
            layer_connector = overview_block.get("layer_connector", {}) or {}
            legend_tables = style.get("legend_tables", {}) or {}
            macros_legend = legend_tables.get("macros", {}) or {}
            tap_dances_legend = legend_tables.get("tap_dances", {}) or {}
            symbols_legend = legend_tables.get("symbols", {}) or {}
            # Special-keys (macros + tap-dances) share a single TUI
            # toggle; on disk they're independent. Show "off" only
            # when both legacy halves are off.
            special_keys_default = macros_legend.get("show", True) or tap_dances_legend.get(
                "show", True
            )
            with Horizontal(classes="field-row"):
                yield Label("Show layer indicators:", classes="field-label")
                yield Static(" ", classes="swatch-spacer")
                yield SkimSwitch(
                    value=layer_indicator.get("show", True),
                    id="show-layer-indicators",
                    help_key="output-style-show-layer-indicators",
                )
            with Horizontal(classes="field-row"):
                yield Label("Show layer connectors:", classes="field-label")
                yield Static(" ", classes="swatch-spacer")
                yield SkimSwitch(
                    value=layer_connector.get("show", True),
                    id="show-layer-connectors",
                    help_key="output-style-show-layer-connectors",
                )
            with Horizontal(classes="field-row"):
                yield Label("Show transparent fall-through:", classes="field-label")
                yield Static(" ", classes="swatch-spacer")
                yield SkimSwitch(
                    value=style.get("show_transparent_fallthrough", True),
                    id="show-transparent-fallthrough",
                    help_key="output-style-show-transparent-fallthrough",
                )
            with Horizontal(classes="field-row"):
                yield Label("Show special keys legend:", classes="field-label")
                yield Static(" ", classes="swatch-spacer")
                yield SkimSwitch(
                    value=special_keys_default,
                    id="show-special-keys-legend",
                    help_key="output-style-show-special-keys-legend",
                )
            with Horizontal(classes="field-row"):
                yield Label("Show symbol legend:", classes="field-label")
                yield Static(" ", classes="swatch-spacer")
                yield SkimSwitch(
                    value=symbols_legend.get("show", True),
                    id="show-symbol-legend",
                    help_key="output-style-show-symbol-legend",
                )
            with Horizontal(classes="field-row"):
                yield Label("Symbol legend flow:", classes="field-label")
                yield SkimSelect(
                    options=[
                        ("Column-major (top-to-bottom)", "column"),
                        ("Row-major (left-to-right)", "row"),
                    ],
                    value=symbols_legend.get("flow", "column"),
                    id="symbol-legend-flow",
                    help_key="output-style-symbol-legend-flow",
                )

        # --- Palette section ---
        with Vertical(classes="section"):
            yield Static(
                "Palette",
                id="output-palette-section",
                classes="section-title",
            )
            for color_label, field_id, config_key, placeholder in [
                ("Background color:", "palette-background-color", "background_color", "white"),
                ("Text color:", "palette-text-color", "text_color", "black"),
                ("Border color:", "palette-border-color", "border_color", "black"),
                ("Neutral color:", "palette-neutral-color", "neutral_color", "#6F768B"),
                ("Key label color:", "palette-key-label-color", "key_label_color", "white"),
                ("Macro color:", "palette-macro-color", "macro_color", "#89511C"),
                ("Tap-dance color:", "palette-tap-dance-color", "tap_dance_color", "#41687F"),
            ]:
                color_val = palette.get(config_key, "") or ""
                with Horizontal(classes="field-row"):
                    yield Label(color_label, classes="field-label")
                    yield Static(
                        "\ue0b6\u2588\u2588\ue0b4",
                        classes="color-swatch",
                        id=f"swatch-{field_id}",
                    )
                    color_input = ColorInput(
                        value=color_val,
                        id=field_id,
                        placeholder=placeholder,
                        suggester=_COLOR_SUGGESTER,
                        help_key=field_id,
                    )
                    yield color_input
                yield ColorAutoComplete(color_input, candidates=_color_candidates)

        # --- Layer colors section ---
        with Vertical(classes="section"):
            yield Static(
                "Layer Colors",
                id="output-layer-color-section",
                classes="section-title",
            )
            yield LayerColorListPane(config_data=self.config_data)

on_mount

on_mount() -> None
Source code in src/skim/tui/output_tab.py
def on_mount(self) -> None:
    pane = self.query_one(LayerColorListPane)
    pane.rebuild_list()
    entries = pane.get_entries()
    if entries:
        pane._selected = 0
        pane.refresh_fields(entries[0])
    pane._update_list_state()
    self._update_all_palette_swatches()

on_list_detail_pane_entry_added

on_list_detail_pane_entry_added(event: EntryAdded) -> None
Source code in src/skim/tui/output_tab.py
def on_list_detail_pane_entry_added(self, event: ListDetailPane.EntryAdded) -> None:
    self.post_message(LayerAdded(event.index, "style"))

on_list_detail_pane_entry_removed

on_list_detail_pane_entry_removed(
    event: EntryRemoved,
) -> None
Source code in src/skim/tui/output_tab.py
def on_list_detail_pane_entry_removed(self, event: ListDetailPane.EntryRemoved) -> None:
    self.post_message(LayerRemoved(event.index, "style"))

sync_layer_added

sync_layer_added(index: int) -> None

Called when a layer is added in the Keyboard tab.

Source code in src/skim/tui/output_tab.py
def sync_layer_added(self, index: int) -> None:
    """Called when a layer is added in the Keyboard tab."""
    pane = self.query_one(LayerColorListPane)
    layer_colors = pane.get_entries()
    new_lc = {"base_color": default_layer_color(index), "color_index": 2, "gradient": None}
    layer_colors.insert(index, new_lc)
    pane.rebuild_list()
    pane._selected = index
    pane.refresh_fields(new_lc)
    pane._update_list_state()

sync_layer_removed

sync_layer_removed(index: int) -> None

Called when a layer is removed in the Keyboard tab.

Source code in src/skim/tui/output_tab.py
def sync_layer_removed(self, index: int) -> None:
    """Called when a layer is removed in the Keyboard tab."""
    pane = self.query_one(LayerColorListPane)
    layer_colors = pane.get_entries()
    if index >= len(layer_colors):
        return
    layer_colors.pop(index)
    pane.rebuild_list()
    if layer_colors:
        pane._selected = min(pane._selected, len(layer_colors) - 1)
        pane.refresh_fields(layer_colors[pane._selected])
    else:
        pane._selected = 0
        pane.clear_fields()
    pane._update_list_state()

on_input_changed

on_input_changed(event: Changed) -> None

Route input changes to the correct config path.

Source code in src/skim/tui/output_tab.py
def on_input_changed(self, event: Input.Changed) -> None:
    """Route input changes to the correct config path."""
    input_id = event.input.id or ""
    value = event.value

    if input_id == "layout-width":
        with contextlib.suppress(ValueError):
            self.config_data["output"]["layout"]["width"] = float(value)

    elif input_id == "layout-margin":
        with contextlib.suppress(ValueError):
            self.config_data["output"]["layout"]["spacing"]["margin"] = float(value)

    elif input_id == "layout-inset":
        with contextlib.suppress(ValueError):
            self.config_data["output"]["layout"]["spacing"]["inset"] = float(value)

    elif input_id in _PALETTE_FIELD_MAP:
        config_key = _PALETTE_FIELD_MAP[input_id]
        self.config_data["output"]["style"]["palette"][config_key] = value
        self._update_swatch(f"swatch-{input_id}", value)

    elif input_id == "border-width":
        border = self.config_data["output"]["style"].get("border")
        if border is not None:
            with contextlib.suppress(ValueError):
                border["width"] = float(value)

    elif input_id == "border-radius":
        border = self.config_data["output"]["style"].get("border")
        if border is not None:
            with contextlib.suppress(ValueError):
                border["radius"] = float(value)

on_switch_changed

on_switch_changed(event: Changed) -> None
Source code in src/skim/tui/output_tab.py
def on_switch_changed(self, event: SkimSwitch.Changed) -> None:
    switch_id = event.switch.id or ""
    value = event.value

    style = self.config_data["output"]["style"]
    if switch_id == "use-layer-colors":
        style["use_layer_colors_on_keys"] = value
    elif switch_id == "show-layer-indicators":
        style.setdefault("layer_indicator", {})["show"] = value
    elif switch_id == "show-layer-connectors":
        style.setdefault("overview", {}).setdefault("layer_connector", {})["show"] = value
    elif switch_id == "show-transparent-fallthrough":
        style["show_transparent_fallthrough"] = value
    elif switch_id == "show-special-keys-legend":
        # Single TUI toggle writes to both halves of the split.
        legends = style.setdefault("legend_tables", {})
        legends.setdefault("macros", {})["show"] = value
        legends.setdefault("tap_dances", {})["show"] = value
    elif switch_id == "show-symbol-legend":
        style.setdefault("legend_tables", {}).setdefault("symbols", {})["show"] = value
    elif switch_id == "use-system-fonts":
        style["use_system_fonts"] = value
    elif switch_id == "border-enabled":
        if value:
            current = style.get("border")
            if current is None:
                style["border"] = {"width": 2.0, "radius": 10.0}
        else:
            style["border"] = None

on_select_changed

on_select_changed(event: Changed) -> None
Source code in src/skim/tui/output_tab.py
def on_select_changed(self, event: SkimSelect.Changed) -> None:
    style = self.config_data["output"]["style"]
    if event.select.id == "hold-symbol-position" and event.value is not SkimSelect.BLANK:
        style["hold_symbol_position"] = str(event.value)
    if event.select.id == "symbol-legend-flow" and event.value is not SkimSelect.BLANK:
        style.setdefault("legend_tables", {}).setdefault("symbols", {})["flow"] = str(
            event.value
        )