"""
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