TUI Common Components

Overview

Shared TUI Components - Common UI widgets and services for Textual-based TUIs.

This package provides reusable components for building Terminal User Interfaces using the Textual framework. It is designed to be shared across multiple TUI applications within the pipeworks_name_generation project.

Design Philosophy:

  • Decoupled: Widgets communicate via Textual Messages, not direct state access

  • Configurable: Validators and callbacks allow domain-specific customization

  • Consistent: Common styling patterns, keybindings, and interaction models

  • Tested: All components have comprehensive test coverage

Package Structure:

tui_common/
├── __init__.py           # This file - package entry point
├── cli_utils.py          # Shared CLI utilities (tab completion, file discovery)
├── controls/             # Reusable UI widgets
│   ├── sliders.py        # FloatSlider - float parameter control
│   ├── spinners.py       # IntSpinner - integer parameter control
│   ├── inputs.py         # SeedInput, RadioOption - input widgets
│   └── browsers.py       # DirectoryBrowserScreen - file browser modal
└── services/             # Backend services
    └── config.py         # KeybindingConfig - keybinding management

Usage Example:

from build_tools.tui_common.controls import (
    FloatSlider,
    IntSpinner,
    SeedInput,
    RadioOption,
    DirectoryBrowserScreen,
)
from build_tools.tui_common.services import KeybindingConfig, load_keybindings

# In your Textual App
class MyApp(App):
    def compose(self) -> ComposeResult:
        yield IntSpinner("Count", value=5, min_val=1, max_val=100, id="count")
        yield FloatSlider("Temperature", value=0.5, min_val=0.0, max_val=1.0, id="temp")

    @on(IntSpinner.Changed)
    def on_spinner_changed(self, event: IntSpinner.Changed) -> None:
        self.notify(f"Spinner {event.widget_id} changed to {event.value}")

Message-Based Communication:

All widgets post Messages when their values change:

  • IntSpinner.Changed(value: int, widget_id: str | None)

  • FloatSlider.Changed(value: float, widget_id: str | None)

  • SeedInput.Changed(value: int, widget_id: str | None)

  • RadioOption.Selected(option_name: str, widget_id: str | None)

Keybinding Patterns:

Widgets define widget-level bindings that don’t interfere with app-level navigation:

  • +/- or j/k: Increment/decrement values

  • Enter/Space: Activate/select options

  • h/j/k/l: Vim-style navigation in browsers

App-level bindings (q for quit, number keys for tabs, etc.) remain unaffected when child widgets have focus.

The TUI Common package provides shared components for building Terminal User Interfaces using the Textual framework. These components are designed to be reused across multiple TUI applications in the project.

Key Features:

  • Reusable Controls: Spinners, sliders, seed inputs, radio buttons, and vim-enabled dropdowns

  • Directory Browser: Configurable modal with custom validation callbacks

  • Keybinding Config: TOML-based configuration management with conflict detection

  • Consistent Patterns: Message-based communication, focus management, vim-style navigation

Design Philosophy:

  • Components are decoupled from specific use cases via callbacks and validators

  • Widgets communicate via Textual Messages, not direct state manipulation

  • CSS styling uses Textual theme variables for consistent appearance

  • Widget-level keybindings don’t conflict with app-level navigation

Controls

IntSpinner

Integer parameter adjustment widget with keyboard navigation.

Features:

  • Keyboard: +/- or j/k to increment/decrement

  • Configurable min/max/step

  • Optional dynamic suffix function

  • Posts IntSpinner.Changed message

from build_tools.tui_common.controls import IntSpinner

yield IntSpinner(
    "Walk Steps",
    value=5,
    min_val=0,
    max_val=20,
    suffix_fn=lambda v: f"-> {v + 1} syllables",
    id="steps-spinner",
)

FloatSlider

Float parameter adjustment widget with precision control.

Features:

  • Keyboard: +/- or j/k to adjust

  • Configurable precision (decimal places)

  • Optional static suffix text

  • Posts FloatSlider.Changed message

from build_tools.tui_common.controls import FloatSlider

yield FloatSlider(
    "Temperature",
    value=0.5,
    min_val=0.0,
    max_val=1.0,
    step=0.1,
    precision=2,
    suffix="bias",
    id="temp-slider",
)

SeedInput

Random seed input with two-box design showing input and actual seed used.

Features:

  • Two-box display: input field and “Using:” display

  • Random mode (-1 or empty) auto-generates seed

  • r key to reset to random

  • Posts SeedInput.Changed message

from build_tools.tui_common.controls import SeedInput

yield SeedInput(id="seed-input")  # Starts in random mode

RadioOption

Radio button style option widget for exclusive selection groups.

Features:

  • Checkbox display: [x] selected, [ ] unselected

  • Enter/Space to select

  • Rich text rendering with color feedback

  • Posts RadioOption.Selected message

from build_tools.tui_common.controls import RadioOption

yield RadioOption("fast", "Quick processing", is_selected=True, id="opt-fast")
yield RadioOption("thorough", "Deep analysis", id="opt-thorough")

# Handle selection in app
@on(RadioOption.Selected)
def on_option_selected(self, event: RadioOption.Selected) -> None:
    for opt in self.query(RadioOption):
        opt.set_selected(opt.option_name == event.option_name)

JKSelect

Dropdown select widget with vim-style j/k navigation support.

Features:

  • Extends Textual’s Select widget

  • j/k keys navigate down/up in the dropdown (in addition to arrow keys)

  • Type-to-search still works for other letters

  • Drop-in replacement for standard Select

from build_tools.tui_common.controls import JKSelect

yield JKSelect(
    [
        ("First Name", "first_name"),
        ("Last Name", "last_name"),
        ("Place Name", "place_name"),
    ],
    value="first_name",
    id="name-class-select",
)

Usage Notes:

  • When the dropdown is open, press j to move down and k to move up

  • Arrow keys continue to work as expected

  • Type any other letter to jump to options starting with that letter

  • Press Enter to select the highlighted option

DirectoryBrowserScreen

Modal directory browser with customizable validation.

Features:

  • Textual DirectoryTree for file system navigation

  • Vim-style keybindings (h/j/k/l, Space, Enter, Esc)

  • Custom validator callback for domain-specific validation

  • Visual feedback for valid/invalid selections

  • Configurable tree root (root_dir) to allow navigation to parent directories

from build_tools.tui_common.controls import DirectoryBrowserScreen

# Custom validator function
def validate_source_dir(path: Path) -> tuple[bool, str, str]:
    txt_files = list(path.glob("*.txt"))
    if txt_files:
        return (True, "source", f"Found {len(txt_files)} files")
    return (False, "", "No .txt files found")

# Use in async action
@work
async def action_select_source(self) -> None:
    result = await self.push_screen_wait(
        DirectoryBrowserScreen(
            title="Select Source Directory",
            validator=validate_source_dir,
            initial_dir=Path("/some/deep/path"),
            root_dir=Path.home(),  # Allows navigating up to home
        )
    )
    if result:
        self.load_source(result)

Services

KeybindingConfig

Keybinding configuration dataclass with context-based organization.

Contexts:

  • global: Application-wide actions (quit, help)

  • tabs: Tab/screen switching

  • navigation: Movement (up, down, left, right)

  • controls: Widget manipulation (activate, increment)

  • actions: Domain-specific operations

from build_tools.tui_common.services import KeybindingConfig, load_keybindings

# Load with defaults
config = load_keybindings()

# Get primary key for display
quit_key = config.get_primary_key("global", "quit")  # "q"
quit_display = config.get_display_key("global", "quit")  # "q"

TOML Configuration:

# ~/.config/pipeworks_tui/keybindings.toml
[keybindings.global]
quit = ["q", "ctrl+q"]
help = ["?", "f1"]

[keybindings.navigation]
up = ["k", "up"]
down = ["j", "down"]

Integration Guide

Using in New TUIs

To use tui_common in a new TUI application:

from textual.app import App, ComposeResult
from textual import on

from build_tools.tui_common.controls import (
    IntSpinner,
    FloatSlider,
    JKSelect,
    RadioOption,
    DirectoryBrowserScreen,
)

class MyApp(App):
    def compose(self) -> ComposeResult:
        yield IntSpinner("Count", value=5, min_val=1, max_val=10, id="count")
        yield FloatSlider("Weight", value=0.5, min_val=0.0, max_val=1.0, id="weight")
        yield JKSelect([("A", "a"), ("B", "b")], value="a", id="choice")

    @on(IntSpinner.Changed)
    def on_spinner_changed(self, event: IntSpinner.Changed) -> None:
        # Handle value changes
        self.state.count = event.value

Extending DirectoryBrowserScreen

Create domain-specific browser subclasses:

from build_tools.tui_common.controls import DirectoryBrowserScreen

class CorpusBrowserScreen(DirectoryBrowserScreen):
    \"\"\"Browser pre-configured for corpus selection.\"\"\"

    # CSS must be redeclared for subclass (Textual CSS is class-specific)
    CSS = \"\"\"
    CorpusBrowserScreen {
        align: center middle;
    }
    # ... rest of CSS
    \"\"\"

    def __init__(self, initial_dir: Path | None = None) -> None:
        super().__init__(
            title="Select Corpus Directory",
            validator=validate_corpus_directory,
            initial_dir=initial_dir,
        )

Important: Textual CSS selectors are class-name specific. Subclasses must redeclare CSS with their own class name for proper styling.

Notes

Dependencies:

Requires Textual library:

pip install -e ".[dev]"

Python Version:

Requires Python 3.12+ for type hints.

Related Documentation:

API Reference

Controls

Shared UI Controls for Textual-based TUIs.

This module provides reusable UI widgets that can be used across multiple TUI applications. All widgets follow consistent patterns:

Communication Pattern:

Each widget posts a custom Message when its value changes:

@on(IntSpinner.Changed)
def handle_spinner(self, event: IntSpinner.Changed) -> None:
    print(f"Widget {event.widget_id} changed to {event.value}")

Focus Pattern:

Widgets are focusable for keyboard navigation but use widget-level bindings that don’t conflict with app-level bindings.

Available Widgets:

class build_tools.tui_common.controls.DirectoryBrowserScreen(title='Select Directory', validator=None, initial_dir=None, help_text=None, root_dir=None)[source]

Bases: ModalScreen[Path | None]

Modal screen for browsing and selecting a directory.

A modal dialog displaying a directory tree for navigation with customizable validation. Returns the selected directory path when user confirms, or None if cancelled.

Validation:

Provide a validator callable that takes a Path and returns a tuple of (is_valid, type_label, message):

  • is_valid: True if the directory can be selected

  • type_label: Short label for valid directories (e.g., “corpus”)

  • message: Error message if invalid, or description if valid

Navigation:

  • j / down: Move cursor down

  • k / up: Move cursor up

  • h / left: Collapse directory

  • l / right: Expand directory

  • > (Shift+.): Toggle hidden files

browser_title

Header text displayed at top of modal

validator

Callback function to validate selected directories

initial_dir

Starting directory for the browser

selected_path

Currently selected path (or None)

Returns:

Selected Path when “Select” is pressed, or None if cancelled

Return type:

None

Example

result = await self.app.push_screen_wait(
    DirectoryBrowserScreen(
        title="Select Corpus Directory",
        validator=validate_corpus_directory,
        initial_dir=Path.home() / "corpora",
    )
)
if result:
    self.load_corpus(result)
BINDINGS: ClassVar[list[BindingType]] = [('j', 'cursor_down', 'Down'), ('k', 'cursor_up', 'Up'), ('h', 'cursor_left', 'Collapse'), ('l', 'cursor_right', 'Expand'), ('space', 'toggle_node', 'Toggle'), ('enter', 'select_node', 'Select'), ('escape', 'cancel', 'Cancel'), ('greater_than_sign', 'toggle_hidden', 'Toggle hidden')]

A list of key bindings.

CSS: ClassVar[str] = '\n    DirectoryBrowserScreen {\n        align: center middle;\n    }\n\n    #browser-container {\n        width: 80;\n        height: 30;\n        background: $panel;\n        border: thick $primary;\n        padding: 1;\n    }\n\n    #browser-header {\n        text-align: center;\n        text-style: bold;\n        color: $accent;\n        margin-bottom: 1;\n    }\n\n    #directory-tree {\n        width: 100%;\n        height: 1fr;\n        border: solid $primary;\n        margin-bottom: 1;\n    }\n\n    #help-text {\n        height: 2;\n        width: 100%;\n        color: $text-muted;\n        text-align: center;\n        margin-bottom: 1;\n    }\n\n    #validation-status {\n        height: 3;\n        width: 100%;\n        border: solid $primary;\n        padding: 0 1;\n        margin-bottom: 1;\n    }\n\n    .status-valid {\n        color: $success;\n    }\n\n    .status-invalid {\n        color: $error;\n    }\n\n    .status-none {\n        color: $text-muted;\n    }\n\n    #button-bar {\n        width: 100%;\n        height: auto;\n        align: center middle;\n    }\n\n    #button-bar Button {\n        margin: 0 1;\n    }\n    '

Inline CSS, useful for quick scripts. Rules here take priority over CSS_PATH.

Note

This CSS applies to the whole app.

__init__(title='Select Directory', validator=None, initial_dir=None, help_text=None, root_dir=None)[source]

Initialize directory browser.

Parameters:
  • title (str) – Header text displayed at top of modal

  • validator (Callable[[Path], tuple[bool, str, str]] | None) – Callback function to validate directories. Signature: (Path) -> (is_valid, type_label, message) If None, uses default_validator which accepts any directory.

  • initial_dir (Path | None) – Starting directory for browser (defaults to home directory)

  • help_text (str | None) – Custom help text to display. If None, uses default help text.

  • root_dir (Path | None) – Root directory for the tree. If None, uses home directory. Set this higher than initial_dir to allow navigating up.

action_cancel()[source]

Cancel and close the dialog (escape key).

Return type:

None

action_cursor_down()[source]

Move cursor down in directory tree (j key).

Return type:

None

action_cursor_left()[source]

Collapse directory in tree (h key).

Collapses the current directory node if expanded.

Return type:

None

action_cursor_right()[source]

Expand directory in tree (l key).

Expands the current directory node if collapsed.

Return type:

None

action_cursor_up()[source]

Move cursor up in directory tree (k key).

Return type:

None

action_select_node()[source]

Select the current node (enter key).

If the current node is a directory, validates it. If already valid, confirms the selection.

Return type:

None

action_toggle_hidden()[source]

Toggle visibility of hidden files (> key).

Return type:

None

action_toggle_node()[source]

Toggle expand/collapse of current node (space key).

Expands collapsed nodes, collapses expanded nodes.

Return type:

None

can_focus: bool = False

Widget may receive focus.

can_focus_children: bool = True

Widget’s children may receive focus.

cancel_pressed()[source]

Handle Cancel button press.

Dismisses the modal with None.

Return type:

None

compose()[source]

Create browser UI layout.

Layout: - Header with title - Help text - Directory tree (expandable/collapsible) - Validation status display - Select/Cancel buttons

Yields:

Composed widget tree for the modal

Return type:

Iterable[Widget]

directory_selected(event)[source]

Handle directory selection in tree.

Validates the selected directory using the configured validator and updates the UI accordingly. Triggered when user clicks on a directory NAME (not the expand arrow).

Parameters:

event (DirectorySelected) – Directory selection event from DirectoryTree

Return type:

None

file_selected(event)[source]

Handle file selection in tree.

When a file is clicked, we don’t clear the current selection since the user may have already validated the parent directory by expanding into it. Instead, we provide a gentle reminder that the parent directory is what will be selected.

Parameters:

event (FileSelected) – File selection event from DirectoryTree

Return type:

None

node_expanded(event)[source]

Handle directory expansion in tree.

When a user expands a directory (via arrow click or ‘l’ key), validate it and allow selection if valid. This improves UX by letting users select a directory after navigating into it, without needing to click on the directory name again.

Note: We use Tree.NodeExpanded because DirectoryTree inherits from Tree and doesn’t define its own NodeExpanded message class.

Parameters:

event (NodeExpanded) – Node expanded event from Tree (parent class of DirectoryTree)

Return type:

None

async on_mount()[source]

Handle screen mount event.

If initial_dir differs from root_dir, attempt to expand the tree to show and select initial_dir after a brief delay to let the tree load its initial content.

Return type:

None

select_pressed()[source]

Handle Select button press.

Dismisses the modal with the selected path.

Return type:

None

class build_tools.tui_common.controls.FloatSlider(label, value, min_val, max_val, step=0.1, precision=1, suffix=None, *args, **kwargs)[source]

Bases: Static

Float slider widget with keyboard and mouse support.

A horizontal widget displaying a label, current value in brackets, and optional suffix. Users can adjust the value using keyboard shortcuts while the widget has focus.

label_text

Display label shown before the value

value

Current float value (clamped to min/max range)

min_val

Minimum allowed value

max_val

Maximum allowed value

step

Amount to increment/decrement per keypress

precision

Number of decimal places to display

suffix

Optional text shown after the value

Keybindings:
  • + or = or j or down: Increment value by step

  • - or _ or k or up: Decrement value by step

Messages:
  • Changed: Posted when value changes, includes new value and widget ID

CSS Classes:
  • .slider-label: Label element (width: 15, right-aligned)

  • .slider-value: Value display (width: 8, centered, highlighted on focus)

  • .slider-suffix: Suffix text (auto width, muted color)

BINDINGS: ClassVar[list[BindingType]] = [('+', 'increment', 'Increment'), ('=', 'increment', 'Increment'), ('j', 'increment', 'Increment'), ('down', 'increment', 'Increment'), ('-', 'decrement', 'Decrement'), ('_', 'decrement', 'Decrement'), ('k', 'decrement', 'Decrement'), ('up', 'decrement', 'Decrement')]

A list of key bindings.

class Changed(value, widget_id)[source]

Bases: Message

Message posted when the slider value changes.

value

The new float value after the change

widget_id

The ID of the widget that posted this message, or None

__init__(value, widget_id)[source]

Initialize the Changed message.

Parameters:
  • value (float) – The new slider value

  • widget_id (str | None) – ID of the slider widget that changed

bubble: ClassVar[bool] = True
handler_name: ClassVar[str] = 'on_float_slider_changed'

Name of the default message handler.

no_dispatch: ClassVar[bool] = False
time
verbose: ClassVar[bool] = False
DEFAULT_CSS: ClassVar[str] = '\n    FloatSlider {\n        layout: horizontal;\n        height: 1;\n        width: 100%;\n    }\n\n    FloatSlider .slider-label {\n        width: 15;\n        text-align: right;\n        padding-right: 1;\n    }\n\n    FloatSlider .slider-value {\n        width: 8;\n        text-align: center;\n        background: $boost;\n    }\n\n    FloatSlider:focus .slider-value {\n        background: $accent;\n        text-style: bold;\n    }\n\n    FloatSlider .slider-suffix {\n        width: auto;\n        padding-left: 1;\n        color: $text-muted;\n    }\n    '

Default TCSS.

__init__(label, value, min_val, max_val, step=0.1, precision=1, suffix=None, *args, **kwargs)[source]

Initialize float slider.

Parameters:
  • label (str) – Display label shown before the value

  • value (float) – Initial value (will be clamped to min/max range)

  • min_val (float) – Minimum allowed value

  • max_val (float) – Maximum allowed value

  • step (float) – Increment/decrement step size (default: 0.1)

  • precision (int) – Number of decimal places to display (default: 1)

  • suffix (str | None) – Optional static suffix text to display after value

  • *args – Additional positional arguments passed to Static

  • **kwargs – Additional keyword arguments passed to Static

action_decrement()[source]

Decrement value by step, clamped to min.

Only posts Changed message if value actually changed.

Return type:

None

action_increment()[source]

Increment value by step, clamped to max.

Only posts Changed message if value actually changed.

Return type:

None

can_focus: bool = False

Widget may receive focus.

can_focus_children: bool = True

Widget’s children may receive focus.

compose()[source]

Create the slider layout.

Layout: [Label:] [value] [suffix]

Yields:

Label widgets for label, value display, and suffix

Return type:

Iterable[Widget]

on_mount()[source]

Configure widget after mounting.

Makes the widget focusable for keyboard navigation. Focus state is used to highlight the value display.

Return type:

None

set_value(value)[source]

Set value programmatically, clamped to range.

Use this method to update the slider value from code. Posts Changed message if value actually changed.

Parameters:

value (float) – New value (will be clamped to min/max range)

Return type:

None

class build_tools.tui_common.controls.IntSpinner(label, value, min_val, max_val, step=1, suffix_fn=None, *args, **kwargs)[source]

Bases: Static

Integer spinner widget with increment/decrement support.

A horizontal widget displaying a label, current value in brackets, and optional dynamic suffix. Users can adjust the value using keyboard shortcuts while the widget has focus.

label_text

Display label shown before the value

value

Current integer value (clamped to min/max range)

min_val

Minimum allowed value

max_val

Maximum allowed value

step

Amount to increment/decrement per keypress

suffix_fn

Optional callable that generates suffix text from current value

Keybindings:
  • + or = or j or down: Increment value by step

  • - or _ or k or up: Decrement value by step

Messages:
  • Changed: Posted when value changes, includes new value and widget ID

CSS Classes:
  • .spinner-label: Label element (width: 15, right-aligned)

  • .spinner-value: Value display (width: 6, centered, highlighted on focus)

  • .spinner-suffix: Suffix text (auto width, muted color)

BINDINGS: ClassVar[list[BindingType]] = [('+', 'increment', 'Increment'), ('=', 'increment', 'Increment'), ('j', 'increment', 'Increment'), ('down', 'increment', 'Increment'), ('-', 'decrement', 'Decrement'), ('_', 'decrement', 'Decrement'), ('k', 'decrement', 'Decrement'), ('up', 'decrement', 'Decrement')]

A list of key bindings.

class Changed(value, widget_id)[source]

Bases: Message

Message posted when the spinner value changes.

value

The new integer value after the change

widget_id

The ID of the widget that posted this message, or None

__init__(value, widget_id)[source]

Initialize the Changed message.

Parameters:
  • value (int) – The new spinner value

  • widget_id (str | None) – ID of the spinner widget that changed

bubble: ClassVar[bool] = True
handler_name: ClassVar[str] = 'on_int_spinner_changed'

Name of the default message handler.

no_dispatch: ClassVar[bool] = False
time
verbose: ClassVar[bool] = False
DEFAULT_CSS: ClassVar[str] = '\n    IntSpinner {\n        layout: horizontal;\n        height: 1;\n        width: 100%;\n    }\n\n    IntSpinner .spinner-label {\n        width: 15;\n        text-align: right;\n        padding-right: 1;\n    }\n\n    IntSpinner .spinner-value {\n        width: 6;\n        text-align: center;\n        background: $boost;\n    }\n\n    IntSpinner:focus .spinner-value {\n        background: $accent;\n        text-style: bold;\n    }\n\n    IntSpinner .spinner-suffix {\n        width: auto;\n        padding-left: 1;\n        color: $text-muted;\n    }\n    '

Default TCSS.

__init__(label, value, min_val, max_val, step=1, suffix_fn=None, *args, **kwargs)[source]

Initialize integer spinner.

Parameters:
  • label (str) – Display label shown before the value

  • value (int) – Initial value (will be clamped to min/max range)

  • min_val (int) – Minimum allowed value

  • max_val (int) – Maximum allowed value

  • step (int) – Increment/decrement step size (default: 1)

  • suffix_fn (Callable[[int], str] | None) – Optional callback to generate suffix text from current value. Called with current value, returns string to display. Example: lambda v: f"-> {v + 1} items"

  • *args – Additional positional arguments passed to Static

  • **kwargs – Additional keyword arguments passed to Static

action_decrement()[source]

Decrement value by step, clamped to min.

Only posts Changed message if value actually changed. Updates both value display and suffix if present.

Return type:

None

action_increment()[source]

Increment value by step, clamped to max.

Only posts Changed message if value actually changed. Updates both value display and suffix if present.

Return type:

None

can_focus: bool = False

Widget may receive focus.

can_focus_children: bool = True

Widget’s children may receive focus.

compose()[source]

Create the spinner layout.

Layout: [Label:] [value] [suffix]

Yields:

Label widgets for label, value display, and optional suffix

Return type:

Iterable[Widget]

on_mount()[source]

Configure widget after mounting.

Makes the widget focusable for keyboard navigation. Focus state is used to highlight the value display.

Return type:

None

set_value(value)[source]

Set value programmatically, clamped to range.

Use this method to update the spinner value from code. Posts Changed message if value actually changed.

Parameters:

value (int) – New value (will be clamped to min/max range)

Return type:

None

class build_tools.tui_common.controls.JKSelect(options, *, prompt='Select', allow_blank=True, value=Select.NULL, type_to_search=True, name=None, id=None, classes=None, disabled=False, tooltip=None, compact=False)[source]

Bases: Select

Select widget with vim-style j/k navigation support.

Extends the standard Textual Select widget to respond to j/k keys in addition to the standard up/down arrow keys when the dropdown is open.

Usage is identical to the standard Select widget:

yield JKSelect(
    [("Option 1", "opt1"), ("Option 2", "opt2")],
    value="opt1",
    id="my-select",
)
can_focus: bool = True

Widget may receive focus.

can_focus_children: bool = True

Widget’s children may receive focus.

compose()[source]

Compose Select with JKSelectOverlay for j/k navigation.

Return type:

Iterable[Widget]

class build_tools.tui_common.controls.RadioOption(option_name, description, is_selected=False, *args, **kwargs)[source]

Bases: Static

Radio button style option widget.

Displays as a checkbox-style option that can be selected with keyboard or mouse. Used in groups where only one option should be selected at a time (managed by parent).

Display Format:

  • Selected: [x] Label: Description (green, bold)

  • Unselected: [ ] Label: Description (muted)

option_name

Internal name for this option (e.g., “fast”, “thorough”)

description

Brief description shown after the label

is_selected

Whether this option is currently selected

Keybindings:
  • Enter or Space: Select this option

Messages:
  • Selected: Posted when this option is selected (only if not already selected)

CSS Classes:
  • Default styles handle hover and focus states

  • .profile-selected / .profile-unselected: Selection state classes

Note

This widget only posts a message when selected. The parent is responsible for deselecting other options in the group by calling set_selected() on each.

BINDINGS: ClassVar[list[BindingType]] = [('enter', 'select', 'Select Option'), ('space', 'select', 'Select Option')]

A list of key bindings.

DEFAULT_CSS: ClassVar[str] = '\n    RadioOption {\n        height: 1;\n        width: 100%;\n    }\n\n    RadioOption:hover {\n        background: $boost;\n    }\n\n    RadioOption:focus {\n        background: $accent;\n    }\n\n    .profile-selected {\n        color: $success;\n        text-style: bold;\n    }\n\n    .profile-unselected {\n        color: $text-muted;\n    }\n    '

Default TCSS.

class Selected(option_name, widget_id)[source]

Bases: Message

Message posted when this option is selected.

Only posted if the option was not already selected.

option_name

The name of the selected option

widget_id

The ID of the widget that posted this message, or None

profile_name

Alias for option_name (backward compatibility)

__init__(option_name, widget_id)[source]

Initialize the Selected message.

Parameters:
  • option_name (str) – Name of the option that was selected

  • widget_id (str | None) – ID of the radio option widget

bubble: ClassVar[bool] = True
handler_name: ClassVar[str] = 'on_radio_option_selected'

Name of the default message handler.

no_dispatch: ClassVar[bool] = False
property profile_name: str

Alias for option_name (backward compatibility with ProfileOption).

time
verbose: ClassVar[bool] = False
__init__(option_name, description, is_selected=False, *args, **kwargs)[source]

Initialize radio option.

Parameters:
  • option_name (str) – Internal name for this option (e.g., “fast”)

  • description (str) – Brief description to show after the label

  • is_selected (bool) – Whether this option starts selected

  • *args – Additional positional arguments passed to Static

  • **kwargs – Additional keyword arguments passed to Static

action_select()[source]

Select this option (Enter/Space).

Only posts Selected message if not already selected. Does not blur focus - keeps focus on selected option like standard radio button behavior.

Return type:

None

can_focus: bool = False

Widget may receive focus.

can_focus_children: bool = True

Widget’s children may receive focus.

on_click()[source]

Handle click on this option.

Same behavior as action_select - posts message if not selected.

Return type:

None

on_mount()[source]

Configure widget after mounting.

Makes the widget focusable for keyboard navigation.

Return type:

None

render()[source]

Render the option as text with Rich markup.

Returns:

Rich Text object with checkbox, label, and description

set_selected(selected)[source]

Update selection state and refresh display.

Called by parent to manage radio group state. Does not post any message.

Parameters:

selected (bool) – Whether this option should be selected

Return type:

None

class build_tools.tui_common.controls.SeedInput(value=None, *args, **kwargs)[source]

Bases: Static

Seed input widget with two-box design.

Displays an input field for entering a seed value and a display showing the actual seed being used. Supports random mode when input is empty or “-1”.

Layout:

[Seed:] [Input Field] [->] [Actual Seed Used]

Modes:

  • Manual mode: Enter a specific integer seed value

  • Random mode: Enter “-1” or leave empty to auto-generate

value

Current seed value being used (always a valid integer)

user_input

User’s raw input (-1 means random mode)

Keybindings:
  • r: Set to random mode (-1)

Messages:
  • Changed: Posted when seed changes, includes actual seed value

CSS Classes:
  • .seed-label: “Seed:” label (width: 15)

  • Input: Text input field (width: 13)

  • .arrow: Arrow indicator “->” (width: 3)

  • .seed-used-value: Actual seed display (width: 12, muted)

BINDINGS: ClassVar[list[BindingType]] = [('r', 'random', 'Random')]

A list of key bindings.

class Changed(value, widget_id)[source]

Bases: Message

Message posted when the seed value changes.

value

The actual seed value being used (always valid integer)

widget_id

The ID of the widget that posted this message, or None

__init__(value, widget_id)[source]

Initialize the Changed message.

Parameters:
  • value (int) – The actual seed value

  • widget_id (str | None) – ID of the seed input widget that changed

bubble: ClassVar[bool] = True
handler_name: ClassVar[str] = 'on_seed_input_changed'

Name of the default message handler.

no_dispatch: ClassVar[bool] = False
time
verbose: ClassVar[bool] = False
DEFAULT_CSS: ClassVar[str] = '\n    SeedInput {\n        layout: horizontal;\n        height: 3;\n        width: 100%;\n    }\n\n    SeedInput .seed-label {\n        width: 15;\n        text-align: right;\n        padding-right: 1;\n        height: 3;\n        content-align: center middle;\n    }\n\n    SeedInput Input {\n        width: 13;\n        height: 3;\n        border: solid $primary;\n    }\n\n    SeedInput .arrow {\n        width: 3;\n        text-align: center;\n        height: 3;\n        content-align: center middle;\n    }\n\n    SeedInput .seed-used-value {\n        width: 12;\n        height: 3;\n        text-align: left;\n        content-align: center middle;\n        color: $text-muted;\n        background: $boost;\n        padding-left: 1;\n    }\n    '

Default TCSS.

__init__(value=None, *args, **kwargs)[source]

Initialize seed input with two-box design.

Parameters:
  • value (int | None) – Initial seed value. If None, generates a random seed using SystemRandom for cryptographic randomness.

  • *args – Additional positional arguments passed to Static

  • **kwargs – Additional keyword arguments passed to Static

action_random()[source]

Set input to random mode (-1).

Bound to ‘r’ key. Generates a new random seed.

Return type:

None

can_focus: bool = False

Widget may receive focus.

can_focus_children: bool = True

Widget’s children may receive focus.

compose()[source]

Create two-box seed input in single horizontal row.

Layout: [Label] [Input] [Arrow] [Actual Value]

Yields:

Label and Input widgets for the two-box design

Return type:

Iterable[Widget]

on_input_changed(event)[source]

Handle user typing in the seed input.

Parameters:

event (Changed) – Input change event from Textual

Return type:

None

Services

Shared Services for Textual-based TUIs.

This module provides backend services for configuration management, keybinding loading, and other non-UI functionality.

Available Services:

Configuration Pattern:

from build_tools.tui_common.services import load_keybindings

# Load from default location (~/.config/pipeworks_tui/keybindings.toml)
config = load_keybindings()

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

# Get display-friendly key name
quit_display = config.get_display_key("global", "quit")  # "q"
class build_tools.tui_common.services.KeybindingConfig(global_bindings=<factory>, tab_bindings=<factory>, navigation_bindings=<factory>, control_bindings=<factory>, action_bindings=<factory>)[source]

Bases: object

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)

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

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"
action_bindings: dict[str, list[str]]
control_bindings: dict[str, list[str]]
classmethod default()[source]

Create default keybinding configuration.

Provides sensible defaults for common TUI actions including vim-style navigation and standard keyboard shortcuts.

Return type:

KeybindingConfig

Returns:

KeybindingConfig with default bindings

get_display_key(context, action)[source]

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 “?”.

Parameters:
  • context (str) – Keybinding context

  • action (str) – Action name

Return type:

str

Returns:

Display-friendly key name, or “?” if not found

get_primary_key(context, action)[source]

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.

Parameters:
  • context (str) – Keybinding context (“global”, “tabs”, “navigation”, etc.)

  • action (str) – Action name (e.g., “quit”, “up”, “activate”)

Return type:

str | None

Returns:

Primary key string, or None if not found

global_bindings: dict[str, list[str]]
navigation_bindings: dict[str, list[str]]
tab_bindings: dict[str, list[str]]
build_tools.tui_common.services.detect_conflicts(config)[source]

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).

Parameters:

config (KeybindingConfig) – Keybinding configuration to validate

Return type:

list[str]

Returns:

List of conflict warning messages (empty if no conflicts)

Example

config = load_keybindings()
conflicts = detect_conflicts(config)
for conflict in conflicts:
    print(f"Warning: {conflict}")
build_tools.tui_common.services.load_config_file(config_path=None)[source]

Load keybinding configuration from TOML file.

Parameters:

config_path (Path | None) – Path to config file, or None to use default location (~/.config/pipeworks_tui/keybindings.toml)

Return type:

dict[str, Any] | None

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.

build_tools.tui_common.services.load_keybindings(config_path=None)[source]

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.

Parameters:

config_path (Path | None) – Optional path to config file. If None, uses default location (~/.config/pipeworks_tui/keybindings.toml)

Return type:

KeybindingConfig

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

build_tools.tui_common.services.merge_config(defaults, user_config)[source]

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.

Parameters:
  • defaults (KeybindingConfig) – Default keybinding configuration

  • user_config (dict[str, Any]) – User-provided configuration from TOML file

Return type:

KeybindingConfig

Returns:

Merged configuration (user overrides defaults)

Example

defaults = KeybindingConfig.default()
user_config = {"keybindings": {"global": {"quit": ["x"]}}}
merged = merge_config(defaults, user_config)
# merged.global_bindings["quit"] == ["x"]
# Other bindings retain defaults