diff --git a/docs/examples/widgets/radio_button.py b/docs/examples/widgets/radio_button.py index 64b7e7e41..316d89100 100644 --- a/docs/examples/widgets/radio_button.py +++ b/docs/examples/widgets/radio_button.py @@ -20,7 +20,7 @@ class RadioChoicesApp(App[None]): yield RadioButton("Wing Commander") def on_mount(self) -> None: - self.query_one("#focus_me", RadioButton).focus() + self.query_one(RadioSet).focus() if __name__ == "__main__": diff --git a/docs/examples/widgets/radio_set.py b/docs/examples/widgets/radio_set.py index 7f8ccf636..c09c9d6be 100644 --- a/docs/examples/widgets/radio_set.py +++ b/docs/examples/widgets/radio_set.py @@ -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") @@ -18,8 +18,7 @@ class RadioChoicesApp(App[None]): 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. @@ -36,7 +35,7 @@ class RadioChoicesApp(App[None]): ) def on_mount(self) -> None: - self.query_one("#focus_me", RadioButton).focus() + self.query_one("#focus_me").focus() if __name__ == "__main__": diff --git a/docs/examples/widgets/radio_set_changed.py b/docs/examples/widgets/radio_set_changed.py index c817b6e6f..4af563c39 100644 --- a/docs/examples/widgets/radio_set_changed.py +++ b/docs/examples/widgets/radio_set_changed.py @@ -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") @@ -18,8 +18,7 @@ class RadioSetChangedApp(App[None]): 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(): @@ -28,7 +27,7 @@ class RadioSetChangedApp(App[None]): 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( diff --git a/src/textual/widgets/_radio_set.py b/src/textual/widgets/_radio_set.py index a05f1966d..4003686b4 100644 --- a/src/textual/widgets/_radio_set.py +++ b/src/textual/widgets/_radio_set.py @@ -2,18 +2,19 @@ from __future__ import annotations -from typing import ClassVar +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 @@ -29,30 +30,47 @@ class RadioSet(Container): width: auto; } - RadioSet:focus-within { + 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("shift+tab", "breakout_previous", "", show=False), - Binding("tab", "breakout_next", "", 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. | - | shift+tab | Move focus to the previous focusable widget relative to the set. | - | tab | Move focus to the next focusable widget relative to 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. @@ -117,12 +135,26 @@ class RadioSet(Container): 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 @@ -131,6 +163,11 @@ class RadioSet(Container): 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. @@ -160,6 +197,22 @@ class RadioSet(Container): # 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.""" @@ -179,31 +232,28 @@ class RadioSet(Container): Note that this will wrap around to the end if at the start. """ - if self.children: - if self.screen.focused == self.children[0]: - self.screen.set_focus(self.children[-1]) + if self._nodes: + if self._selected == 0: + self._selected = len(self.children) - 1 + elif self._selected is None: + self._selected = 0 else: - self.screen.focus_previous() + 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.children: - if self.screen.focused == self.children[-1]: - self.screen.set_focus(self.children[0]) + if self._nodes: + if self._selected is None or self._selected == len(self._nodes) - 1: + self._selected = 0 else: - self.screen.focus_next() + self._selected += 1 - def action_breakout_previous(self) -> None: - """Break out of the radio set to the previous widget in the focus chain.""" - if self.children: - self.screen.set_focus(self.children[0]) - self.screen.focus_previous() - - def action_breakout_next(self) -> None: - """Break out of the radio set to the next widget in the focus chain.""" - if self.children: - self.screen.set_focus(self.children[-1]) - self.screen.focus_next() + 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() diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 9a1b9d9a5..22bbbdafd 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -19858,137 +19858,137 @@ font-weight: 700; } - .terminal-2550674499-matrix { + .terminal-1099969603-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2550674499-title { + .terminal-1099969603-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2550674499-r1 { fill: #e1e1e1 } - .terminal-2550674499-r2 { fill: #c5c8c6 } - .terminal-2550674499-r3 { fill: #0178d4 } - .terminal-2550674499-r4 { fill: #3d3d3d } - .terminal-2550674499-r5 { fill: #1e1e1e;font-weight: bold } - .terminal-2550674499-r6 { fill: #515151 } - .terminal-2550674499-r7 { fill: #e1e1e1;text-decoration: underline; } - .terminal-2550674499-r8 { fill: #4ebf71;font-weight: bold } + .terminal-1099969603-r1 { fill: #e1e1e1 } + .terminal-1099969603-r2 { fill: #c5c8c6 } + .terminal-1099969603-r3 { fill: #0178d4 } + .terminal-1099969603-r4 { fill: #515151 } + .terminal-1099969603-r5 { fill: #1e1e1e;font-weight: bold } + .terminal-1099969603-r6 { fill: #e1e1e1;text-decoration: underline; } + .terminal-1099969603-r7 { fill: #3d3d3d } + .terminal-1099969603-r8 { fill: #4ebf71;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - RadioChoicesApp + RadioChoicesApp - - - - - - - - - - ─────────────────────────────────────── - Battlestar Galactica - Dune 1984 - Dune 2021 - Serenity - Star Trek: The Motion Picture - Star Wars: A New Hope - The Last Starfighter - Total Recall 👉 🔴 - Wing Commander - ─────────────────────────────────────── - - - - - - + + + + + + + + + + ─────────────────────────────────────── + Battlestar Galactica + Dune 1984 + Dune 2021 + Serenity + Star Trek: The Motion Picture + Star Wars: A New Hope + The Last Starfighter + Total Recall 👉 🔴 + Wing Commander + ─────────────────────────────────────── + + + + + + @@ -20019,139 +20019,139 @@ font-weight: 700; } - .terminal-3895672826-matrix { + .terminal-4163780602-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3895672826-title { + .terminal-4163780602-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3895672826-r1 { fill: #e1e1e1 } - .terminal-3895672826-r2 { fill: #c5c8c6 } - .terminal-3895672826-r3 { fill: #0178d4 } - .terminal-3895672826-r4 { fill: #666666 } - .terminal-3895672826-r5 { fill: #3d3d3d } - .terminal-3895672826-r6 { fill: #1e1e1e;font-weight: bold } - .terminal-3895672826-r7 { fill: #4ebf71;font-weight: bold } - .terminal-3895672826-r8 { fill: #cc555a;font-weight: bold;font-style: italic; } - .terminal-3895672826-r9 { fill: #515151 } - .terminal-3895672826-r10 { fill: #e1e1e1;text-decoration: underline; } + .terminal-4163780602-r1 { fill: #e1e1e1 } + .terminal-4163780602-r2 { fill: #c5c8c6 } + .terminal-4163780602-r3 { fill: #0178d4 } + .terminal-4163780602-r4 { fill: #666666 } + .terminal-4163780602-r5 { fill: #515151 } + .terminal-4163780602-r6 { fill: #1e1e1e;font-weight: bold } + .terminal-4163780602-r7 { fill: #e1e1e1;text-decoration: underline; } + .terminal-4163780602-r8 { fill: #3d3d3d } + .terminal-4163780602-r9 { fill: #4ebf71;font-weight: bold } + .terminal-4163780602-r10 { fill: #cc555a;font-weight: bold;font-style: italic; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - RadioChoicesApp + RadioChoicesApp - - - - - - - - - - ────────────────────────────────────────────────────────────────────── - Battlestar GalacticaAmanda - Dune 1984Connor MacLeod - Dune 2021Duncan MacLeod - SerenityHeather MacLeod - Star Trek: The Motion PictureJoe Dawson - Star Wars: A New HopeKurgan, The - The Last StarfighterMethos - Total Recall 👉 🔴Rachel Ellenstein - Wing CommanderRamírez - ────────────────────────────────────────────────────────────────────── - - - - - - + + + + + + + + + + ────────────────────────────────────────────────────────────────────── + Battlestar GalacticaAmanda + Dune 1984Connor MacLeod + Dune 2021Duncan MacLeod + SerenityHeather MacLeod + Star Trek: The Motion PictureJoe Dawson + Star Wars: A New HopeKurgan, The + The Last StarfighterMethos + Total Recall 👉 🔴Rachel Ellenstein + Wing CommanderRamírez + ────────────────────────────────────────────────────────────────────── + + + + + + diff --git a/tests/toggles/test_radioset.py b/tests/toggles/test_radioset.py index b44722787..dc9de5b1b 100644 --- a/tests/toggles/test_radioset.py +++ b/tests/toggles/test_radioset.py @@ -57,13 +57,10 @@ async def test_radioset_inner_navigation(): async with RadioSetApp().run_test() as pilot: assert pilot.app.screen.focused is None await pilot.press("tab") - assert ( - pilot.app.screen.focused == pilot.app.query_one("#from_buttons").children[0] - ) for key, landing in (("down", 1), ("up", 0), ("right", 1), ("left", 0)): - await pilot.press(key) + await pilot.press(key, "enter") assert ( - pilot.app.screen.focused + pilot.app.query_one("#from_buttons", RadioSet).pressed_button == pilot.app.query_one("#from_buttons").children[landing] ) @@ -73,11 +70,11 @@ async def test_radioset_breakout_navigation(): async with RadioSetApp().run_test() as pilot: assert pilot.app.screen.focused is None await pilot.press("tab") - assert pilot.app.screen.focused.parent is pilot.app.query_one("#from_buttons") + assert pilot.app.screen.focused is pilot.app.query_one("#from_buttons") await pilot.press("tab") - assert pilot.app.screen.focused.parent is pilot.app.query_one("#from_strings") + assert pilot.app.screen.focused is pilot.app.query_one("#from_strings") await pilot.press("shift+tab") - assert pilot.app.screen.focused.parent is pilot.app.query_one("#from_buttons") + assert pilot.app.screen.focused is pilot.app.query_one("#from_buttons") class BadRadioSetApp(App[None]):