diff --git a/CHANGELOG.md b/CHANGELOG.md index b5f08ad04..917ac0ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 e06f7a715..4003686b4 100644 --- a/src/textual/widgets/_radio_set.py +++ b/src/textual/widgets/_radio_set.py @@ -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 @@ -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. @@ -95,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 @@ -109,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. @@ -138,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.""" @@ -151,3 +226,34 @@ class RadioSet(Container): 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() diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 797ec93b5..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-1209678307-matrix { + .terminal-1099969603-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1209678307-title { + .terminal-1099969603-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1209678307-r1 { fill: #e1e1e1 } - .terminal-1209678307-r2 { fill: #c5c8c6 } - .terminal-1209678307-r3 { fill: #666666 } - .terminal-1209678307-r4 { fill: #3d3d3d } - .terminal-1209678307-r5 { fill: #1e1e1e;font-weight: bold } - .terminal-1209678307-r6 { fill: #515151 } - .terminal-1209678307-r7 { fill: #e1e1e1;text-decoration: underline; } - .terminal-1209678307-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,138 +20019,139 @@ font-weight: 700; } - .terminal-4238820762-matrix { + .terminal-4163780602-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4238820762-title { + .terminal-4163780602-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4238820762-r1 { fill: #e1e1e1 } - .terminal-4238820762-r2 { fill: #c5c8c6 } - .terminal-4238820762-r3 { fill: #666666 } - .terminal-4238820762-r4 { fill: #3d3d3d } - .terminal-4238820762-r5 { fill: #1e1e1e;font-weight: bold } - .terminal-4238820762-r6 { fill: #4ebf71;font-weight: bold } - .terminal-4238820762-r7 { fill: #cc555a;font-weight: bold;font-style: italic; } - .terminal-4238820762-r8 { fill: #515151 } - .terminal-4238820762-r9 { 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/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 16001300b..7cc85ef02 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -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"], terminal_size=(100, 30), ) diff --git a/tests/toggles/test_radioset.py b/tests/toggles/test_radioset.py index 198fb7b30..dc9de5b1b 100644 --- a/tests/toggles/test_radioset.py +++ b/tests/toggles/test_radioset.py @@ -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():