use crate::event::{Key, NamedKey, Point, Rect};
use crate::types::{Modifiers, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RadioOption {
pub id: WidgetId,
pub label: String,
#[serde(default)]
pub disabled: bool,
}
impl RadioOption {
pub fn new(id: WidgetId, label: impl Into<String>) -> Self {
Self {
id,
label: label.into(),
disabled: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RadioGroup {
pub id: WidgetId,
pub options: Vec<RadioOption>,
#[serde(default)]
pub selected: Option<usize>,
#[serde(default)]
pub focused: bool,
}
impl RadioGroup {
pub fn new(id: WidgetId, options: Vec<RadioOption>) -> Self {
Self {
id,
options,
selected: None,
focused: false,
}
}
pub fn layout<F>(&self, bounds: Rect, measure: F) -> RadioGroupLayout
where
F: Fn(&RadioOption) -> RadioOptionMeasure,
{
let mut items = Vec::with_capacity(self.options.len());
let mut y = bounds.y;
for option in &self.options {
let size = measure(option);
let height = size.height.min(bounds.y + bounds.height - y).max(0.0);
items.push(Rect::new(bounds.x, y, bounds.width.min(size.width), height));
y += size.height;
}
RadioGroupLayout { bounds, items }
}
pub fn handle_key(&self, key: &Key, _modifiers: &Modifiers) -> Option<RadioGroupEvent> {
if !self.focused {
return None;
}
let delta = match key {
Key::Named(NamedKey::Up) | Key::Named(NamedKey::Left) => -1,
Key::Named(NamedKey::Down) | Key::Named(NamedKey::Right) => 1,
_ => return None,
};
self.move_selection(delta)
.map(|index| RadioGroupEvent::SelectionChanged { index })
}
pub fn select(&self, index: usize) -> Option<RadioGroupEvent> {
let option = self.options.get(index)?;
if option.disabled {
return None;
}
Some(RadioGroupEvent::SelectionChanged { index })
}
fn move_selection(&self, delta: isize) -> Option<usize> {
let len = self.options.len();
if len == 0 {
return None;
}
let len_i = len as isize;
let mut idx = match self.selected {
None if delta >= 0 => -1,
None => 0,
Some(i) => i as isize,
};
for _ in 0..len {
idx = (idx + delta).rem_euclid(len_i);
let candidate = idx as usize;
if !self.options[candidate].disabled {
return Some(candidate);
}
}
None
}
}
impl Default for RadioGroup {
fn default() -> Self {
Self::new(WidgetId::new(""), Vec::new())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RadioGroupLayout {
pub bounds: Rect,
pub items: Vec<Rect>,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct RadioOptionMeasure {
pub width: f32,
pub height: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RadioGroupHit {
Option(usize),
Outside,
}
impl RadioGroupLayout {
pub fn hit_test(&self, p: Point) -> RadioGroupHit {
for (index, item) in self.items.iter().enumerate() {
if item.contains(p) {
return RadioGroupHit::Option(index);
}
}
RadioGroupHit::Outside
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{RadioGroup, RadioGroupLayout, RadioOption, RadioOptionMeasure};
use crate::event::Rect;
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
pub(crate) fn paint(
group: &RadioGroup,
surface: &mut dyn PaintSurface,
theme: &Theme,
rect: Rect,
row_height: f32,
) -> RadioGroupLayout {
let layout = group.layout(rect, |_option: &RadioOption| RadioOptionMeasure {
width: rect.width,
height: row_height,
});
for (index, option) in group.options.iter().enumerate() {
let Some(item) = layout.items.get(index) else {
continue;
};
if item.width <= 0.0 || item.height <= 0.0 {
continue;
}
let selected = group.selected == Some(index);
let marker_color = if option.disabled {
theme.muted_fg
} else if selected {
theme.accent_fg
} else {
theme.border_fg
};
let marker_size = item.height.min(item.width).max(0.0);
let marker_rect = Rect::new(item.x, item.y, marker_size, marker_size);
if selected {
surface.surface_fill_rounded_rect(marker_rect, marker_size / 2.0, marker_color);
} else {
surface.surface_stroke_rounded_rect(
marker_rect,
marker_size / 2.0,
marker_color,
1.0,
);
}
if !option.label.is_empty() {
let label_x = item.x + marker_size + 4.0;
let label_rect = Rect::new(
label_x,
item.y,
(item.x + item.width - label_x).max(0.0),
item.height,
);
let fg = if option.disabled {
theme.muted_fg
} else {
theme.foreground
};
surface.surface_draw_text_run(label_rect, &option.label, fg);
}
}
layout
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RadioGroupEvent {
SelectionChanged { index: usize },
}
#[cfg(test)]
mod tests {
use super::*;
fn options() -> Vec<RadioOption> {
vec![
RadioOption::new(WidgetId::new("theme:light"), "Light"),
RadioOption::new(WidgetId::new("theme:dark"), "Dark"),
RadioOption::new(WidgetId::new("theme:auto"), "Auto"),
]
}
fn group() -> RadioGroup {
RadioGroup::new(WidgetId::new("settings:theme"), options())
}
fn measure(_option: &RadioOption) -> RadioOptionMeasure {
RadioOptionMeasure {
width: 10.0,
height: 1.0,
}
}
#[test]
fn new_defaults_to_unselected_and_unfocused() {
let g = group();
assert_eq!(g.selected, None);
assert!(!g.focused);
assert_eq!(g.options.len(), 3);
}
#[test]
fn layout_stacks_options_vertically_and_hit_tests_by_index() {
let g = group();
let layout = g.layout(Rect::new(0.0, 0.0, 10.0, 3.0), measure);
assert_eq!(layout.items.len(), 3);
assert_eq!(
layout.hit_test(Point::new(1.0, 0.0)),
RadioGroupHit::Option(0)
);
assert_eq!(
layout.hit_test(Point::new(1.0, 1.0)),
RadioGroupHit::Option(1)
);
assert_eq!(
layout.hit_test(Point::new(1.0, 2.0)),
RadioGroupHit::Option(2)
);
assert_eq!(
layout.hit_test(Point::new(1.0, 5.0)),
RadioGroupHit::Outside
);
}
#[test]
fn handle_key_down_seeds_at_first_option_when_unselected() {
let mut g = group();
g.focused = true;
let event = g.handle_key(&Key::Named(NamedKey::Down), &Modifiers::default());
assert_eq!(event, Some(RadioGroupEvent::SelectionChanged { index: 0 }));
}
#[test]
fn handle_key_up_seeds_at_last_option_when_unselected() {
let mut g = group();
g.focused = true;
let event = g.handle_key(&Key::Named(NamedKey::Up), &Modifiers::default());
assert_eq!(event, Some(RadioGroupEvent::SelectionChanged { index: 2 }));
}
#[test]
fn handle_key_down_wraps_from_last_to_first() {
let mut g = group();
g.focused = true;
g.selected = Some(2);
let event = g.handle_key(&Key::Named(NamedKey::Right), &Modifiers::default());
assert_eq!(event, Some(RadioGroupEvent::SelectionChanged { index: 0 }));
}
#[test]
fn handle_key_up_wraps_from_first_to_last() {
let mut g = group();
g.focused = true;
g.selected = Some(0);
let event = g.handle_key(&Key::Named(NamedKey::Left), &Modifiers::default());
assert_eq!(event, Some(RadioGroupEvent::SelectionChanged { index: 2 }));
}
#[test]
fn handle_key_skips_disabled_options() {
let mut g = group();
g.focused = true;
g.options[1].disabled = true;
g.selected = Some(0);
let event = g.handle_key(&Key::Named(NamedKey::Down), &Modifiers::default());
assert_eq!(event, Some(RadioGroupEvent::SelectionChanged { index: 2 }));
}
#[test]
fn handle_key_returns_none_when_all_options_disabled() {
let mut g = group();
g.focused = true;
for option in &mut g.options {
option.disabled = true;
}
g.selected = Some(0);
assert_eq!(
g.handle_key(&Key::Named(NamedKey::Down), &Modifiers::default()),
None
);
}
#[test]
fn handle_key_ignores_unrelated_keys() {
let mut g = group();
g.focused = true;
assert_eq!(
g.handle_key(&Key::Named(NamedKey::Tab), &Modifiers::default()),
None
);
}
#[test]
fn handle_key_is_inert_when_not_focused() {
let g = group();
assert_eq!(
g.handle_key(&Key::Named(NamedKey::Down), &Modifiers::default()),
None
);
}
#[test]
fn select_fires_for_enabled_option_without_requiring_focus() {
let g = group();
assert_eq!(
g.select(1),
Some(RadioGroupEvent::SelectionChanged { index: 1 })
);
}
#[test]
fn select_is_inert_for_disabled_option() {
let mut g = group();
g.options[1].disabled = true;
assert_eq!(g.select(1), None);
}
#[test]
fn select_is_inert_for_out_of_range_index() {
let g = group();
assert_eq!(g.select(99), None);
}
#[test]
fn serde_round_trip() {
let mut g = group();
g.selected = Some(1);
g.focused = true;
let json = serde_json::to_string(&g).unwrap();
let back: RadioGroup = serde_json::from_str(&json).unwrap();
assert_eq!(g, back);
}
#[test]
fn serde_defaults_selected_and_focused_when_omitted() {
let json = r#"{"id":"settings:theme","options":[]}"#;
let g: RadioGroup = serde_json::from_str(json).unwrap();
assert_eq!(g.selected, None);
assert!(!g.focused);
}
}