Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RadioSet redux #2372

Merged
merged 10 commits into from
Apr 25, 2023
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Breaking change: `HorizontalScroll` no longer shows a required vertical scrollbar by default
- Breaking change: Renamed `App.action_add_class_` to `App.action_add_class`
- Breaking change: Renamed `App.action_remove_class_` to `App.action_remove_class`
- Breaking change: `RadioSet` is now a single focusable widget https://github.com/Textualize/textual/pull/2372

### Added

Expand Down
2 changes: 1 addition & 1 deletion docs/examples/widgets/radio_button.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def compose(self) -> ComposeResult:
yield RadioButton("Wing Commander")

def on_mount(self) -> None:
self.query_one("#focus_me", RadioButton).focus()
self.query_one(RadioSet).focus()


if __name__ == "__main__":
Expand Down
7 changes: 3 additions & 4 deletions docs/examples/widgets/radio_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class RadioChoicesApp(App[None]):
def compose(self) -> ComposeResult:
with Horizontal():
# A RadioSet built up from RadioButtons.
with RadioSet():
with RadioSet(id="focus_me"):
yield RadioButton("Battlestar Galactica")
yield RadioButton("Dune 1984")
yield RadioButton("Dune 2021")
Expand All @@ -18,8 +18,7 @@ def compose(self) -> ComposeResult:
yield RadioButton("Star Wars: A New Hope")
yield RadioButton("The Last Starfighter")
yield RadioButton(
"Total Recall :backhand_index_pointing_right: :red_circle:",
id="focus_me",
"Total Recall :backhand_index_pointing_right: :red_circle:"
)
yield RadioButton("Wing Commander")
# A RadioSet built up from a collection of strings.
Expand All @@ -36,7 +35,7 @@ def compose(self) -> ComposeResult:
)

def on_mount(self) -> None:
self.query_one("#focus_me", RadioButton).focus()
self.query_one("#focus_me").focus()


if __name__ == "__main__":
Expand Down
7 changes: 3 additions & 4 deletions docs/examples/widgets/radio_set_changed.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class RadioSetChangedApp(App[None]):
def compose(self) -> ComposeResult:
with VerticalScroll():
with Horizontal():
with RadioSet():
with RadioSet(id="focus_me"):
yield RadioButton("Battlestar Galactica")
yield RadioButton("Dune 1984")
yield RadioButton("Dune 2021")
Expand All @@ -18,8 +18,7 @@ def compose(self) -> ComposeResult:
yield RadioButton("Star Wars: A New Hope")
yield RadioButton("The Last Starfighter")
yield RadioButton(
"Total Recall :backhand_index_pointing_right: :red_circle:",
id="focus_me",
"Total Recall :backhand_index_pointing_right: :red_circle:"
)
yield RadioButton("Wing Commander")
with Horizontal():
Expand All @@ -28,7 +27,7 @@ def compose(self) -> ComposeResult:
yield Label(id="index")

def on_mount(self) -> None:
self.query_one("#focus_me", RadioButton).focus()
self.query_one(RadioSet).focus()

def on_radio_set_changed(self, event: RadioSet.Changed) -> None:
self.query_one("#pressed", Label).update(
Expand Down
112 changes: 109 additions & 3 deletions src/textual/widgets/_radio_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@

from __future__ import annotations

from typing import ClassVar, Optional

import rich.repr

from ..binding import Binding, BindingType
from ..containers import Container
from ..events import Mount
from ..events import Click, Mount
from ..message import Message
from ..reactive import var
from ._radio_button import RadioButton


class RadioSet(Container):
class RadioSet(Container, 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
Expand All @@ -26,11 +30,47 @@ class RadioSet(Container):
width: auto;
}

RadioSet:focus {
border: round $accent;
}

App.-light-mode RadioSet {
border: round #CCC;
}

/* The following rules/styles mimic similar ToggleButton:focus rules in
* ToggleButton. If those styles ever get updated, these should be too.
*/

RadioSet:focus > RadioButton.-selected > .toggle--label {
text-style: underline;
}

RadioSet:focus ToggleButton.-selected > .toggle--button {
background: $foreground 25%;
}

RadioSet:focus > RadioButton.-on.-selected > .toggle--button {
background: $foreground 25%;
}
"""

BINDINGS: ClassVar[list[BindingType]] = [
Binding("down,right", "next_button", "", show=False),
Binding("enter,space", "toggle", "Toggle", show=False),
Binding("up,left", "previous_button", "", 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."""

@rich.repr.auto
class Changed(Message, bubble=True):
"""Posted when the pressed button in the set changes.
Expand Down Expand Up @@ -95,12 +135,26 @@ def __init__(
def _on_mount(self, _: Mount) -> None:
"""Perform some processing once mounted in the DOM."""

# If there are radio buttons, select the first one.
if self._nodes:
self._selected = 0

# 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 self.query(RadioButton) if button.value]
switched_on = [button for button in buttons if button.value]
with self.prevent(RadioButton.Changed):
for button in switched_on[1:]:
button.value = False
Expand All @@ -109,6 +163,11 @@ def _on_mount(self, _: Mount) -> None:
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")

def _on_radio_button_changed(self, event: RadioButton.Changed) -> None:
"""Respond to the value of a button in the set being changed.

Expand Down Expand Up @@ -138,6 +197,22 @@ def _on_radio_button_changed(self, event: RadioButton.Changed) -> None:
# 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."""
Expand All @@ -151,3 +226,34 @@ def pressed_index(self) -> int:
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.
"""
if self._nodes:
if self._selected == 0:
self._selected = len(self.children) - 1
elif self._selected is None:
self._selected = 0
else:
self._selected -= 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.
"""
if self._nodes:
if self._selected is None or self._selected == len(self._nodes) - 1:
self._selected = 0
else:
self._selected += 1

def action_toggle(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()
247 changes: 124 additions & 123 deletions tests/snapshot_tests/__snapshots__/test_snapshots.ambr

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion tests/snapshot_tests/test_snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ def test_demo(snap_compare):
"""Test the demo app (python -m textual)"""
assert snap_compare(
Path("../../src/textual/demo.py"),
press=["down", "down", "down"],
press=["down", "down", "down", "wait:250"],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Little out-of-scope tweak to the demo snapshot; in the hope that it'll stop it being a problem when running CI tests in Windows.

Based on a sample of 1 run it seems to have done the trick!

terminal_size=(100, 30),
)

Expand Down
25 changes: 25 additions & 0 deletions tests/toggles/test_radioset.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,31 @@ async def test_radio_sets_toggle():
]


async def test_radioset_inner_navigation():
"""Using the cursor keys should navigate between buttons in a set."""
async with RadioSetApp().run_test() as pilot:
assert pilot.app.screen.focused is None
await pilot.press("tab")
for key, landing in (("down", 1), ("up", 0), ("right", 1), ("left", 0)):
await pilot.press(key, "enter")
assert (
pilot.app.query_one("#from_buttons", RadioSet).pressed_button
== pilot.app.query_one("#from_buttons").children[landing]
)


async def test_radioset_breakout_navigation():
"""Shift/Tabbing while in a radioset should move to the previous/next focsuable after the set itself."""
async with RadioSetApp().run_test() as pilot:
assert pilot.app.screen.focused is None
await pilot.press("tab")
assert pilot.app.screen.focused is pilot.app.query_one("#from_buttons")
await pilot.press("tab")
assert pilot.app.screen.focused is pilot.app.query_one("#from_strings")
await pilot.press("shift+tab")
assert pilot.app.screen.focused is pilot.app.query_one("#from_buttons")


class BadRadioSetApp(App[None]):
def compose(self) -> ComposeResult:
with RadioSet():
Expand Down