Source code for build_tools.tui_common.services.config

"""
Configuration management for Textual-based TUIs.

This module handles loading and validating user-configurable keybindings
from TOML files, with fallback to sensible defaults.

**Configuration File Location:**

By default, configuration is loaded from:
``~/.config/pipeworks_tui/keybindings.toml``

**TOML Format:**

.. code-block:: toml

    [keybindings.global]
    quit = ["q", "ctrl+q"]
    help = ["?", "f1"]

    [keybindings.tabs]
    tab1 = ["1"]
    tab2 = ["2"]

    [keybindings.navigation]
    up = ["k", "up"]
    down = ["j", "down"]
    left = ["h", "left"]
    right = ["l", "right"]

**Example Usage:**

.. code-block:: python

    from build_tools.tui_common.services import load_keybindings

    # Load with defaults
    config = load_keybindings()

    # Get primary key for an action
    quit_key = config.get_primary_key("global", "quit")  # "q"

    # Get display-friendly name
    quit_display = config.get_display_key("global", "quit")  # "q"

    # Check for conflicts
    from build_tools.tui_common.services import detect_conflicts
    conflicts = detect_conflicts(config)
    if conflicts:
        for conflict in conflicts:
            print(f"Warning: {conflict}")
"""

from __future__ import annotations

import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

# -----------------------------------------------------------------------------
# TOML library handling
# Python 3.11+ has tomllib built-in, otherwise use tomli
# -----------------------------------------------------------------------------
if sys.version_info >= (3, 11):
    import tomllib as tomli

    TOMLI_AVAILABLE = True
else:
    try:
        import tomli

        TOMLI_AVAILABLE = True
    except ImportError:
        TOMLI_AVAILABLE = False
        tomli = None  # type: ignore


[docs] @dataclass class KeybindingConfig: """ Keybinding configuration for TUI applications. Organizes keybindings by context (global, tabs, navigation, etc.) where each action maps to a list of keys that can trigger it. **Contexts:** - ``global``: Application-wide actions (quit, help) - ``tabs``: Tab/screen switching - ``navigation``: Movement within panels/controls - ``controls``: Widget manipulation (increment, decrement, activate) - ``actions``: Domain-specific operations (generate, copy, paste) Attributes: global_bindings: Global actions like quit, help tab_bindings: Tab/screen switching bindings navigation_bindings: Movement bindings (up, down, left, right) control_bindings: Widget control bindings (activate, increment) action_bindings: Domain-specific action bindings Example: .. code-block:: python config = KeybindingConfig.default() # Multiple keys can trigger the same action quit_keys = config.global_bindings["quit"] # ["q", "ctrl+q"] # Get the primary (first) key for UI display primary = config.get_primary_key("global", "quit") # "q" """ global_bindings: dict[str, list[str]] = field(default_factory=dict) tab_bindings: dict[str, list[str]] = field(default_factory=dict) navigation_bindings: dict[str, list[str]] = field(default_factory=dict) control_bindings: dict[str, list[str]] = field(default_factory=dict) action_bindings: dict[str, list[str]] = field(default_factory=dict)
[docs] @classmethod def default(cls) -> KeybindingConfig: """ Create default keybinding configuration. Provides sensible defaults for common TUI actions including vim-style navigation and standard keyboard shortcuts. Returns: KeybindingConfig with default bindings """ return cls( global_bindings={ "quit": ["q", "ctrl+q"], "help": ["question_mark", "f1"], }, tab_bindings={ # Tab bindings are application-specific # Override in your app's config }, navigation_bindings={ "up": ["k", "up"], "down": ["j", "down"], "left": ["h", "left"], "right": ["l", "right"], "next_panel": ["tab", "ctrl+n"], "prev_panel": ["shift+tab", "ctrl+p"], }, control_bindings={ "activate": ["enter", "space"], "increment": ["plus", "equal", "right_square_bracket"], "decrement": ["minus", "left_square_bracket"], }, action_bindings={ # Action bindings are application-specific # Override in your app's config }, )
[docs] def get_primary_key(self, context: str, action: str) -> str | None: """ Get the primary (first) keybinding for an action. The primary key is typically used for display in the UI and is the first key in the binding list. Args: context: Keybinding context ("global", "tabs", "navigation", etc.) action: Action name (e.g., "quit", "up", "activate") Returns: Primary key string, or None if not found """ bindings_map = { "global": self.global_bindings, "tabs": self.tab_bindings, "navigation": self.navigation_bindings, "controls": self.control_bindings, "actions": self.action_bindings, } bindings = bindings_map.get(context, {}) keys = bindings.get(action, []) return keys[0] if keys else None
[docs] def get_display_key(self, context: str, action: str) -> str: """ Get human-readable key name for display in UI. Converts internal Textual key names to user-friendly display names. For example, "ctrl+q" becomes "Ctrl+Q" and "question_mark" becomes "?". Args: context: Keybinding context action: Action name Returns: Display-friendly key name, or "?" if not found """ key = self.get_primary_key(context, action) if not key: return "?" # Convert Textual key names to display-friendly names key_display_map = { "question_mark": "?", "ctrl+q": "Ctrl+Q", "ctrl+c": "Ctrl+C", "ctrl+v": "Ctrl+V", "ctrl+n": "Ctrl+N", "ctrl+p": "Ctrl+P", "f1": "F1", "f5": "F5", "enter": "Enter", "space": "Space", "tab": "Tab", "shift+tab": "Shift+Tab", "plus": "+", "equal": "=", "minus": "-", "left_square_bracket": "[", "right_square_bracket": "]", "up": "Up", "down": "Down", "left": "Left", "right": "Right", } return key_display_map.get(key, key.upper())
[docs] def detect_conflicts(config: KeybindingConfig) -> list[str]: """ Detect conflicting keybindings within each context. A conflict occurs when the same key is bound to multiple actions within the same context. Cross-context conflicts are allowed (e.g., "j" for both navigation and control increment). Args: config: Keybinding configuration to validate Returns: List of conflict warning messages (empty if no conflicts) Example: .. code-block:: python config = load_keybindings() conflicts = detect_conflicts(config) for conflict in conflicts: print(f"Warning: {conflict}") """ conflicts: list[str] = [] contexts = { "global": config.global_bindings, "tabs": config.tab_bindings, "navigation": config.navigation_bindings, "controls": config.control_bindings, "actions": config.action_bindings, } for context_name, bindings in contexts.items(): seen: dict[str, str] = {} for action, keys in bindings.items(): for key in keys: if key in seen: conflicts.append( f"Conflict in '{context_name}': key '{key}' is bound to both " f"'{action}' and '{seen[key]}'" ) else: seen[key] = action return conflicts
[docs] def load_config_file(config_path: Path | None = None) -> dict[str, Any] | None: """ Load keybinding configuration from TOML file. Args: config_path: Path to config file, or None to use default location (~/.config/pipeworks_tui/keybindings.toml) Returns: Parsed TOML configuration as dict, or None if file doesn't exist or tomli is not available Note: Requires tomli (Python <3.11) or tomllib (Python 3.11+). If neither is available, returns None and uses defaults. """ if not TOMLI_AVAILABLE: return None if config_path is None: config_dir = Path.home() / ".config" / "pipeworks_tui" config_path = config_dir / "keybindings.toml" if not config_path.exists(): return None try: with open(config_path, "rb") as f: return tomli.load(f) except Exception as e: print(f"Warning: Failed to load config from {config_path}: {e}") return None
[docs] def merge_config(defaults: KeybindingConfig, user_config: dict[str, Any]) -> KeybindingConfig: """ Merge user configuration with defaults. User configuration completely replaces default values for any context that is specified. Contexts not specified in user config retain their default values. Args: defaults: Default keybinding configuration user_config: User-provided configuration from TOML file Returns: Merged configuration (user overrides defaults) Example: .. code-block:: python defaults = KeybindingConfig.default() user_config = {"keybindings": {"global": {"quit": ["x"]}}} merged = merge_config(defaults, user_config) # merged.global_bindings["quit"] == ["x"] # Other bindings retain defaults """ # Extract keybindings section keybindings = user_config.get("keybindings", {}) # Merge each context - user config completely replaces defaults merged = KeybindingConfig( global_bindings=keybindings.get("global", defaults.global_bindings), tab_bindings=keybindings.get("tabs", defaults.tab_bindings), navigation_bindings=keybindings.get("navigation", defaults.navigation_bindings), control_bindings=keybindings.get("controls", defaults.control_bindings), action_bindings=keybindings.get("actions", defaults.action_bindings), ) return merged
[docs] def load_keybindings(config_path: Path | None = None) -> KeybindingConfig: """ Load keybindings from config file with fallback to defaults. This is the main entry point for loading keybinding configuration. It handles all the complexity of finding config files, parsing TOML, merging with defaults, and detecting conflicts. Args: config_path: Optional path to config file. If None, uses default location (~/.config/pipeworks_tui/keybindings.toml) Returns: Keybinding configuration (user config merged with defaults) Note: - If config file doesn't exist, returns defaults - If config file has errors, prints warning and returns defaults - Prints conflict warnings to stderr if detected """ defaults = KeybindingConfig.default() # Try to load user config user_config = load_config_file(config_path) if user_config is None: # No user config, use defaults config = defaults else: # Merge user config with defaults config = merge_config(defaults, user_config) # Check for conflicts and warn user conflicts = detect_conflicts(config) if conflicts: print("Warning: Keybinding conflicts detected:") for conflict in conflicts: print(f" - {conflict}") return config