1use iced::widget::{self, button::Status, column, container, mouse_area, row, space, text};
4use iced::{Alignment, Background, Border, Color, Element, Length, Shadow, Theme, mouse};
5
6use crate::theme::{Tokens, fade, mix, radius, space as spacing, text_size};
7
8pub const CIRCLE_SIZE: f32 = 16.0;
10
11const DOT_SIZE: f32 = 8.0;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
15pub enum Direction {
16 #[default]
17 Vertical,
18 Horizontal,
19}
20
21impl Direction {
22 pub const ALL: [Direction; 2] = [Direction::Vertical, Direction::Horizontal];
23}
24
25#[derive(Debug)]
29pub struct Radio<'a, V, Message> {
30 label: text::Fragment<'a>,
31 value: V,
32 is_selected: bool,
33 on_select: Option<Message>,
34}
35
36pub fn radio<'a, V, Message>(
38 label: impl text::IntoFragment<'a>,
39 value: V,
40 selected: Option<V>,
41) -> Radio<'a, V, Message>
42where
43 V: Copy + Eq,
44{
45 Radio {
46 label: label.into_fragment(),
47 value,
48 is_selected: selected == Some(value),
49 on_select: None,
50 }
51}
52
53impl<'a, V: Copy, Message> Radio<'a, V, Message> {
54 pub fn on_select(mut self, on_select: impl FnOnce(V) -> Message) -> Self {
56 self.on_select = Some(on_select(self.value));
57 self
58 }
59
60 pub fn is_selected(&self) -> bool {
61 self.is_selected
62 }
63
64 pub fn is_enabled(&self) -> bool {
65 self.on_select.is_some()
66 }
67}
68
69impl<'a, V, Message: Clone + 'a> From<Radio<'a, V, Message>> for Element<'a, Message> {
70 fn from(radio: Radio<'a, V, Message>) -> Self {
71 let selected = radio.is_selected;
72 let resting = if radio.on_select.is_some() {
73 Status::Active
74 } else {
75 Status::Disabled
76 };
77
78 let dot: Element<'a, Message> = if selected {
79 container(space())
80 .width(DOT_SIZE)
81 .height(DOT_SIZE)
82 .style(move |theme: &Theme| container::Style {
83 background: Some(Background::Color(
84 colours(&Tokens::of(theme), selected, resting).dot,
85 )),
86 border: Border::default().rounded(radius::FULL),
87 ..container::Style::default()
88 })
89 .into()
90 } else {
91 space().into()
92 };
93
94 let circle = widget::button(container(dot).center(Length::Fill))
95 .width(CIRCLE_SIZE)
96 .height(CIRCLE_SIZE)
97 .padding(0)
98 .on_press_maybe(radio.on_select.clone())
99 .style(move |theme, status| style(&Tokens::of(theme), selected, status));
100
101 let label = text(radio.label)
102 .size(text_size::SM)
103 .style(move |theme: &Theme| text::Style {
104 color: Some(colours(&Tokens::of(theme), selected, resting).label),
105 });
106 let label = match radio.on_select {
107 Some(message) => mouse_area(label)
108 .on_press(message)
109 .interaction(mouse::Interaction::Pointer),
110 None => mouse_area(label),
111 };
112
113 row![circle, label]
114 .spacing(spacing::SM)
115 .align_y(Alignment::Center)
116 .into()
117 }
118}
119
120pub struct RadioGroup<'a, V, Message> {
124 options: Vec<V>,
125 selected: Option<V>,
126 direction: Direction,
127 on_select: Option<Box<dyn Fn(V) -> Message + 'a>>,
128}
129
130impl<V: std::fmt::Debug, Message> std::fmt::Debug for RadioGroup<'_, V, Message> {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 f.debug_struct("RadioGroup")
133 .field("options", &self.options)
134 .field("selected", &self.selected)
135 .field("direction", &self.direction)
136 .field("enabled", &self.on_select.is_some())
137 .finish()
138 }
139}
140
141pub fn radio_group<'a, V, Message>(
143 options: impl IntoIterator<Item = V>,
144 selected: Option<V>,
145) -> RadioGroup<'a, V, Message>
146where
147 V: Copy + Eq + ToString,
148{
149 RadioGroup {
150 options: options.into_iter().collect(),
151 selected,
152 direction: Direction::default(),
153 on_select: None,
154 }
155}
156
157impl<'a, V, Message> RadioGroup<'a, V, Message> {
158 pub fn direction(mut self, direction: Direction) -> Self {
159 self.direction = direction;
160 self
161 }
162
163 pub fn on_select(mut self, on_select: impl Fn(V) -> Message + 'a) -> Self {
165 self.on_select = Some(Box::new(on_select));
166 self
167 }
168
169 pub fn is_enabled(&self) -> bool {
170 self.on_select.is_some()
171 }
172}
173
174impl<'a, V, Message> From<RadioGroup<'a, V, Message>> for Element<'a, Message>
175where
176 V: Copy + Eq + ToString + 'a,
177 Message: Clone + 'a,
178{
179 fn from(group: RadioGroup<'a, V, Message>) -> Self {
180 let on_select = group.on_select;
181 let radios = group.options.into_iter().map(|value| {
182 let mut radio = radio(value.to_string(), value, group.selected);
183 radio.on_select = on_select.as_ref().map(|f| f(value));
184 Element::from(radio)
185 });
186
187 match group.direction {
188 Direction::Vertical => column(radios).spacing(spacing::MD).into(),
189 Direction::Horizontal => row(radios)
190 .spacing(spacing::XL)
191 .align_y(Alignment::Center)
192 .wrap()
193 .into(),
194 }
195 }
196}
197
198#[derive(Debug, Clone, Copy, PartialEq)]
200pub struct Colours {
201 pub background: Color,
202 pub border: Color,
203 pub dot: Color,
204 pub label: Color,
205}
206
207pub fn colours(tokens: &Tokens, selected: bool, status: Status) -> Colours {
209 let hover = matches!(status, Status::Hovered | Status::Pressed);
210 let pressed = status == Status::Pressed;
211
212 let border = match (selected, hover) {
213 (true, _) => tokens.primary,
214 (false, true) => mix(tokens.border, tokens.foreground, 0.6),
215 (false, false) => mix(tokens.border, tokens.foreground, 0.3),
216 };
217 let colours = Colours {
218 background: if pressed {
219 tokens.accent
220 } else {
221 Color::TRANSPARENT
222 },
223 border,
224 dot: tokens.primary,
225 label: tokens.foreground,
226 };
227
228 if status != Status::Disabled {
229 return colours;
230 }
231 Colours {
232 background: fade(colours.background, 0.5),
233 border: fade(colours.border, 0.5),
234 dot: fade(colours.dot, 0.5),
235 label: fade(colours.label, 0.5),
236 }
237}
238
239pub fn style(tokens: &Tokens, selected: bool, status: Status) -> widget::button::Style {
241 let colours = colours(tokens, selected, status);
242 widget::button::Style {
243 background: Some(Background::Color(colours.background)),
244 text_color: colours.label,
245 border: Border {
246 color: colours.border,
247 width: 1.0,
248 radius: radius::FULL.into(),
249 },
250 shadow: Shadow::default(),
251 snap: true,
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::theme::{dark, light};
259
260 const STATES: [Status; 4] = [
261 Status::Active,
262 Status::Hovered,
263 Status::Pressed,
264 Status::Disabled,
265 ];
266
267 #[test]
268 fn radio_is_selected_only_for_its_own_value() {
269 let r: Radio<'_, u8, ()> = radio("One", 1, Some(1));
270 assert!(r.is_selected());
271 let r: Radio<'_, u8, ()> = radio("One", 1, Some(2));
272 assert!(!r.is_selected());
273 let r: Radio<'_, u8, ()> = radio("One", 1, None);
274 assert!(!r.is_selected());
275 }
276
277 #[test]
278 fn radio_without_message_is_disabled() {
279 let r: Radio<'_, u8, u8> = radio("One", 1, None);
280 assert!(!r.is_enabled());
281 assert_eq!(r.on_select(|v| v * 10).on_select, Some(10));
282 }
283
284 #[test]
285 fn group_defaults_to_vertical_and_disabled() {
286 let g: RadioGroup<'_, u8, ()> = radio_group([1, 2, 3], None);
287 assert_eq!(g.direction, Direction::Vertical);
288 assert_eq!(g.options, vec![1, 2, 3]);
289 assert!(!g.is_enabled());
290 assert!(g.on_select(|_| ()).is_enabled());
291 }
292
293 #[test]
294 fn selected_border_uses_primary_in_every_enabled_status() {
295 for theme in [light(), dark()] {
296 let tokens = Tokens::of(&theme);
297 for status in [Status::Active, Status::Hovered, Status::Pressed] {
298 assert_eq!(colours(&tokens, true, status).border, tokens.primary);
299 }
300 }
301 }
302
303 #[test]
304 fn hover_strengthens_the_unselected_border() {
305 for theme in [light(), dark()] {
306 let tokens = Tokens::of(&theme);
307 let active = colours(&tokens, false, Status::Active);
308 let hovered = colours(&tokens, false, Status::Hovered);
309 assert_ne!(active.border, hovered.border);
310 }
311 }
312
313 #[test]
314 fn pressing_fills_the_background() {
315 let tokens = Tokens::of(&light());
316 for selected in [false, true] {
317 assert_eq!(
318 colours(&tokens, selected, Status::Pressed).background,
319 tokens.accent
320 );
321 }
322 }
323
324 #[test]
325 fn disabled_halves_alpha() {
326 for theme in [light(), dark()] {
327 let tokens = Tokens::of(&theme);
328 for selected in [false, true] {
329 let active = colours(&tokens, selected, Status::Active);
330 let disabled = colours(&tokens, selected, Status::Disabled);
331 assert!((disabled.dot.a - active.dot.a * 0.5).abs() < 1e-6);
332 assert!((disabled.label.a - active.label.a * 0.5).abs() < 1e-6);
333 }
334 }
335 }
336
337 #[test]
338 fn every_style_is_a_circle() {
339 for theme in [light(), dark()] {
340 let tokens = Tokens::of(&theme);
341 for selected in [false, true] {
342 for status in STATES {
343 let style = style(&tokens, selected, status);
344 assert_eq!(style.border.radius, radius::FULL.into());
345 assert_eq!(style.border.width, 1.0);
346 }
347 }
348 }
349 }
350}