1use std::rc::Rc;
28
29use frust_core::accesskit::{Action, Role};
30use frust_core::{
31 BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent, LayoutCtx,
32 PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
33};
34use frust_scene::arc_path;
35use frust_theme::Theme;
36use kurbo::{Point, Size};
37use peniko::{Brush, Color};
38
39use crate::authoring::presses;
40use crate::shortcuts::{activation, press_claims_focus};
41use crate::text;
42
43const DIAMETER: f64 = 20.0;
47const GAP: f64 = 8.0;
49const RING_STROKE: f64 = 2.0;
51const DOT_RADIUS: f64 = 5.0;
53const RING_OFF: Color = Color::from_rgb8(0xE5, 0xE7, 0xEB);
56const RING_ON: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
63
64fn resolve_color(theme: Option<&Theme>, selected: bool) -> Color {
68 match theme {
69 Some(theme) => {
70 let scheme = theme.scheme();
71 if selected {
72 scheme.primary
73 } else {
74 scheme.outline
75 }
76 }
77 None => {
78 if selected {
79 RING_ON
80 } else {
81 RING_OFF
82 }
83 }
84 }
85}
86
87type OnSelect<State> = Rc<dyn Fn(&mut State)>;
89
90pub struct RadioView<State: 'static> {
92 selected: bool,
93 label: String,
94 on_select: Option<OnSelect<State>>,
95}
96
97pub fn radio<State: 'static>(selected: bool, label: impl Into<String>) -> RadioView<State> {
102 RadioView {
103 selected,
104 label: label.into(),
105 on_select: None,
106 }
107}
108
109#[allow(non_snake_case)]
111pub fn Radio<State: 'static>(selected: bool, label: impl Into<String>) -> RadioView<State> {
112 radio(selected, label)
113}
114
115impl<State: 'static> RadioView<State> {
116 pub fn on_select<F: Fn(&mut State) + 'static>(mut self, on_select: F) -> Self {
119 self.on_select = Some(Rc::new(on_select));
120 self
121 }
122}
123
124pub struct RadioWidget {
126 selected: bool,
127 label: ChildPod,
128 label_text: String,
132 pressed: bool,
134 captured: bool,
138 on_select: Option<crate::authoring::ErasedCallback>,
139}
140
141fn inside(pos: Point, size: Size) -> bool {
142 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
143}
144
145impl<State: 'static> View<State> for RadioView<State> {
146 type Element = RadioWidget;
147
148 fn build(&self, ctx: &mut BuildCtx<'_>) -> RadioWidget {
149 let label_view = any::<State, _>(text(self.label.clone()));
150 RadioWidget {
151 selected: self.selected,
152 label: crate::authoring::build_child(&label_view, ctx),
153 label_text: self.label.clone(),
154 pressed: false,
155 captured: false,
156 on_select: self
157 .on_select
158 .as_ref()
159 .map(crate::authoring::erase_callback),
160 }
161 }
162
163 fn rebuild(
164 &self,
165 prev: &Self,
166 element: &mut RadioWidget,
167 ctx: &mut BuildCtx<'_>,
168 ) -> ChangeFlags {
169 element.on_select = self
171 .on_select
172 .as_ref()
173 .map(crate::authoring::erase_callback);
174 let mut flags = ChangeFlags::NONE;
175 if prev.selected != self.selected {
176 element.selected = self.selected;
178 flags |= ChangeFlags::PAINT;
179 }
180 if prev.label != self.label {
181 element.label_text = self.label.clone();
182 let prev_view = any::<State, _>(text(prev.label.clone()));
183 let next_view = any::<State, _>(text(self.label.clone()));
184 flags |=
185 crate::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
186 }
187 flags
188 }
189
190 fn teardown(&self, element: &mut RadioWidget, ctx: &mut BuildCtx<'_>) {
191 let label_view = any::<State, _>(text(self.label.clone()));
192 crate::authoring::teardown_child(&label_view, &mut element.label, ctx);
193 }
194}
195
196impl Widget for RadioWidget {
197 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
198 let label_max = Size::new((bc.max().width - DIAMETER - GAP).max(0.0), bc.max().height);
199 let label_size = self
200 .label
201 .layout_child(ctx, &BoxConstraints::loose(label_max));
202 let height = label_size.height.max(DIAMETER);
203 self.label.set_origin(Point::new(
205 DIAMETER + GAP,
206 (height - label_size.height) / 2.0,
207 ));
208 bc.constrain(Size::new(DIAMETER + GAP + label_size.width, height))
209 }
210
211 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
212 let color = resolve_color(Theme::from_paint_ctx(ctx), self.selected);
213 let brush = Brush::Solid(color);
214 let center = Point::new(
215 ctx.origin().x + DIAMETER / 2.0,
216 ctx.origin().y + ctx.size().height / 2.0,
217 );
218 let ring_radius = (DIAMETER - RING_STROKE) / 2.0;
219 let ring_path = arc_path(center, ring_radius, 0.0, std::f64::consts::TAU);
220 scene.stroke_path(Point::ZERO, &ring_path, RING_STROKE, &brush);
221 if self.selected {
222 let dot_path = arc_path(center, DOT_RADIUS, 0.0, std::f64::consts::TAU);
223 scene.fill_path(Point::ZERO, &dot_path, &brush);
224 }
225 self.label.paint_child(ctx, scene);
226 }
227
228 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
229 if activation(ctx, event) {
230 if let Some(on_select) = self.on_select.as_mut() {
231 (on_select)(ctx);
232 }
233 ctx.request_redraw();
234 return EventResult::Handled;
235 }
236 let InputEvent::Pointer(p) = event else {
237 return EventResult::Ignored;
238 };
239 match p.phase {
240 PointerPhase::Down => {
241 if !presses(p) {
242 return EventResult::Ignored;
243 }
244 if press_claims_focus(ctx) {
245 ctx.request_focus();
246 }
247 self.pressed = true;
248 self.captured = true;
249 ctx.capture_pointer();
250 ctx.request_redraw();
251 EventResult::Handled
252 }
253 PointerPhase::Move => {
254 if !self.captured {
257 return EventResult::Ignored;
258 }
259 self.pressed = inside(p.position, ctx.size());
260 ctx.request_redraw();
261 EventResult::Handled
262 }
263 PointerPhase::Up => {
264 if !self.captured {
265 return EventResult::Ignored;
266 }
267 if inside(p.position, ctx.size())
268 && let Some(on_select) = self.on_select.as_mut()
269 {
270 (on_select)(ctx);
273 }
274 self.pressed = false;
275 self.captured = false;
276 ctx.request_redraw();
277 EventResult::Handled
278 }
279 PointerPhase::Cancel => {
280 if !self.captured {
281 return EventResult::Ignored;
282 }
283 self.pressed = false;
284 self.captured = false;
285 ctx.request_redraw();
286 EventResult::Handled
287 }
288 }
289 }
290
291 fn semantics(&self, ctx: &mut SemanticsCtx) {
292 ctx.push_node(Role::RadioButton, |node| {
295 node.set_label(self.label_text.as_str());
296 node.set_selected(self.selected);
297 node.add_action(Action::Click);
298 });
299 }
300
301 fn is_focusable(&self) -> bool {
303 true
304 }
305
306 crate::authoring::visit_children!(label);
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use std::any::Any;
313
314 #[derive(Default)]
315 struct SelectState {
316 selects: u32,
317 }
318
319 fn widget(selected: bool) -> RadioWidget {
320 let view = radio::<SelectState>(selected, "daily").on_select(|s: &mut SelectState| {
321 s.selects += 1;
322 });
323 let mut counter = 0u64;
324 View::<SelectState>::build(&view, &mut BuildCtx::new(&mut counter))
325 }
326
327 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
328 InputEvent::Pointer(frust_core::PointerEvent {
329 phase,
330 position: Point::new(x, y),
331 button: frust_core::PointerButton::Primary,
332 })
333 }
334
335 fn dispatch(w: &mut RadioWidget, state: &mut SelectState, event: &InputEvent) {
336 let state_any: &mut dyn Any = state;
337 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
338 w.event(&mut ctx, event);
339 }
340
341 #[test]
342 fn unselected_fires_on_select_and_does_not_self_mutate() {
343 let mut w = widget(false);
344 let mut state = SelectState::default();
345 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
346 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
347 assert_eq!(state.selects, 1);
348 assert!(!w.selected, "radio must not mutate its own selected flag");
349 }
350
351 #[test]
352 fn already_selected_still_fires() {
353 let mut w = widget(true);
354 let mut state = SelectState::default();
355 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
356 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
357 assert_eq!(state.selects, 1);
358 assert!(w.selected, "still selected until the app rebuilds it");
359 }
360
361 #[test]
362 fn up_outside_does_not_fire() {
363 let mut w = widget(false);
364 let mut state = SelectState::default();
365 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
366 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
367 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
368 assert_eq!(state.selects, 0);
369 }
370
371 #[test]
372 fn hover_move_without_down_is_ignored_noop() {
373 let mut w = widget(false);
374 let mut state = SelectState::default();
375 let state_any: &mut dyn Any = &mut state;
376 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
377 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
378 assert!(matches!(result, EventResult::Ignored));
379 assert!(!w.pressed, "hover must not press");
380 assert!(!ctx.needs_redraw(), "hover must not request a redraw");
381 assert_eq!(state.selects, 0);
382 }
383
384 #[test]
385 fn up_without_down_does_not_fire() {
386 let mut w = widget(false);
387 let mut state = SelectState::default();
388 let state_any: &mut dyn Any = &mut state;
389 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
390 let result = w.event(&mut ctx, &ev(PointerPhase::Up, 5.0, 12.0));
391 assert!(matches!(result, EventResult::Ignored));
392 assert_eq!(state.selects, 0);
393 }
394
395 #[test]
396 fn cancel_clears_armed_state() {
397 let mut w = widget(false);
398 let mut state = SelectState::default();
399 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
400 assert!(w.captured);
401 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 12.0));
402 assert!(!w.captured, "Cancel disarms the press");
403 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
405 assert_eq!(state.selects, 0);
406 }
407
408 #[derive(Default)]
410 struct RingRecorder {
411 strokes: Vec<Color>,
412 fills: Vec<Color>,
413 }
414
415 impl PaintScene for RingRecorder {
416 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
417 fn draw_text(&mut self, _o: Point, _t: &str) {}
418 fn stroke_path(&mut self, _o: Point, _path: &kurbo::BezPath, _width: f64, brush: &Brush) {
419 if let Brush::Solid(c) = brush {
420 self.strokes.push(*c);
421 }
422 }
423 fn fill_path(&mut self, _o: Point, _path: &kurbo::BezPath, brush: &Brush) {
424 if let Brush::Solid(c) = brush {
425 self.fills.push(*c);
426 }
427 }
428 }
429
430 fn paint_rec(w: &mut RadioWidget, theme: Option<&Theme>) -> RingRecorder {
431 let mut rec = RingRecorder::default();
432 let mut ctx = match theme {
433 Some(t) => PaintCtx::new(Point::ZERO, Size::new(120.0, 24.0)).with_theme(t),
434 None => PaintCtx::new(Point::ZERO, Size::new(120.0, 24.0)),
435 };
436 w.paint(&mut ctx, &mut rec);
437 rec
438 }
439
440 #[test]
441 fn unthemed_paint_uses_fallback_constants() {
442 let mut off = widget(false);
443 let rec = paint_rec(&mut off, None);
444 assert_eq!(rec.strokes, vec![RING_OFF]);
445 assert!(rec.fills.is_empty(), "unselected radio paints no dot");
446 let mut on = widget(true);
447 let rec = paint_rec(&mut on, None);
448 assert_eq!(rec.strokes, vec![RING_ON]);
449 assert_eq!(rec.fills, vec![RING_ON]);
450 }
451
452 #[test]
453 fn themed_paint_resolves_roles() {
454 let theme = Theme::neutral();
455 let scheme = theme.scheme();
456 let mut off = widget(false);
457 assert_eq!(
458 paint_rec(&mut off, Some(&theme)).strokes,
459 vec![scheme.outline],
460 "unselected ring uses the outline role"
461 );
462 let mut on = widget(true);
463 let rec = paint_rec(&mut on, Some(&theme));
464 assert_eq!(
465 rec.strokes,
466 vec![scheme.primary],
467 "selected ring uses primary"
468 );
469 assert_eq!(rec.fills, vec![scheme.primary], "dot uses primary");
470 }
471
472 #[test]
473 fn rebuild_adopts_new_selected_value() {
474 let mut counter = 0u64;
475 let prev = radio::<SelectState>(false, "daily");
476 let mut w = View::<SelectState>::build(&prev, &mut BuildCtx::new(&mut counter));
477 assert!(!w.selected);
478 let next = radio::<SelectState>(true, "daily");
479 let flags =
480 View::<SelectState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
481 assert!(w.selected);
482 assert!(flags.needs_paint());
483 }
484
485 #[test]
486 fn semantics_reports_role_label_selected_and_bounds() {
487 fn logic(_s: &mut ()) -> RadioView<()> {
488 radio::<()>(true, "daily")
489 }
490 let mut root: frust_core::RenderRoot<(), RadioView<()>> = frust_core::RenderRoot::new();
491 let mut state = ();
492 root.rebuild(&mut logic, &mut state);
493 let mut tcx = frust_text::TextContext::new();
494 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
495 let update = root.semantics();
496 let (_, node) = update
497 .nodes
498 .iter()
499 .find(|(_, n)| n.role() == Role::RadioButton)
500 .expect("radio contributes a Role::RadioButton node");
501 assert_eq!(node.label(), Some("daily"));
502 assert_eq!(node.is_selected(), Some(true));
503 let bounds = node.bounds().expect("radio node has bounds");
504 assert_eq!((bounds.x0, bounds.y0), (0.0, 0.0));
505 }
506
507 mod keyboard {
508 use super::*;
509 use frust_core::{
510 DiscardScene, FrameTime, Key, KeyEvent, Modifiers, NamedKey, PointerButton,
511 PointerEvent, RenderRoot,
512 };
513
514 fn key(key: Key, repeat: bool) -> InputEvent {
515 InputEvent::Key(KeyEvent {
516 key,
517 modifiers: Modifiers::default(),
518 repeat,
519 })
520 }
521
522 fn pointer(phase: PointerPhase) -> InputEvent {
523 InputEvent::Pointer(PointerEvent {
524 phase,
525 position: Point::new(5.0, 10.0),
526 button: PointerButton::Primary,
527 })
528 }
529
530 #[test]
531 fn a_press_keeps_focus_so_the_next_tab_moves_to_the_next_stop() {
532 let mut logic = |_: &mut SelectState| {
533 crate::column()
534 .child(radio(false, "a").on_select(|s: &mut SelectState| s.selects += 1))
535 .child(radio(false, "b").on_select(|s: &mut SelectState| s.selects += 10))
536 };
537 let mut root: RenderRoot<SelectState, crate::FlexView<SelectState>> = RenderRoot::new();
538 let mut state = SelectState::default();
539 let mut tcx = frust_text::TextContext::new();
540 root.rebuild(&mut logic, &mut state);
541 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
542 root.paint(&mut DiscardScene, FrameTime::ZERO);
543 root.event(&mut state, &pointer(PointerPhase::Down));
544 root.event(&mut state, &pointer(PointerPhase::Up));
545 root.event(&mut state, &key(Key::Named(NamedKey::Tab), false));
546 root.event(&mut state, &key(Key::Character(" ".into()), false));
547 assert_eq!(state.selects, 11, "Tab went to the second radio");
548 }
549
550 #[test]
551 fn a_touch_tap_selects_without_claiming_focus() {
552 let mut logic = |_: &mut SelectState| {
553 crate::column()
554 .child(radio(false, "a").on_select(|s: &mut SelectState| s.selects += 1))
555 };
556 let mut root: RenderRoot<SelectState, crate::FlexView<SelectState>> = RenderRoot::new();
557 let mut state = SelectState::default();
558 let mut tcx = frust_text::TextContext::new();
559 root.rebuild(&mut logic, &mut state);
560 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
561 root.paint(&mut DiscardScene, FrameTime::ZERO);
562 for phase in [PointerPhase::Down, PointerPhase::Up] {
563 let InputEvent::Pointer(event) = pointer(phase) else {
564 unreachable!()
565 };
566 let contact = InputEvent::PointerContact {
567 pointer_id: frust_core::event::PointerId::touch(0),
568 event,
569 };
570 root.event(&mut state, &contact);
571 }
572 assert_eq!(state.selects, 1);
573 assert!(!root.is_focus_active(), "a tap leaves focus alone");
574 }
575
576 #[test]
577 fn tab_focuses_the_radio_then_space_or_enter_selects_it() {
578 let mut logic = |_: &mut SelectState| {
579 let daily = radio(false, "daily").on_select(|s: &mut SelectState| s.selects += 1);
580 crate::column().child(daily)
581 };
582 let mut root: RenderRoot<SelectState, crate::FlexView<SelectState>> = RenderRoot::new();
583 let mut state = SelectState::default();
584 let mut tcx = frust_text::TextContext::new();
585 root.rebuild(&mut logic, &mut state);
586 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
587 root.paint(&mut DiscardScene, FrameTime::ZERO);
588 root.event(&mut state, &key(Key::Named(NamedKey::Enter), false));
589 assert_eq!(state.selects, 0, "an unfocused radio takes no keys");
590 root.event(&mut state, &pointer(PointerPhase::Down));
591 root.event(&mut state, &pointer(PointerPhase::Up));
592 assert_eq!(state.selects, 1);
593 assert!(root.is_focus_active(), "a click claims focus");
594
595 root.event(&mut state, &key(Key::Named(NamedKey::Tab), false));
596 assert!(root.is_focus_active());
597 assert!(
598 root.event(&mut state, &key(Key::Character(" ".into()), false))
599 .handled
600 );
601 root.event(&mut state, &key(Key::Named(NamedKey::Enter), false));
602 assert_eq!(state.selects, 3);
603 root.event(&mut state, &key(Key::Named(NamedKey::Enter), true));
604 assert_eq!(state.selects, 3, "an auto-repeat does not select again");
605 assert!(widget(true).is_focusable());
606 }
607 }
608}