use alloc::string::String;
use alloc::vec::Vec;
use denise::{ElementState, InputEvent, KeyCode, Point, Rect, Role, Theme};
use denise_render::Canvas;
use denise_text::{TextEngine, TextStyle};
use crate::widget::{Event, EventCtx, Handled, PaintCtx, VisualState, Widget};
use crate::widgets::style::{Align, draw_aligned, focus_ring, interactive_pair};
#[derive(Clone, Debug)]
pub struct RadioGroup<M> {
options: Vec<String>,
selected: usize,
message: Option<fn(usize) -> M>,
role: Role,
style: TextStyle,
}
impl<M> RadioGroup<M> {
pub fn new(
options: impl IntoIterator<Item = impl Into<String>>,
message: fn(usize) -> M,
) -> Self {
Self {
options: options.into_iter().map(Into::into).collect(),
selected: 0,
message: Some(message),
role: Role::Primary,
style: TextStyle::built_in(16),
}
}
pub fn inert(options: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
options: options.into_iter().map(Into::into).collect(),
selected: 0,
message: None,
role: Role::Primary,
style: TextStyle::built_in(16),
}
}
pub fn with_selected(mut self, index: usize) -> Self {
self.selected = self.clamp(index);
self
}
pub fn with_role(mut self, role: Role) -> Self {
self.role = role;
self
}
pub fn with_style(mut self, style: TextStyle) -> Self {
self.style = style;
self
}
pub fn with_size(mut self, size_px: u16) -> Self {
self.style.size_px = size_px;
self
}
#[inline]
pub const fn selected(&self) -> usize {
self.selected
}
#[inline]
pub fn selected_label(&self) -> Option<&str> {
self.options.get(self.selected).map(String::as_str)
}
pub fn set_selected(&mut self, index: usize) {
self.selected = self.clamp(index);
}
#[inline]
pub fn options(&self) -> &[String] {
&self.options
}
pub fn set_options(&mut self, options: impl IntoIterator<Item = impl Into<String>>) {
self.options = options.into_iter().map(Into::into).collect();
self.selected = self.clamp(self.selected);
}
pub fn set_role(&mut self, role: Role) {
self.role = role;
}
pub fn set_style(&mut self, style: TextStyle) {
self.style = style;
}
pub fn preferred_width(&self, theme: &Theme, engine: &mut TextEngine) -> i32 {
let side = theme.metrics.size_selector;
let widest = self
.options
.iter()
.map(|option| engine.measure_line(self.style, option))
.max()
.unwrap_or(0);
if widest == 0 {
side
} else {
side + gap(side) + widest
}
}
pub fn preferred_height(&self, theme: &Theme) -> i32 {
let row = theme.metrics.size_selector * 3 / 2;
row * self.options.len().max(1) as i32
}
#[inline]
fn clamp(&self, index: usize) -> usize {
index.min(self.options.len().saturating_sub(1))
}
fn step(&self, forward: bool) -> usize {
let count = self.options.len();
if count == 0 {
return 0;
}
if forward {
(self.selected + 1) % count
} else {
(self.selected + count - 1) % count
}
}
}
#[inline]
const fn gap(side: i32) -> i32 {
if side < 2 { 1 } else { side / 2 }
}
fn row_rect(bounds: Rect, count: usize, index: usize) -> Rect {
if count == 0 {
return Rect::new(bounds.x, bounds.y, bounds.width, 0);
}
let edge = |i: usize| -> i32 {
(i64::from(bounds.height) * i as i64 / count as i64) as i32 + bounds.y
};
Rect::from_edges(bounds.x, edge(index), bounds.right(), edge(index + 1))
}
fn row_at(bounds: Rect, count: usize, point: Point) -> Option<usize> {
if count == 0 || !bounds.contains(point) {
return None;
}
let offset = i64::from(point.y - bounds.y);
let index = (offset * count as i64 / i64::from(bounds.height.max(1))) as usize;
Some(index.min(count - 1))
}
fn circle_rect(row: Rect, theme: &Theme) -> Rect {
let side = theme
.metrics
.size_selector
.min(row.height)
.min(row.width)
.max(1);
Rect::new(row.x, row.y + (row.height - side) / 2, side, side)
}
impl<M: 'static> Widget<M> for RadioGroup<M> {
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Canvas<'_>) {
let count = self.options.len();
if count == 0 {
return;
}
let label_color = interactive_pair(ctx.theme, Role::Base100, ctx.state).1;
for (index, option) in self.options.iter().enumerate() {
let row = row_rect(ctx.bounds, count, index);
if row.is_empty() {
continue;
}
let circle = circle_rect(row, ctx.theme);
let radius = circle.width / 2;
if index == self.selected {
let (fill, dot) = interactive_pair(ctx.theme, self.role, ctx.state);
canvas.fill_rounded_rect(circle, radius, fill);
let inset = (circle.width * 2 / 5).max(1);
let centre = circle.inflate(-inset);
if !centre.is_empty() {
canvas.fill_rounded_rect(centre, centre.width / 2, dot);
}
if ctx.state.contains(VisualState::FOCUSED) {
focus_ring(
ctx.theme,
row,
ctx.theme.radius(denise::Radius::Field),
canvas,
);
}
} else {
let (surface, _) = interactive_pair(ctx.theme, Role::Base100, ctx.state);
canvas.fill_rounded_rect(circle, radius, surface);
canvas.stroke_rounded_rect(
circle,
radius,
ctx.theme.metrics.border,
ctx.theme.color(Role::Base300),
);
}
if option.is_empty() {
continue;
}
let text = Rect::from_edges(
circle.right() + gap(circle.width),
row.y,
row.right(),
row.bottom(),
);
if !text.is_empty() {
draw_aligned(
canvas,
ctx.text,
self.style,
text,
(Align::Start, Align::Center),
option,
label_color,
);
}
}
}
fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
let count = self.options.len();
if count == 0 {
return Handled::No;
}
let chosen = match event {
Event::Input(InputEvent::PointerButton {
state: ElementState::Up,
position,
..
}) => row_at(ctx.bounds, count, *position),
Event::Input(InputEvent::TouchUp {
position,
cancelled: false,
..
}) => row_at(ctx.bounds, count, *position),
Event::Input(InputEvent::Key {
code: code @ (KeyCode::ArrowDown | KeyCode::ArrowRight),
state: ElementState::Down,
..
})
| Event::Input(InputEvent::Key {
code: code @ (KeyCode::ArrowUp | KeyCode::ArrowLeft),
state: ElementState::Down,
..
}) if ctx.state.contains(VisualState::FOCUSED) => {
let forward = matches!(code, KeyCode::ArrowDown | KeyCode::ArrowRight);
Some(self.step(forward))
}
_ => return Handled::No,
};
let Some(chosen) = chosen else {
return Handled::No;
};
if chosen == self.selected {
return Handled::Yes;
}
self.selected = chosen;
if let Some(message) = self.message {
ctx.emit(message(chosen));
}
Handled::Yes
}
fn accepts_pointer(&self) -> bool {
true
}
fn focusable(&self) -> bool {
!self.options.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use denise::theme;
fn group() -> RadioGroup<usize> {
RadioGroup::new(["Auto", "Manual", "Off"], |index| index)
}
#[test]
fn rows_tile_the_bounds_with_no_gap_and_no_overlap() {
for count in 1..=7 {
let bounds = Rect::new(10, 20, 200, 101);
let mut previous_bottom = bounds.y;
for index in 0..count {
let row = row_rect(bounds, count, index);
assert_eq!(row.y, previous_bottom, "count {count}, row {index}");
assert_eq!(row.x, bounds.x);
assert_eq!(row.right(), bounds.right());
previous_bottom = row.bottom();
}
assert_eq!(
previous_bottom,
bounds.bottom(),
"count {count} left the last row short of the bottom"
);
}
}
#[test]
fn a_point_lands_in_the_row_that_contains_it() {
let bounds = Rect::new(10, 20, 200, 90);
let count = 3;
for index in 0..count {
let row = row_rect(bounds, count, index);
for y in row.y..row.bottom() {
assert_eq!(
row_at(bounds, count, Point::new(bounds.x + 5, y)),
Some(index),
"y {y} should be row {index}"
);
}
}
assert_eq!(row_at(bounds, count, Point::new(5, 25)), None, "left of it");
assert_eq!(row_at(bounds, count, Point::new(15, 5)), None, "above it");
assert_eq!(row_at(bounds, count, Point::new(15, 500)), None, "below it");
}
#[test]
fn an_absurd_rectangle_neither_overflows_nor_panics() {
let bounds = Rect::new(0, 0, 1000, i32::MAX);
for index in 0..4 {
let row = row_rect(bounds, 4, index);
assert!(row.height > 0, "row {index} of a tall group vanished");
}
assert!(row_at(bounds, 4, Point::new(1, i32::MAX / 2)).is_some());
}
#[test]
fn arrows_wrap_in_both_directions() {
let mut group = group();
assert_eq!(group.selected(), 0);
assert_eq!(group.step(true), 1);
group.set_selected(2);
assert_eq!(group.step(true), 0, "past the end comes back to the start");
group.set_selected(0);
assert_eq!(group.step(false), 2, "and before the start goes to the end");
}
#[test]
fn a_single_option_group_steps_to_itself() {
let group: RadioGroup<usize> = RadioGroup::new(["Only"], |index| index);
assert_eq!(group.step(true), 0);
assert_eq!(group.step(false), 0);
}
#[test]
fn an_empty_group_is_inert_rather_than_broken() {
let mut group: RadioGroup<usize> = RadioGroup::inert(Vec::<String>::new());
assert_eq!(group.selected(), 0);
assert_eq!(group.selected_label(), None);
assert_eq!(group.step(true), 0);
assert!(!Widget::<usize>::focusable(&group));
group.set_selected(9);
assert_eq!(group.selected(), 0);
assert_eq!(row_rect(Rect::new(0, 0, 10, 10), 0, 0).height, 0);
}
#[test]
fn the_selection_is_always_an_option_that_exists() {
let mut group = group();
group.set_selected(99);
assert_eq!(group.selected(), 2);
assert_eq!(group.selected_label(), Some("Off"));
group.set_options(["One"]);
assert_eq!(group.selected(), 0);
assert_eq!(group.selected_label(), Some("One"));
}
#[test]
fn the_dot_is_visible_inside_the_disc_in_every_theme_and_state() {
use denise::theme::{AA_LARGE, contrast_x100};
for theme in Theme::BUILT_IN {
for state in [
VisualState::NONE,
VisualState::HOVERED,
VisualState::PRESSED,
VisualState::DISABLED,
VisualState::FOCUSED,
] {
let (disc, dot) = interactive_pair(&theme, Role::Primary, state);
let ratio = contrast_x100(disc, dot);
assert!(
ratio >= AA_LARGE,
"{} {state:?}: dot against disc is {ratio}, floor is {AA_LARGE}",
theme.name
);
}
}
}
#[test]
fn the_circle_stays_square_and_inside_its_row() {
for height in [1, 7, 20, 60] {
let row = Rect::new(10, 20, 200, height);
let circle = circle_rect(row, &theme::DARK);
assert_eq!(circle.width, circle.height, "height {height}");
assert!(circle.width >= 1, "height {height}");
assert!(
row.contains_rect(&circle),
"height {height}: {circle:?} escaped {row:?}"
);
}
}
#[test]
fn the_preferred_width_follows_the_widest_option() {
let mut engine = TextEngine::new();
let group = group();
let side = theme::DARK.metrics.size_selector;
let widest = engine.measure_line(TextStyle::built_in(16), "Manual");
assert_eq!(
group.preferred_width(&theme::DARK, &mut engine),
side + gap(side) + widest
);
}
}