Source code for build_tools.tui_common.controls.spinners

"""
Integer spinner control widget.

This module provides the IntSpinner widget for integer parameter control
with keyboard support and optional dynamic suffix.

**Features:**

- Keyboard navigation: ``+``/``-`` or ``j``/``k`` or arrow keys to adjust
- Configurable step size and min/max range
- Optional suffix function for dynamic labels (e.g., "5 items", "10 chars")
- Posts ``Changed`` message when value updates
- Focusable with visual feedback

**Example Usage:**

.. code-block:: python

    from build_tools.tui_common.controls import IntSpinner
    from textual import on

    class MyApp(App):
        def compose(self) -> ComposeResult:
            # Simple spinner with static label
            yield IntSpinner(
                label="Count",
                value=5,
                min_val=1,
                max_val=100,
                id="count-spinner",
            )

            # Spinner with dynamic suffix based on value
            yield IntSpinner(
                label="Walk Steps",
                value=5,
                min_val=0,
                max_val=20,
                suffix_fn=lambda v: f"-> {v + 1} syllables",
                id="steps-spinner",
            )

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

from __future__ import annotations

from collections.abc import Callable
from typing import TYPE_CHECKING

from textual.message import Message
from textual.widgets import Label, Static

if TYPE_CHECKING:
    from textual.app import ComposeResult


[docs] class IntSpinner(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. Attributes: 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: - :class:`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) """ # ------------------------------------------------------------------------- # Widget-level bindings for parameter adjustment # These don't interfere with app-level bindings (q, a, v, etc.) # ------------------------------------------------------------------------- BINDINGS = [ ("+", "increment", "Increment"), ("=", "increment", "Increment"), ("j", "increment", "Increment"), ("down", "increment", "Increment"), ("-", "decrement", "Decrement"), ("_", "decrement", "Decrement"), ("k", "decrement", "Decrement"), ("up", "decrement", "Decrement"), ]
[docs] class Changed(Message): """ Message posted when the spinner value changes. Attributes: value: The new integer value after the change 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 new spinner value widget_id: ID of the spinner widget that changed """ super().__init__() self.value = value self.widget_id = widget_id
# ------------------------------------------------------------------------- # Default styling using Textual's CSS variables for theme compatibility # ------------------------------------------------------------------------- DEFAULT_CSS = """ IntSpinner { layout: horizontal; height: 1; width: 100%; } IntSpinner .spinner-label { width: 15; text-align: right; padding-right: 1; } IntSpinner .spinner-value { width: 6; text-align: center; background: $boost; } IntSpinner:focus .spinner-value { background: $accent; text-style: bold; } IntSpinner .spinner-suffix { width: auto; padding-left: 1; color: $text-muted; } """
[docs] def __init__( self, label: str, value: int, min_val: int, max_val: int, step: int = 1, suffix_fn: Callable[[int], str] | None = None, *args, **kwargs, ) -> None: """ Initialize integer spinner. Args: label: Display label shown before the value value: Initial value (will be clamped to min/max range) min_val: Minimum allowed value max_val: Maximum allowed value step: Increment/decrement step size (default: 1) suffix_fn: 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 """ super().__init__(*args, **kwargs) self.label_text = label # Clamp initial value to valid range self.value = max(min_val, min(value, max_val)) self.min_val = min_val self.max_val = max_val self.step = step self.suffix_fn = suffix_fn
[docs] def compose(self) -> ComposeResult: """ Create the spinner layout. Layout: [Label:] [value] [suffix] Yields: Label widgets for label, value display, and optional suffix """ yield Label(f"{self.label_text}:", classes="spinner-label") yield Label(f"[{self.value:2d}]", classes="spinner-value", id="value-display") # Generate initial suffix if function provided suffix_text = self.suffix_fn(self.value) if self.suffix_fn else "" yield Label(suffix_text, classes="spinner-suffix", id="suffix-display")
[docs] def on_mount(self) -> None: """ Configure widget after mounting. Makes the widget focusable for keyboard navigation. Focus state is used to highlight the value display. """ self.can_focus = True
[docs] def action_increment(self) -> None: """ Increment value by step, clamped to max. Only posts Changed message if value actually changed. Updates both value display and suffix if present. """ old_value = self.value self.value = min(self.value + self.step, self.max_val) if self.value != old_value: self._update_display() self.post_message(self.Changed(self.value, self.id))
[docs] def action_decrement(self) -> None: """ Decrement value by step, clamped to min. Only posts Changed message if value actually changed. Updates both value display and suffix if present. """ old_value = self.value self.value = max(self.value - self.step, self.min_val) if self.value != old_value: self._update_display() self.post_message(self.Changed(self.value, self.id))
[docs] def set_value(self, value: int) -> None: """ Set value programmatically, clamped to range. Use this method to update the spinner value from code. Posts Changed message if value actually changed. Args: value: New value (will be clamped to min/max range) """ old_value = self.value self.value = max(self.min_val, min(value, self.max_val)) if self.value != old_value: self._update_display() self.post_message(self.Changed(self.value, self.id))
def _update_display(self) -> None: """ Update the displayed value and suffix labels. Called after value changes to sync the UI. Silently handles cases where widget isn't mounted yet. """ try: # Update value display display = self.query_one("#value-display", Label) display.update(f"[{self.value:2d}]") # Update suffix if function provided if self.suffix_fn: suffix = self.query_one("#suffix-display", Label) suffix.update(self.suffix_fn(self.value)) except Exception: # nosec B110 - Widget may not be mounted yet pass