1use std::rc::Rc;
23
24use frust_core::accesskit::{Action, Role};
25use frust_core::{
26 BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent, LayoutCtx,
27 PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
28};
29use frust_scene::arc_path;
30use frust_theme::Theme;
31use kurbo::{Point, Size};
32use peniko::{Brush, Color};
33
34use crate::authoring::presses;
35use crate::text;
36
37const DIAMETER: f64 = 20.0;
41const GAP: f64 = 8.0;
43const RING_STROKE: f64 = 2.0;
45const DOT_RADIUS: f64 = 5.0;
47const RING_OFF: Color = Color::from_rgb8(0xE5, 0xE7, 0xEB);
50const RING_ON: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
57
58fn resolve_color(theme: Option<&Theme>, selected: bool) -> Color {
62 match theme {
63 Some(theme) => {
64 let scheme = theme.scheme();
65 if selected {
66 scheme.primary
67 } else {
68 scheme.outline
69 }
70 }
71 None => {
72 if selected {
73 RING_ON
74 } else {
75 RING_OFF
76 }
77 }
78 }
79}
80
81type OnSelect<State> = Rc<dyn Fn(&mut State)>;
83
84pub struct RadioView<State: 'static> {
86 selected: bool,
87 label: String,
88 on_select: Option<OnSelect<State>>,
89}
90
91pub fn radio<State: 'static>(selected: bool, label: impl Into<String>) -> RadioView<State> {
96 RadioView {
97 selected,
98 label: label.into(),
99 on_select: None,
100 }
101}
102
103#[allow(non_snake_case)]
105pub fn Radio<State: 'static>(selected: bool, label: impl Into<String>) -> RadioView<State> {
106 radio(selected, label)
107}
108
109impl<State: 'static> RadioView<State> {
110 pub fn on_select<F: Fn(&mut State) + 'static>(mut self, on_select: F) -> Self {
113 self.on_select = Some(Rc::new(on_select));
114 self
115 }
116}
117
118pub struct RadioWidget {
120 selected: bool,
121 label: ChildPod,
122 label_text: String,
126 pressed: bool,
128 captured: bool,
132 on_select: Option<crate::authoring::ErasedCallback>,
133}
134
135fn inside(pos: Point, size: Size) -> bool {
136 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
137}
138
139impl<State: 'static> View<State> for RadioView<State> {
140 type Element = RadioWidget;
141
142 fn build(&self, ctx: &mut BuildCtx<'_>) -> RadioWidget {
143 let label_view = any::<State, _>(text(self.label.clone()));
144 RadioWidget {
145 selected: self.selected,
146 label: crate::authoring::build_child(&label_view, ctx),
147 label_text: self.label.clone(),
148 pressed: false,
149 captured: false,
150 on_select: self
151 .on_select
152 .as_ref()
153 .map(crate::authoring::erase_callback),
154 }
155 }
156
157 fn rebuild(
158 &self,
159 prev: &Self,
160 element: &mut RadioWidget,
161 ctx: &mut BuildCtx<'_>,
162 ) -> ChangeFlags {
163 element.on_select = self
165 .on_select
166 .as_ref()
167 .map(crate::authoring::erase_callback);
168 let mut flags = ChangeFlags::NONE;
169 if prev.selected != self.selected {
170 element.selected = self.selected;
172 flags |= ChangeFlags::PAINT;
173 }
174 if prev.label != self.label {
175 element.label_text = self.label.clone();
176 let prev_view = any::<State, _>(text(prev.label.clone()));
177 let next_view = any::<State, _>(text(self.label.clone()));
178 flags |=
179 crate::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
180 }
181 flags
182 }
183
184 fn teardown(&self, element: &mut RadioWidget, ctx: &mut BuildCtx<'_>) {
185 let label_view = any::<State, _>(text(self.label.clone()));
186 crate::authoring::teardown_child(&label_view, &mut element.label, ctx);
187 }
188}
189
190impl Widget for RadioWidget {
191 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
192 let label_max = Size::new((bc.max().width - DIAMETER - GAP).max(0.0), bc.max().height);
193 let label_size = self
194 .label
195 .layout_child(ctx, &BoxConstraints::loose(label_max));
196 let height = label_size.height.max(DIAMETER);
197 self.label.set_origin(Point::new(
199 DIAMETER + GAP,
200 (height - label_size.height) / 2.0,
201 ));
202 bc.constrain(Size::new(DIAMETER + GAP + label_size.width, height))
203 }
204
205 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
206 let color = resolve_color(Theme::from_paint_ctx(ctx), self.selected);
207 let brush = Brush::Solid(color);
208 let center = Point::new(
209 ctx.origin().x + DIAMETER / 2.0,
210 ctx.origin().y + ctx.size().height / 2.0,
211 );
212 let ring_radius = (DIAMETER - RING_STROKE) / 2.0;
213 let ring_path = arc_path(center, ring_radius, 0.0, std::f64::consts::TAU);
214 scene.stroke_path(Point::ZERO, &ring_path, RING_STROKE, &brush);
215 if self.selected {
216 let dot_path = arc_path(center, DOT_RADIUS, 0.0, std::f64::consts::TAU);
217 scene.fill_path(Point::ZERO, &dot_path, &brush);
218 }
219 self.label.paint_child(ctx, scene);
220 }
221
222 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
223 let InputEvent::Pointer(p) = event else {
224 return EventResult::Ignored;
225 };
226 match p.phase {
227 PointerPhase::Down => {
228 if !presses(p) {
229 return EventResult::Ignored;
230 }
231 self.pressed = true;
232 self.captured = true;
233 ctx.capture_pointer();
234 ctx.request_redraw();
235 EventResult::Handled
236 }
237 PointerPhase::Move => {
238 if !self.captured {
241 return EventResult::Ignored;
242 }
243 self.pressed = inside(p.position, ctx.size());
244 ctx.request_redraw();
245 EventResult::Handled
246 }
247 PointerPhase::Up => {
248 if !self.captured {
249 return EventResult::Ignored;
250 }
251 if inside(p.position, ctx.size())
252 && let Some(on_select) = self.on_select.as_mut()
253 {
254 (on_select)(ctx);
257 }
258 self.pressed = false;
259 self.captured = false;
260 ctx.request_redraw();
261 EventResult::Handled
262 }
263 PointerPhase::Cancel => {
264 if !self.captured {
265 return EventResult::Ignored;
266 }
267 self.pressed = false;
268 self.captured = false;
269 ctx.request_redraw();
270 EventResult::Handled
271 }
272 }
273 }
274
275 fn semantics(&self, ctx: &mut SemanticsCtx) {
276 ctx.push_node(Role::RadioButton, |node| {
279 node.set_label(self.label_text.as_str());
280 node.set_selected(self.selected);
281 node.add_action(Action::Click);
282 });
283 }
284
285 crate::authoring::visit_children!(label);
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use std::any::Any;
292
293 #[derive(Default)]
294 struct SelectState {
295 selects: u32,
296 }
297
298 fn widget(selected: bool) -> RadioWidget {
299 let view = radio::<SelectState>(selected, "daily").on_select(|s: &mut SelectState| {
300 s.selects += 1;
301 });
302 let mut counter = 0u64;
303 View::<SelectState>::build(&view, &mut BuildCtx::new(&mut counter))
304 }
305
306 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
307 InputEvent::Pointer(frust_core::PointerEvent {
308 phase,
309 position: Point::new(x, y),
310 button: frust_core::PointerButton::Primary,
311 })
312 }
313
314 fn dispatch(w: &mut RadioWidget, state: &mut SelectState, event: &InputEvent) {
315 let state_any: &mut dyn Any = state;
316 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
317 w.event(&mut ctx, event);
318 }
319
320 #[test]
321 fn unselected_fires_on_select_and_does_not_self_mutate() {
322 let mut w = widget(false);
323 let mut state = SelectState::default();
324 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
325 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
326 assert_eq!(state.selects, 1);
327 assert!(!w.selected, "radio must not mutate its own selected flag");
328 }
329
330 #[test]
331 fn already_selected_still_fires() {
332 let mut w = widget(true);
333 let mut state = SelectState::default();
334 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
335 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
336 assert_eq!(state.selects, 1);
337 assert!(w.selected, "still selected until the app rebuilds it");
338 }
339
340 #[test]
341 fn up_outside_does_not_fire() {
342 let mut w = widget(false);
343 let mut state = SelectState::default();
344 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
345 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
346 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
347 assert_eq!(state.selects, 0);
348 }
349
350 #[test]
351 fn hover_move_without_down_is_ignored_noop() {
352 let mut w = widget(false);
353 let mut state = SelectState::default();
354 let state_any: &mut dyn Any = &mut state;
355 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
356 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
357 assert!(matches!(result, EventResult::Ignored));
358 assert!(!w.pressed, "hover must not press");
359 assert!(!ctx.needs_redraw(), "hover must not request a redraw");
360 assert_eq!(state.selects, 0);
361 }
362
363 #[test]
364 fn up_without_down_does_not_fire() {
365 let mut w = widget(false);
366 let mut state = SelectState::default();
367 let state_any: &mut dyn Any = &mut state;
368 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
369 let result = w.event(&mut ctx, &ev(PointerPhase::Up, 5.0, 12.0));
370 assert!(matches!(result, EventResult::Ignored));
371 assert_eq!(state.selects, 0);
372 }
373
374 #[test]
375 fn cancel_clears_armed_state() {
376 let mut w = widget(false);
377 let mut state = SelectState::default();
378 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
379 assert!(w.captured);
380 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 12.0));
381 assert!(!w.captured, "Cancel disarms the press");
382 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
384 assert_eq!(state.selects, 0);
385 }
386
387 #[derive(Default)]
389 struct RingRecorder {
390 strokes: Vec<Color>,
391 fills: Vec<Color>,
392 }
393
394 impl PaintScene for RingRecorder {
395 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
396 fn draw_text(&mut self, _o: Point, _t: &str) {}
397 fn stroke_path(&mut self, _o: Point, _path: &kurbo::BezPath, _width: f64, brush: &Brush) {
398 if let Brush::Solid(c) = brush {
399 self.strokes.push(*c);
400 }
401 }
402 fn fill_path(&mut self, _o: Point, _path: &kurbo::BezPath, brush: &Brush) {
403 if let Brush::Solid(c) = brush {
404 self.fills.push(*c);
405 }
406 }
407 }
408
409 fn paint_rec(w: &mut RadioWidget, theme: Option<&Theme>) -> RingRecorder {
410 let mut rec = RingRecorder::default();
411 let mut ctx = match theme {
412 Some(t) => PaintCtx::new(Point::ZERO, Size::new(120.0, 24.0)).with_theme(t),
413 None => PaintCtx::new(Point::ZERO, Size::new(120.0, 24.0)),
414 };
415 w.paint(&mut ctx, &mut rec);
416 rec
417 }
418
419 #[test]
420 fn unthemed_paint_uses_fallback_constants() {
421 let mut off = widget(false);
422 let rec = paint_rec(&mut off, None);
423 assert_eq!(rec.strokes, vec![RING_OFF]);
424 assert!(rec.fills.is_empty(), "unselected radio paints no dot");
425 let mut on = widget(true);
426 let rec = paint_rec(&mut on, None);
427 assert_eq!(rec.strokes, vec![RING_ON]);
428 assert_eq!(rec.fills, vec![RING_ON]);
429 }
430
431 #[test]
432 fn themed_paint_resolves_roles() {
433 let theme = Theme::neutral();
434 let scheme = theme.scheme();
435 let mut off = widget(false);
436 assert_eq!(
437 paint_rec(&mut off, Some(&theme)).strokes,
438 vec![scheme.outline],
439 "unselected ring uses the outline role"
440 );
441 let mut on = widget(true);
442 let rec = paint_rec(&mut on, Some(&theme));
443 assert_eq!(
444 rec.strokes,
445 vec![scheme.primary],
446 "selected ring uses primary"
447 );
448 assert_eq!(rec.fills, vec![scheme.primary], "dot uses primary");
449 }
450
451 #[test]
452 fn rebuild_adopts_new_selected_value() {
453 let mut counter = 0u64;
454 let prev = radio::<SelectState>(false, "daily");
455 let mut w = View::<SelectState>::build(&prev, &mut BuildCtx::new(&mut counter));
456 assert!(!w.selected);
457 let next = radio::<SelectState>(true, "daily");
458 let flags =
459 View::<SelectState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
460 assert!(w.selected);
461 assert!(flags.needs_paint());
462 }
463
464 #[test]
465 fn semantics_reports_role_label_selected_and_bounds() {
466 fn logic(_s: &mut ()) -> RadioView<()> {
467 radio::<()>(true, "daily")
468 }
469 let mut root: frust_core::RenderRoot<(), RadioView<()>> = frust_core::RenderRoot::new();
470 let mut state = ();
471 root.rebuild(&mut logic, &mut state);
472 let mut tcx = frust_text::TextContext::new();
473 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
474 let update = root.semantics();
475 let (_, node) = update
476 .nodes
477 .iter()
478 .find(|(_, n)| n.role() == Role::RadioButton)
479 .expect("radio contributes a Role::RadioButton node");
480 assert_eq!(node.label(), Some("daily"));
481 assert_eq!(node.is_selected(), Some(true));
482 let bounds = node.bounds().expect("radio node has bounds");
483 assert_eq!((bounds.x0, bounds.y0), (0.0, 0.0));
484 }
485}