Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions src/textual/widgets/_radio_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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."""
Expand Down
122 changes: 122 additions & 0 deletions tests/test_radio_set_clear.py
Original file line number Diff line number Diff line change
@@ -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