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:
+/-orj/k: Increment/decrement valuesEnter/Space: Activate/select optionsh/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:
+/-orj/kto increment/decrementConfigurable min/max/step
Optional dynamic suffix function
Posts
IntSpinner.Changedmessage
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:
+/-orj/kto adjustConfigurable precision (decimal places)
Optional static suffix text
Posts
FloatSlider.Changedmessage
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 (
-1or empty) auto-generates seedrkey to reset to randomPosts
SeedInput.Changedmessage
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,[ ]unselectedEnter/Spaceto selectRich text rendering with color feedback
Posts
RadioOption.Selectedmessage
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
Selectwidgetj/kkeys 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
jto move down andkto move upArrow keys continue to work as expected
Type any other letter to jump to options starting with that letter
Press
Enterto 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 switchingnavigation: 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:
Syllable Walker TUI - Uses tui_common for corpus browsing and controls
Pipeline TUI - Uses tui_common for directory selection and configuration
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:
FloatSlider- Float parameter adjustment with precision controlIntSpinner- Integer parameter adjustment with optional suffixSeedInput- Random seed input with two-box designRadioOption- Radio button style option selectionDirectoryBrowserScreen- Modal directory browser with validationJKSelect- Dropdown select with vim-style j/k navigation
- 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
validatorcallable that takes a Path and returns a tuple of(is_valid, type_label, message):is_valid: True if the directory can be selectedtype_label: Short label for valid directories (e.g., “corpus”)message: Error message if invalid, or description if valid
Navigation:
j/down: Move cursor downk/up: Move cursor uph/left: Collapse directoryl/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 modalvalidator (
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_cursor_left()[source]
Collapse directory in tree (h key).
Collapses the current directory node if expanded.
- Return type:
- action_cursor_right()[source]
Expand directory in tree (l key).
Expands the current directory node if collapsed.
- Return type:
- 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:
Toggle visibility of hidden files (> key).
- Return type:
- action_toggle_node()[source]
Toggle expand/collapse of current node (space key).
Expands collapsed nodes, collapses expanded nodes.
- Return type:
- can_focus: bool = False
Widget may receive focus.
- can_focus_children: bool = True
Widget’s children may receive focus.
- 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:
- 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:
- 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:
- class build_tools.tui_common.controls.FloatSlider(label, value, min_val, max_val, step=0.1, precision=1, suffix=None, *args, **kwargs)[source]
Bases:
StaticFloat 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=orjordown: Increment value by step-or_orkorup: 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:
MessageMessage 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
- 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 valuevalue (
float) – Initial value (will be clamped to min/max range)min_val (
float) – Minimum allowed valuemax_val (
float) – Maximum allowed valuestep (
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:
- action_increment()[source]
Increment value by step, clamped to max.
Only posts Changed message if value actually changed.
- Return type:
- 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]
- class build_tools.tui_common.controls.IntSpinner(label, value, min_val, max_val, step=1, suffix_fn=None, *args, **kwargs)[source]
Bases:
StaticInteger 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=orjordown: Increment value by step-or_orkorup: 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:
MessageMessage 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
- 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 valuevalue (
int) – Initial value (will be clamped to min/max range)min_val (
int) – Minimum allowed valuemax_val (
int) – Maximum allowed valuestep (
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:
- 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:
- 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]
- 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:
SelectSelect 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.
- class build_tools.tui_common.controls.RadioOption(option_name, description, is_selected=False, *args, **kwargs)[source]
Bases:
StaticRadio 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:
EnterorSpace: 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:
MessageMessage 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)
- bubble: ClassVar[bool] = True
- handler_name: ClassVar[str] = 'on_radio_option_selected'
Name of the default message handler.
- no_dispatch: ClassVar[bool] = False
- time
- verbose: ClassVar[bool] = False
- __init__(option_name, description, is_selected=False, *args, **kwargs)[source]
Initialize radio option.
- Parameters:
- 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:
- 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:
- on_mount()[source]
Configure widget after mounting.
Makes the widget focusable for keyboard navigation.
- Return type:
- class build_tools.tui_common.controls.SeedInput(value=None, *args, **kwargs)[source]
Bases:
StaticSeed 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:
MessageMessage 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
- 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.
- action_random()[source]
Set input to random mode (-1).
Bound to ‘r’ key. Generates a new random seed.
- Return type:
- can_focus: bool = False
Widget may receive focus.
- can_focus_children: bool = True
Widget’s children may receive focus.
Services
Shared Services for Textual-based TUIs.
This module provides backend services for configuration management, keybinding loading, and other non-UI functionality.
Available Services:
KeybindingConfig- Dataclass holding keybinding configurationload_keybindings()- Load keybindings from TOML with defaultsdetect_conflicts()- Validate keybindings for conflicts
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:
objectKeybinding 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 switchingnavigation: Movement within panels/controlscontrols: 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
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"
- classmethod default()[source]
Create default keybinding configuration.
Provides sensible defaults for common TUI actions including vim-style navigation and standard keyboard shortcuts.
- Return type:
- 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 “?”.
- 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:
- 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:
- 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:
- 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 configurationuser_config (
dict[str,Any]) – User-provided configuration from TOML file
- Return type:
- 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