use crossterm::event::KeyCode;
use rich_rs::{Console, ConsoleOptions, Renderable, Segment, Segments};
use crate::event::{Event, EventCtx};
use crate::message::*;
use super::{
Widget, WidgetStyles,
helpers::{adjust_line_length_no_bg, empty_classes, fixed_height_from_constraints},
option_list::toggle_option::OptionCursorState,
radio_button::RadioButton,
};
use crate::compose::ComposeResult;
use crate::reactive::{ReactiveCtx, ReactiveFlags, ReactiveWidget};
#[derive(Debug, Clone)]
pub struct RadioSet {
buttons: Vec<RadioButton>,
cursor: OptionCursorState,
disabled: bool,
focused: bool,
hovered: bool,
hovered_index: Option<usize>,
classes: Vec<String>,
focused_classes: Vec<String>,
styles: WidgetStyles,
}
impl RadioSet {
pub fn new() -> Self {
Self {
buttons: Vec::new(),
cursor: OptionCursorState::default(),
disabled: false,
focused: false,
hovered: false,
hovered_index: None,
classes: vec!["radio-set".to_string()],
focused_classes: vec!["radio-set".to_string(), "focused".to_string()],
styles: WidgetStyles::default(),
}
}
pub fn from_labels(labels: &[&str]) -> Self {
let mut set = Self::new();
for label in labels {
set.buttons.push(RadioButton::new(*label));
}
set.cursor.set_highlighted(set.first_enabled_index());
set
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
if disabled {
self.focused = false;
}
self
}
pub fn set_disabled(&mut self, value: bool, ctx: &mut ReactiveCtx) {
if self.disabled != value {
let old = self.disabled;
self.disabled = value;
ctx.record_change(
"disabled",
ReactiveFlags::reactive(),
Box::new(old),
Box::new(value),
);
}
}
pub fn with_button(mut self, button: RadioButton) -> Self {
self.add_button(button);
self
}
pub fn add_button(&mut self, button: RadioButton) {
let index = self.buttons.len();
if button.value() {
if let Some(prev) = self.cursor.selected() {
if let Some(btn) = self.buttons.get_mut(prev) {
btn.set_value_silent(false);
}
}
self.cursor.set_selected(Some(index));
}
self.buttons.push(button);
if self.cursor.highlighted().is_none() {
self.cursor.set_highlighted(self.first_enabled_index());
}
}
pub fn pressed_index(&self) -> Option<usize> {
self.cursor.selected()
}
pub fn selected_index(&self) -> usize {
self.cursor.highlighted().unwrap_or(0)
}
pub fn button(&self, index: usize) -> Option<&RadioButton> {
self.buttons.get(index)
}
pub fn button_mut(&mut self, index: usize) -> Option<&mut RadioButton> {
self.buttons.get_mut(index)
}
pub fn len(&self) -> usize {
self.buttons.len()
}
pub fn is_empty(&self) -> bool {
self.buttons.is_empty()
}
fn move_selection(&mut self, delta: isize) {
if self.buttons.is_empty() {
return;
}
let enabled_indices: Vec<usize> = self
.buttons
.iter()
.enumerate()
.filter_map(|(idx, button)| (!button.is_disabled()).then_some(idx))
.collect();
if enabled_indices.is_empty() {
self.cursor.set_highlighted(None);
return;
}
let current_pos = self
.cursor
.highlighted()
.and_then(|idx| enabled_indices.iter().position(|&enabled| enabled == idx));
let next_pos = if let Some(pos) = current_pos {
let len = enabled_indices.len() as isize;
((pos as isize + if delta.is_negative() { -1 } else { 1 }) % len + len) % len
} else if delta.is_negative() {
enabled_indices.len() as isize - 1
} else {
0
};
self.cursor
.set_highlighted(Some(enabled_indices[next_pos as usize]));
}
fn toggle_selected(&mut self, ctx: &mut EventCtx) {
if self.disabled || self.buttons.is_empty() {
return;
}
let index = self.cursor.highlighted().unwrap_or(0);
if self
.buttons
.get(index)
.map(Widget::is_disabled)
.unwrap_or(true)
{
return;
}
let already_pressed = self.cursor.selected() == Some(index);
if already_pressed {
ctx.set_handled();
return;
}
if let Some(prev) = self.cursor.selected() {
if let Some(btn) = self.buttons.get_mut(prev) {
btn.set_value_silent(false);
}
}
if let Some(btn) = self.buttons.get_mut(index) {
btn.set_value_silent(true);
}
self.cursor.set_selected(Some(index));
let button_id = self.node_id();
ctx.post_message(Message::RadioSetChanged(RadioSetChanged {
index,
button_id,
}));
ctx.request_repaint();
ctx.set_handled();
}
fn first_enabled_index(&self) -> Option<usize> {
self.buttons.iter().position(|button| !button.is_disabled())
}
fn has_enabled_button(&self) -> bool {
self.buttons.iter().any(|button| !button.is_disabled())
}
pub fn children(&self) -> &[RadioButton] {
&self.buttons
}
pub fn children_mut(&mut self) -> &mut Vec<RadioButton> {
&mut self.buttons
}
}
impl Widget for RadioSet {
fn compose(&self) -> ComposeResult {
Vec::new()
}
fn take_composed_children(&mut self) -> Vec<Box<dyn Widget>> {
self.buttons
.drain(..)
.map(|b| Box::new(b) as Box<dyn Widget>)
.collect()
}
fn focusable(&self) -> bool {
!self.disabled && self.has_enabled_button()
}
fn set_focus(&mut self, focused: bool) {
self.focused = focused && !self.disabled;
}
fn is_disabled(&self) -> bool {
self.disabled
}
fn set_disabled_state(&mut self, disabled: bool) {
self.disabled = disabled;
if disabled {
self.focused = false;
self.hovered = false;
}
}
fn has_focus(&self) -> bool {
self.focused
}
fn is_hovered(&self) -> bool {
self.hovered
}
fn set_hovered(&mut self, hovered: bool) {
self.hovered = hovered;
if !hovered {
self.hovered_index = None;
}
}
fn on_event(&mut self, event: &Event, ctx: &mut EventCtx) {
if self.disabled || self.buttons.is_empty() {
return;
}
match event {
Event::MouseDown(mouse) if mouse.target == self.node_id() => {
let index = mouse.y as usize;
if index < self.buttons.len() && !self.buttons[index].is_disabled() {
self.cursor.set_highlighted(Some(index));
self.toggle_selected(ctx);
}
}
Event::Key(key) if self.focused => match key.code {
KeyCode::Up | KeyCode::Left => {
self.move_selection(-1);
ctx.request_repaint();
ctx.set_handled();
}
KeyCode::Down | KeyCode::Right => {
self.move_selection(1);
ctx.request_repaint();
ctx.set_handled();
}
KeyCode::Enter | KeyCode::Char(' ') => {
self.toggle_selected(ctx);
}
_ => {}
},
_ => {}
}
}
fn on_message(&mut self, message: &MessageEvent, ctx: &mut EventCtx) {
if let Message::RadioButtonChanged(RadioButtonChanged { value }) = &message.message {
if let Some(index) = self
.buttons
.iter()
.position(|_b| message.sender == self.node_id())
{
if *value {
if let Some(prev) = self.cursor.selected() {
if prev != index {
if let Some(btn) = self.buttons.get_mut(prev) {
btn.set_value_silent(false);
}
}
}
self.cursor.set_selected(Some(index));
self.cursor.set_highlighted(Some(index));
let button_id = self.node_id();
ctx.post_message(Message::RadioSetChanged(RadioSetChanged {
index,
button_id,
}));
ctx.request_repaint();
} else {
if let Some(btn) = self.buttons.get_mut(index) {
btn.set_value_silent(true);
}
}
ctx.set_handled();
}
}
}
fn on_mouse_move(&mut self, _x: u16, y: u16) -> bool {
if self.disabled || self.buttons.is_empty() {
return false;
}
let index = y as usize;
let hovered =
(index < self.buttons.len() && !self.buttons[index].is_disabled()).then_some(index);
if hovered != self.hovered_index {
self.hovered_index = hovered;
return true;
}
false
}
fn render(&self, _console: &Console, options: &ConsoleOptions) -> Segments {
let width = options.size.0.max(1);
let height = options.size.1.max(1);
let mut out = Segments::new();
let base_style = crate::css::resolve_component_style(self, &["radio-button--label"])
.to_rich()
.unwrap_or_else(rich_rs::Style::new);
for row in 0..height {
if row >= self.buttons.len() {
out.push(Segment::styled(" ".repeat(width), base_style));
} else {
let button = &self.buttons[row];
let is_selected = self.cursor.highlighted() == Some(row);
let is_pressed = self.cursor.selected() == Some(row);
let is_hovered_row = self.hovered_index == Some(row);
let glyph = if is_pressed || button.value() {
"●"
} else {
"○"
};
let mut glyph_classes = vec!["radio-button--button"];
let mut label_classes = vec!["radio-button--label"];
if is_pressed || button.value() {
glyph_classes.push("-on");
label_classes.push("-on");
}
if is_selected && self.focused {
glyph_classes.push("-focus");
label_classes.push("-focus");
}
if is_hovered_row {
glyph_classes.push("-hover");
label_classes.push("-hover");
}
if is_selected {
label_classes.push("-selected");
}
let glyph_style = crate::css::resolve_component_style(self, &glyph_classes)
.to_rich()
.unwrap_or_else(rich_rs::Style::new);
let label_style = crate::css::resolve_component_style(self, &label_classes)
.to_rich()
.unwrap_or_else(rich_rs::Style::new);
let glyph_bg = glyph_style.bgcolor;
let outer_bg = crate::style::parse_color_like("$surface")
.unwrap_or(crate::style::Color::rgb(0, 0, 0))
.to_simple_opaque();
let left_style = rich_rs::Style::new()
.with_color(glyph_bg.unwrap_or(outer_bg))
.with_bgcolor(outer_bg);
let right_style = left_style;
let segments = vec![
Segment::styled("▐".to_string(), left_style),
Segment::styled(glyph.to_string(), glyph_style),
Segment::styled("▌".to_string(), right_style),
Segment::styled(format!(" {}", button.label()), label_style),
];
let line = adjust_line_length_no_bg(&segments, width);
out.extend(line);
}
if row + 1 < height {
out.push(Segment::line());
}
}
out
}
fn layout_height(&self) -> Option<usize> {
fixed_height_from_constraints(self.layout_constraints()).or(Some(self.buttons.len().max(1)))
}
fn content_width(&self) -> Option<usize> {
let content_width = self
.buttons
.iter()
.map(|b| {
rich_rs::cell_len(b.label()).saturating_add(4)
})
.max()
.unwrap_or(4)
.max(1);
let meta = crate::css::selector_meta_generic(self);
let resolved = crate::css::resolve_style(self, &meta);
let padding = resolved.effective_padding();
let (_, _, border_left, border_right) =
super::helpers::border_spacing_from_style(&resolved);
let chrome_lr =
usize::from(padding.left.saturating_add(padding.right)) + border_left + border_right;
Some(content_width.saturating_add(chrome_lr).max(1))
}
fn style_classes(&self) -> &[String] {
if self.focused {
&self.focused_classes
} else if self.classes.is_empty() {
empty_classes()
} else {
&self.classes
}
}
fn style_type(&self) -> &'static str {
"RadioSet"
}
fn styles(&self) -> Option<&WidgetStyles> {
Some(&self.styles)
}
fn styles_mut(&mut self) -> Option<&mut WidgetStyles> {
Some(&mut self.styles)
}
}
impl Renderable for RadioSet {
fn render(&self, console: &Console, options: &ConsoleOptions) -> Segments {
Widget::render(self, console, options)
}
}
impl ReactiveWidget for RadioSet {}
#[cfg(test)]
mod tests {
use super::*;
use crate::keys::KeyEventData;
use crate::node_id::NodeId;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test]
fn radio_set_space_changes_selection_and_emits_message() {
let mut set = RadioSet::from_labels(&["A", "B", "C"]);
set.set_focus(true);
let down = KeyEventData::from_crossterm(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
let mut ctx1 = EventCtx::default();
set.on_event(&Event::Key(down), &mut ctx1);
assert_eq!(set.selected_index(), 1);
let space =
KeyEventData::from_crossterm(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
let mut ctx2 = EventCtx::default();
set.on_event(&Event::Key(space), &mut ctx2);
assert_eq!(set.pressed_index(), Some(1));
let messages = ctx2.take_messages();
assert!(messages.iter().any(|m| matches!(
m.message,
Message::RadioSetChanged(RadioSetChanged { index: 1, .. })
)));
}
#[test]
fn radio_set_cannot_deselect_active_button() {
let mut set = RadioSet::new().with_button(RadioButton::new("A").with_value(true));
set.set_focus(true);
let space =
KeyEventData::from_crossterm(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
let mut ctx = EventCtx::default();
set.on_event(&Event::Key(space), &mut ctx);
assert_eq!(set.pressed_index(), Some(0));
}
#[test]
fn radio_set_navigation_skips_disabled_buttons() {
let mut set = RadioSet::new()
.with_button(RadioButton::new("A").disabled(true))
.with_button(RadioButton::new("B"))
.with_button(RadioButton::new("C").disabled(true))
.with_button(RadioButton::new("D"));
set.set_focus(true);
assert_eq!(set.selected_index(), 1);
let down = KeyEventData::from_crossterm(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
let mut ctx = EventCtx::default();
set.on_event(&Event::Key(down), &mut ctx);
assert_eq!(set.selected_index(), 3);
}
#[test]
fn radio_set_click_on_disabled_button_is_ignored() {
let mut set = RadioSet::new()
.with_button(RadioButton::new("A"))
.with_button(RadioButton::new("B").disabled(true));
set.set_focus(true);
let mut ctx = EventCtx::default();
set.on_event(
&Event::MouseDown(crate::event::MouseDownEvent {
target: NodeId::default(),
screen_x: 0,
screen_y: 1,
x: 0,
y: 1,
}),
&mut ctx,
);
assert!(!ctx.handled());
assert_eq!(set.pressed_index(), None);
}
#[test]
fn radio_set_disabled_ignores_input() {
let mut set = RadioSet::from_labels(&["A", "B"]).disabled(true);
set.set_focus(true);
let down = KeyEventData::from_crossterm(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
let mut ctx = EventCtx::default();
set.on_event(&Event::Key(down), &mut ctx);
assert!(!ctx.handled());
assert!(!set.focusable());
}
#[test]
fn compose_returns_empty() {
let set = RadioSet::from_labels(&["A", "B"]);
assert!(set.compose().is_empty());
}
#[test]
fn children_accessor_returns_buttons() {
let set = RadioSet::from_labels(&["A", "B", "C"]);
assert_eq!(set.children().len(), 3);
assert_eq!(set.children()[0].label(), "A");
assert_eq!(set.children()[2].label(), "C");
}
#[test]
fn children_mut_allows_modification() {
let mut set = RadioSet::from_labels(&["A", "B"]);
set.children_mut().push(RadioButton::new("C"));
assert_eq!(set.len(), 3);
}
#[test]
fn take_composed_children_drains_buttons() {
let mut set = RadioSet::from_labels(&["A", "B", "C"]);
let children = set.take_composed_children();
assert_eq!(children.len(), 3);
assert!(set.is_empty());
}
fn make_node_id() -> NodeId {
use slotmap::SlotMap;
let mut sm: SlotMap<NodeId, ()> = SlotMap::new();
sm.insert(())
}
#[test]
fn mouse_click_with_dispatch_context_is_handled() {
use crate::runtime::dispatch_ctx::set_dispatch_recipient;
let mut set = RadioSet::from_labels(&["A", "B"]);
set.set_focus(true);
let id = make_node_id();
let _guard = set_dispatch_recipient(id);
let mut ctx = EventCtx::default();
set.on_event(
&Event::MouseDown(crate::event::MouseDownEvent {
target: id,
screen_x: 0,
screen_y: 0,
x: 0,
y: 0,
}),
&mut ctx,
);
assert_eq!(set.pressed_index(), Some(0));
}
#[test]
fn mouse_click_with_wrong_target_is_ignored() {
use crate::runtime::dispatch_ctx::set_dispatch_recipient;
use slotmap::SlotMap;
let mut set = RadioSet::from_labels(&["A", "B"]);
set.set_focus(true);
let mut sm: SlotMap<NodeId, ()> = SlotMap::new();
let my_id = sm.insert(());
let other_id = sm.insert(());
let _guard = set_dispatch_recipient(my_id);
let mut ctx = EventCtx::default();
set.on_event(
&Event::MouseDown(crate::event::MouseDownEvent {
target: other_id,
screen_x: 0,
screen_y: 0,
x: 0,
y: 0,
}),
&mut ctx,
);
assert!(!ctx.handled());
assert_eq!(set.pressed_index(), None);
}
}