From f1e20c301c2f534aecd236733a7977aee7048635 Mon Sep 17 00:00:00 2001 From: NIKHIL Date: Sun, 16 Nov 2025 19:42:28 +0530 Subject: [PATCH] Add clear() method to RadioSet - Implements RadioSet.clear() to deselect all buttons - Returns RadioSet to initial unset state - Useful for surveys/questionnaires where no response is valid - Fixes #6198 --- src/textual/widgets/_radio_set.py | 34 ++++++++- tests/test_radio_set_clear.py | 122 ++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 tests/test_radio_set_clear.py diff --git a/src/textual/widgets/_radio_set.py b/src/textual/widgets/_radio_set.py index 7fedbac00e..b2d3e93105 100644 --- a/src/textual/widgets/_radio_set.py +++ b/src/textual/widgets/_radio_set.py @@ -106,7 +106,8 @@ class Changed(Message): 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: + def __init__(self, radio_set: RadioSet, pressed: RadioButton | None) -> None: # None = cleared state + """Initialise the message. Args: @@ -247,7 +248,12 @@ def _on_radio_set_changed(self, event: RadioSet.Changed) -> None: This handler ensures that, when a button is pressed, it's also the selected button. """ - self._selected = event.index + if event.index >= 0: + self._selected = event.index + else: + # cleared state - avoid triggering watcher + with self.prevent(RadioSet.Changed): + self._selected = None async def _on_click(self, _: Click) -> None: """Handle a click on or within the radio set. @@ -299,6 +305,30 @@ def action_toggle_button(self) -> None: button = self._nodes[self._selected] assert isinstance(button, RadioButton) button.toggle() + + def clear(self) -> None: + """Clear the radio set so that no button is pressed. + + This returns the RadioSet to its initial unset state, useful for + survey/questionnaire interfaces where "no response" is a valid state. + See: https://github.com/Textualize/textual/issues/6197 + """ + # turn off all buttons + with self.prevent(RadioButton.Changed): + for button in self.query(RadioButton): + button.value = False + + self._pressed_button = None + self.query(RadioButton).remove_class("-selected") + + # set to None without triggering the watcher + with self.prevent(RadioSet.Changed): + self._selected = None + + self.post_message(self.Changed(self, None)) + self.refresh() + + def _scroll_to_selected(self) -> None: """Ensure that the selected button is in view.""" diff --git a/tests/test_radio_set_clear.py b/tests/test_radio_set_clear.py new file mode 100644 index 0000000000..546ae71970 --- /dev/null +++ b/tests/test_radio_set_clear.py @@ -0,0 +1,122 @@ +from textual.app import App, ComposeResult +from textual.widgets import RadioSet, RadioButton + + +class RadioSetTestApp(App): + def compose(self) -> ComposeResult: + yield RadioSet( + RadioButton("One"), + RadioButton("Two"), + RadioButton("Three"), + ) + + +async def test_radioset_clear(): + """clear() should remove all selections""" + app = RadioSetTestApp() + + async with app.run_test() as pilot: + rs = app.query_one(RadioSet) + buttons = list(rs.query(RadioButton)) + rb1, rb2, rb3 = buttons + + await pilot.pause() + + # press second button + rb2.value = True + await pilot.pause() + + assert rs.pressed_button is rb2 + assert rs.pressed_index == 1 + assert rb2.value is True + + # now clear it + rs.clear() + await pilot.pause() + + assert rs.pressed_button is None + assert rs.pressed_index == -1 + + for rb in buttons: + assert rb.value is False + + +async def test_clear_empty_radioset(): + """calling clear on empty radioset shouldn't crash""" + + class EmptyApp(App): + def compose(self) -> ComposeResult: + yield RadioSet() + + app = EmptyApp() + + async with app.run_test() as pilot: + rs = app.query_one(RadioSet) + await pilot.pause() + + rs.clear() # shouldn't error + await pilot.pause() + + assert rs.pressed_button is None + assert rs.pressed_index == -1 + + +async def test_clear_twice(): + """clearing multiple times should work fine""" + + class TestApp(App): + def compose(self) -> ComposeResult: + yield RadioSet( + RadioButton("One"), + RadioButton("Two"), + ) + + app = TestApp() + + async with app.run_test() as pilot: + rs = app.query_one(RadioSet) + btns = list(rs.query(RadioButton)) + await pilot.pause() + + btns[0].value = True + await pilot.pause() + assert rs.pressed_button is btns[0] + + rs.clear() + await pilot.pause() + assert rs.pressed_button is None + + # clear again - should still work + rs.clear() + await pilot.pause() + assert rs.pressed_button is None + assert rs.pressed_index == -1 + + +async def test_can_select_after_clear(): + """after clearing, we should be able to select buttons again""" + + class TestApp(App): + def compose(self) -> ComposeResult: + yield RadioSet( + RadioButton("Option A"), + RadioButton("Option B"), + ) + + app = TestApp() + + async with app.run_test() as pilot: + rs = app.query_one(RadioSet) + btns = list(rs.query(RadioButton)) + await pilot.pause() + + rs.clear() + await pilot.pause() + assert rs.pressed_button is None + + # should be able to select again + btns[1].value = True + await pilot.pause() + + assert rs.pressed_button is btns[1] + assert rs.pressed_index == 1 \ No newline at end of file