Source code for build_tools.tui_common.controls.inputs
"""
Seed input and radio option widgets.
This module provides input widgets for seed/random value entry and
radio-button style option selection.
**Widgets:**
- :class:`SeedInput` - Two-box seed input with random mode support
- :class:`RadioOption` - Checkbox-style radio button for option groups
**Example Usage:**
.. code-block:: python
from build_tools.tui_common.controls import SeedInput, RadioOption
from textual import on
class MyApp(App):
def compose(self) -> ComposeResult:
# Seed input with random default
yield SeedInput(id="seed-input")
# Radio options for mode selection
yield RadioOption("fast", "Quick processing", is_selected=True, id="opt-fast")
yield RadioOption("thorough", "Deep analysis", id="opt-thorough")
@on(SeedInput.Changed)
def on_seed_changed(self, event: SeedInput.Changed) -> None:
print(f"Using seed: {event.value}")
@on(RadioOption.Selected)
def on_option_selected(self, event: RadioOption.Selected) -> None:
# Update other options to deselect them
for opt in self.query(RadioOption):
opt.set_selected(opt.option_name == event.option_name)
"""
from __future__ import annotations
import random
from typing import TYPE_CHECKING
from textual import on
from textual.message import Message
from textual.widgets import Input, Label, Static
if TYPE_CHECKING:
from textual.app import ComposeResult
[docs]
class SeedInput(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
Attributes:
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:
- :class:`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)
"""
# -------------------------------------------------------------------------
# Widget-level bindings
# -------------------------------------------------------------------------
BINDINGS = [
("r", "random", "Random"),
]
[docs]
class Changed(Message):
"""
Message posted when the seed value changes.
Attributes:
value: The actual seed value being used (always valid integer)
widget_id: The ID of the widget that posted this message, or None
"""
[docs]
def __init__(self, value: int, widget_id: str | None) -> None:
"""
Initialize the Changed message.
Args:
value: The actual seed value
widget_id: ID of the seed input widget that changed
"""
super().__init__()
self.value = value
self.widget_id = widget_id
# -------------------------------------------------------------------------
# Default styling
# -------------------------------------------------------------------------
DEFAULT_CSS = """
SeedInput {
layout: horizontal;
height: 3;
width: 100%;
}
SeedInput .seed-label {
width: 15;
text-align: right;
padding-right: 1;
height: 3;
content-align: center middle;
}
SeedInput Input {
width: 13;
height: 3;
border: solid $primary;
}
SeedInput .arrow {
width: 3;
text-align: center;
height: 3;
content-align: center middle;
}
SeedInput .seed-used-value {
width: 12;
height: 3;
text-align: left;
content-align: center middle;
color: $text-muted;
background: $boost;
padding-left: 1;
}
"""
[docs]
def __init__(self, value: int | None = None, *args, **kwargs) -> None:
"""
Initialize seed input with two-box design.
Args:
value: 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
"""
super().__init__(*args, **kwargs)
# Generate initial random seed if not provided
# Use SystemRandom for true randomness (not affected by seed state)
if value is None:
self.value = random.SystemRandom().randint(0, 2**32 - 1)
else:
self.value = value
# User input defaults to -1 (random mode)
self.user_input = -1
[docs]
def compose(self) -> ComposeResult:
"""
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
"""
yield Label("Seed:", classes="seed-label")
yield Input(
placeholder="-1 (random)",
value="-1",
id="seed-input",
)
yield Label("->", classes="arrow")
yield Label(f"{self.value}", classes="seed-used-value", id="seed-used")
[docs]
def action_random(self) -> None:
"""
Set input to random mode (-1).
Bound to 'r' key. Generates a new random seed.
"""
try:
input_widget = self.query_one("#seed-input", Input)
input_widget.value = "-1"
self._handle_input_change("-1")
except Exception: # nosec B110 - Safe widget query failure
pass
[docs]
@on(Input.Changed, "#seed-input")
def on_input_changed(self, event: Input.Changed) -> None:
"""
Handle user typing in the seed input.
Args:
event: Input change event from Textual
"""
self._handle_input_change(event.value)
def _handle_input_change(self, input_value: str) -> None:
"""
Process input value and update actual seed.
Handles three cases:
1. Empty or "-1": Random mode - generate new seed
2. Valid integer: Use that value (clamped to 32-bit range)
3. Invalid input: Ignore and keep current value
Args:
input_value: Raw string from the input field
"""
input_str = input_value.strip()
# Handle empty or -1 as random mode
if not input_str or input_str == "-1":
self.user_input = -1
# Generate new random seed using SystemRandom
self.value = random.SystemRandom().randint(0, 2**32 - 1)
else:
# Try to parse as integer
try:
manual_seed = int(input_str)
# Clamp to valid 32-bit unsigned range
manual_seed = max(0, min(manual_seed, 2**32 - 1))
self.user_input = manual_seed
self.value = manual_seed
except ValueError:
# Invalid input, ignore and keep current value
return
# Update display and post message
self._update_display()
self.post_message(self.Changed(self.value, self.id))
def _update_display(self) -> None:
"""
Update the 'Using:' display with actual seed value.
Called after seed changes to sync the UI.
"""
try:
display = self.query_one("#seed-used", Label)
display.update(f"{self.value}")
except Exception: # nosec B110 - Safe widget query failure
pass
[docs]
class RadioOption(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)
Attributes:
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:
- :class:`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 :meth:`set_selected` on each.
"""
# -------------------------------------------------------------------------
# Widget-level bindings for selection
# -------------------------------------------------------------------------
BINDINGS = [
("enter", "select", "Select Option"),
("space", "select", "Select Option"),
]
[docs]
class Selected(Message):
"""
Message posted when this option is selected.
Only posted if the option was not already selected.
Attributes:
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)
"""
[docs]
def __init__(self, option_name: str, widget_id: str | None) -> None:
"""
Initialize the Selected message.
Args:
option_name: Name of the option that was selected
widget_id: ID of the radio option widget
"""
super().__init__()
self.option_name = option_name
self.widget_id = widget_id
@property
def profile_name(self) -> str:
"""Alias for option_name (backward compatibility with ProfileOption)."""
return self.option_name
# -------------------------------------------------------------------------
# Default styling
# -------------------------------------------------------------------------
DEFAULT_CSS = """
RadioOption {
height: 1;
width: 100%;
}
RadioOption:hover {
background: $boost;
}
RadioOption:focus {
background: $accent;
}
.profile-selected {
color: $success;
text-style: bold;
}
.profile-unselected {
color: $text-muted;
}
"""
[docs]
def __init__(
self,
option_name: str,
description: str,
is_selected: bool = False,
*args,
**kwargs,
) -> None:
"""
Initialize radio option.
Args:
option_name: Internal name for this option (e.g., "fast")
description: Brief description to show after the label
is_selected: Whether this option starts selected
*args: Additional positional arguments passed to Static
**kwargs: Additional keyword arguments passed to Static
"""
super().__init__(*args, **kwargs)
self.option_name = option_name
self.description = description
self.is_selected = is_selected
[docs]
def on_mount(self) -> None:
"""
Configure widget after mounting.
Makes the widget focusable for keyboard navigation.
"""
self.can_focus = True
[docs]
def render(self):
"""
Render the option as text with Rich markup.
Returns:
Rich Text object with checkbox, label, and description
"""
from rich.text import Text
# Build the display text
# Note: Escape square brackets to prevent Rich markup interpretation
checkbox = "\\[x]" if self.is_selected else "\\[ ]"
label = self.option_name.capitalize()
# Apply styling based on selection state
if self.is_selected:
# Selected: bold green checkbox, bold label
return Text.from_markup(
f"[bold green]{checkbox}[/bold green] " f"[bold]{label}[/bold]: {self.description}"
)
else:
# Unselected: muted gray
return Text.from_markup(f"[dim]{checkbox}[/dim] {label}: [dim]{self.description}[/dim]")
[docs]
def action_select(self) -> None:
"""
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.
"""
if not self.is_selected:
self.post_message(self.Selected(self.option_name, self.id))
[docs]
def on_click(self) -> None:
"""
Handle click on this option.
Same behavior as action_select - posts message if not selected.
"""
if not self.is_selected:
self.post_message(self.Selected(self.option_name, self.id))
[docs]
def set_selected(self, selected: bool) -> None:
"""
Update selection state and refresh display.
Called by parent to manage radio group state.
Does not post any message.
Args:
selected: Whether this option should be selected
"""
self.is_selected = selected
self.refresh()