"""Provides a RadioSet widget, which groups radio buttons.""" from __future__ import annotations from typing import ClassVar, Optional import rich.repr from rich.console import RenderableType from textual import _widget_navigation from textual.binding import Binding, BindingType from textual.containers import VerticalScroll from textual.events import Click, Mount from textual.message import Message from textual.reactive import reactive, var from textual.widgets._radio_button import RadioButton class RadioSet(VerticalScroll, can_focus=True, can_focus_children=False): """Widget for grouping a collection of radio buttons into a set. When a collection of [`RadioButton`][textual.widgets.RadioButton]s are grouped with this widget, they will be treated as a mutually-exclusive grouping. If one button is turned on, the previously-on button will be turned off. """ ALLOW_SELECT = False ALLOW_MAXIMIZE = True DEFAULT_CSS = """ RadioSet { border: tall $border-blurred; background: $surface; padding: 0 1; height: auto; width: 1fr; &.-textual-compact { border: none !important; padding: 0; } & > RadioButton { background: transparent; border: none; padding: 0; width: 1fr; & > .toggle--button { color: $panel-darken-2; background: $panel; } } & > RadioButton.-on .toggle--button { color: $text-success; } &:blur { & > RadioButton.-selected { & > .toggle--label { background: $block-cursor-blurred-background; } } } &:focus { /* The following rules/styles mimic similar ToggleButton:focus rules in * ToggleButton. If those styles ever get updated, these should be too. */ border: tall $border; background-tint: $foreground 5%; & > RadioButton.-selected { & > .toggle--label { background: $block-cursor-background; color: $block-cursor-foreground; text-style: $block-cursor-text-style; } } } } """ BINDINGS: ClassVar[list[BindingType]] = [ Binding("down,right", "next_button", "Next option", show=False), Binding("enter,space", "toggle_button", "Toggle", show=False), Binding("up,left", "previous_button", "Previous option", show=False), ] """ | Key(s) | Description | | :- | :- | | enter, space | Toggle the currently-selected button. | | left, up | Select the previous radio button in the set. | | right, down | Select the next radio button in the set. | """ _selected: var[int | None] = var[Optional[int]](None) """The index of the currently-selected radio button.""" compact: reactive[bool] = reactive(False, toggle_class="-textual-compact") """Enable compact display?""" @rich.repr.auto class Changed(Message): """Posted when the pressed button in the set changes. This message can be handled using an `on_radio_set_changed` method. """ ALLOW_SELECTOR_MATCH = {"pressed"} """Additional message attributes that can be used with the [`on` decorator][textual.on].""" def __init__(self, radio_set: RadioSet, pressed: RadioButton) -> None: """Initialise the message. Args: pressed: The radio button that was pressed. """ super().__init__() self.radio_set = radio_set """A reference to the [`RadioSet`][textual.widgets.RadioSet] that was changed.""" self.pressed = pressed """The [`RadioButton`][textual.widgets.RadioButton] that was pressed to make the change.""" self.index = radio_set.pressed_index """The index of the [`RadioButton`][textual.widgets.RadioButton] that was pressed to make the change.""" @property def control(self) -> RadioSet: """A reference to the [`RadioSet`][textual.widgets.RadioSet] that was changed. This is an alias for [`Changed.radio_set`][textual.widgets.RadioSet.Changed.radio_set] and is used by the [`on`][textual.on] decorator. """ return self.radio_set def __rich_repr__(self) -> rich.repr.Result: yield "radio_set", self.radio_set yield "pressed", self.pressed yield "index", self.index def __init__( self, *buttons: str | RadioButton, name: str | None = None, id: str | None = None, classes: str | None = None, disabled: bool = False, tooltip: RenderableType | None = None, compact: bool = False, ) -> None: """Initialise the radio set. Args: buttons: The labels or [`RadioButton`][textual.widgets.RadioButton]s to group together. name: The name of the radio set. id: The ID of the radio set in the DOM. classes: The CSS classes of the radio set. disabled: Whether the radio set is disabled or not. tooltip: Optional tooltip. compact: Enable compact radio set style Note: When a `str` label is provided, a [RadioButton][textual.widgets.RadioButton] will be created from it. """ self._pressed_button: RadioButton | None = None """Holds the radio buttons we're responsible for.""" super().__init__( *[ (button if isinstance(button, RadioButton) else RadioButton(button)) for button in buttons ], name=name, id=id, classes=classes, disabled=disabled, ) if tooltip is not None: self.tooltip = tooltip self.compact = compact def _on_mount(self, _: Mount) -> None: """Perform some processing once mounted in the DOM.""" # If there are radio buttons, select the first available one. self.action_next_button() # Get all the buttons within us; we'll be doing a couple of things # with that list. buttons = list(self.query(RadioButton)) # RadioButtons can have focus, by default. But we're going to take # that over and handle movement between them. So here we tell them # all they can't focus. for button in buttons: button.can_focus = False # It's possible for the user to pass in a collection of radio # buttons, with more than one set to on; they shouldn't, but we # can't stop them. So here we check for that and, for want of a # better approach, we keep the first one on and turn all the others # off. switched_on = [button for button in buttons if button.value] with self.prevent(RadioButton.Changed): for button in switched_on[1:]: button.value = False # Keep track of which button is initially pressed. if switched_on: self._pressed_button = switched_on[0] def watch__selected(self) -> None: self.query(RadioButton).remove_class("-selected") if self._selected is not None: self._nodes[self._selected].add_class("-selected") self._scroll_to_selected() def _on_radio_button_changed(self, event: RadioButton.Changed) -> None: """Respond to the value of a button in the set being changed. Args: event: The event. """ # We're going to consume the underlying radio button events, making # it appear as if they don't emit their own, as far as the caller is # concerned. As such, stop the event bubbling and also prohibit the # same event being sent out if/when we make a value change in here. event.stop() with self.prevent(RadioButton.Changed): # If the message pertains to a button being clicked to on... if event.radio_button.value: # If there's a button pressed right now and it's not really a # case of the user mashing on the same button... if ( self._pressed_button is not None and self._pressed_button != event.radio_button ): self._pressed_button.value = False # Make the pressed button this new button. self._pressed_button = event.radio_button # Emit a message to say our state has changed. self.post_message(self.Changed(self, event.radio_button)) else: # We're being clicked off, we don't want that. event.radio_button.value = True def _on_radio_set_changed(self, event: RadioSet.Changed) -> None: """Handle a change to which button in the set is pressed. This handler ensures that, when a button is pressed, it's also the selected button. """ self._selected = event.index async def _on_click(self, _: Click) -> None: """Handle a click on or within the radio set. This handler ensures that focus moves to the clicked radio set, even if there's a click on one of the radio buttons it contains. """ self.focus() @property def pressed_button(self) -> RadioButton | None: """The currently-pressed [`RadioButton`][textual.widgets.RadioButton], or `None` if none are pressed.""" return self._pressed_button @property def pressed_index(self) -> int: """The index of the currently-pressed [`RadioButton`][textual.widgets.RadioButton], or -1 if none are pressed.""" return ( self._nodes.index(self._pressed_button) if self._pressed_button is not None else -1 ) def action_previous_button(self) -> None: """Navigate to the previous button in the set. Note that this will wrap around to the end if at the start. """ self._selected = _widget_navigation.find_next_enabled( self.children, anchor=self._selected, direction=-1, ) def action_next_button(self) -> None: """Navigate to the next button in the set. Note that this will wrap around to the start if at the end. """ self._selected = _widget_navigation.find_next_enabled( self.children, anchor=self._selected, direction=1, ) def action_toggle_button(self) -> None: """Toggle the state of the currently-selected button.""" if self._selected is not None: button = self._nodes[self._selected] assert isinstance(button, RadioButton) button.toggle() def _scroll_to_selected(self) -> None: """Ensure that the selected button is in view.""" if self._selected is not None: button = self._nodes[self._selected] self.call_after_refresh(self.scroll_to_widget, button, animate=False)