315 lines
11 KiB
Python
315 lines
11 KiB
Python
"""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)
|