1use std::rc::Rc;
119
120use frust::authoring::{Action, Role};
121use frust::authoring::{
122 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
123 LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, ThemeTextColor, ThemeTextType,
124 TypedArgCallback, View, Widget, any,
125};
126use frust::{AnimationController, FrameTime, Theme};
127use kurbo::{Circle, Point, Rect, Shape, Size};
128use peniko::{Brush, Color};
129
130use super::press::presses;
131use super::state_layer::StateLayer;
132use crate::interaction::{DISABLED_CONTENT_OPACITY, HapticSignal, MaterialHaptics};
133use crate::tokens::MaterialMotion;
134
135const HIT_SIZE: f64 = 40.0;
139const RING_SIZE: f64 = 20.0;
141const DOT_SIZE: f64 = 10.0;
144const BORDER_WIDTH: f64 = 2.0;
146const LABEL_GAP: f64 = 8.0;
149const STROKE_TOLERANCE: f64 = 0.1;
156
157const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
160const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
163const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
167const ERROR: Color = Color::from_rgb8(0xB3, 0x26, 0x1E);
170
171fn with_alpha(color: Color, alpha: f32) -> Color {
174 let c = color.components;
175 Color::new([c[0], c[1], c[2], alpha])
176}
177
178fn resolve_colors(
188 theme: Option<&Theme>,
189 selected: bool,
190 error: bool,
191 enabled: bool,
192) -> (Color, Color) {
193 match theme {
194 Some(theme) => {
195 let scheme = theme.scheme();
196 let ring = if !enabled {
197 with_alpha(scheme.on_surface, DISABLED_CONTENT_OPACITY)
198 } else if error {
199 scheme.error
200 } else if selected {
201 scheme.primary
202 } else {
203 scheme.on_surface_variant
204 };
205 let state_layer = if selected {
206 scheme.primary
207 } else {
208 scheme.on_surface
209 };
210 (ring, state_layer)
211 }
212 None => {
213 let ring = if !enabled {
214 with_alpha(ON_SURFACE, DISABLED_CONTENT_OPACITY)
215 } else if error {
216 ERROR
217 } else if selected {
218 PRIMARY
219 } else {
220 ON_SURFACE_VARIANT
221 };
222 let state_layer = if selected { PRIMARY } else { ON_SURFACE };
223 (ring, state_layer)
224 }
225 }
226}
227
228fn inside(pos: Point, size: Size) -> bool {
231 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
232}
233
234fn label_view<State: 'static>(label: String) -> AnyView<State> {
239 any::<State, _>(
240 frust::text(label)
241 .themed_role(ThemeTextColor::OnSurface)
242 .themed_family(ThemeTextType::BodyLarge),
243 )
244}
245
246fn bind_on_changed<State: 'static, T: Clone + 'static>(
251 on_changed: &TypedArgCallback<State, T>,
252 value: T,
253) -> frust::authoring::ErasedCallback {
254 let on_changed = on_changed.clone();
255 Box::new(move |ctx: &mut EventCtx| {
256 let state = ctx.state_mut::<State>();
257 on_changed(state, value.clone());
258 })
259}
260
261pub struct RadioView<State: 'static, T: PartialEq + Clone + 'static> {
263 value: T,
264 group_value: T,
265 on_changed: Option<TypedArgCallback<State, T>>,
266 label: Option<String>,
267 error: bool,
268}
269
270pub fn radio<State: 'static, T: PartialEq + Clone + 'static>(
275 value: T,
276 group_value: T,
277) -> RadioView<State, T> {
278 RadioView {
279 value,
280 group_value,
281 on_changed: None,
282 label: None,
283 error: false,
284 }
285}
286
287#[allow(non_snake_case)]
289pub fn Radio<State: 'static, T: PartialEq + Clone + 'static>(
290 value: T,
291 group_value: T,
292) -> RadioView<State, T> {
293 radio(value, group_value)
294}
295
296impl<State: 'static, T: PartialEq + Clone + 'static> RadioView<State, T> {
297 pub fn on_changed<F: Fn(&mut State, T) + 'static>(mut self, on_changed: F) -> Self {
303 self.on_changed = Some(Rc::new(on_changed));
304 self
305 }
306
307 pub fn label(mut self, label: impl Into<String>) -> Self {
310 self.label = Some(label.into());
311 self
312 }
313
314 pub fn error(mut self, error: bool) -> Self {
317 self.error = error;
318 self
319 }
320}
321
322pub struct RadioWidget {
325 selected: bool,
326 enabled: bool,
327 error: bool,
328 anim: AnimationController,
331 state_layer: StateLayer,
332 label_pod: Option<ChildPod>,
333 label_text: Option<String>,
335 pressed: bool,
337 captured: bool,
339 on_changed: Option<frust::authoring::ErasedCallback>,
340}
341
342impl<State: 'static, T: PartialEq + Clone + 'static> View<State> for RadioView<State, T> {
343 type Element = RadioWidget;
344
345 fn build(&self, ctx: &mut BuildCtx<'_>) -> RadioWidget {
346 let selected = self.value == self.group_value;
347 let enabled = self.on_changed.is_some();
348
349 let label_pod = self
350 .label
351 .as_ref()
352 .map(|label| frust::authoring::build_child(&label_view::<State>(label.clone()), ctx));
353
354 let mut anim = AnimationController::new(MaterialMotion::SHORT_4)
355 .with_curve(MaterialMotion::EMPHASIZED_DECELERATE);
356 if selected {
357 anim.forward();
358 anim.advance(FrameTime::ZERO);
361 anim.advance(FrameTime::from_nanos(
362 MaterialMotion::SHORT_4.as_nanos() as u64
363 ));
364 }
365
366 RadioWidget {
367 selected,
368 enabled,
369 error: self.error,
370 anim,
371 state_layer: StateLayer::new(),
372 label_pod,
373 label_text: self.label.clone(),
374 pressed: false,
375 captured: false,
376 on_changed: self
377 .on_changed
378 .as_ref()
379 .map(|cb| bind_on_changed::<State, T>(cb, self.value.clone())),
380 }
381 }
382
383 fn rebuild(
384 &self,
385 prev: &Self,
386 element: &mut RadioWidget,
387 ctx: &mut BuildCtx<'_>,
388 ) -> ChangeFlags {
389 let mut flags = ChangeFlags::NONE;
390
391 element.on_changed = self
393 .on_changed
394 .as_ref()
395 .map(|cb| bind_on_changed::<State, T>(cb, self.value.clone()));
396
397 let selected = self.value == self.group_value;
398 if element.selected != selected {
399 element.selected = selected;
400 if selected {
404 element.anim.forward();
405 } else {
406 element.anim.reverse();
407 }
408 flags |= ChangeFlags::PAINT;
409 }
410
411 let enabled = self.on_changed.is_some();
412 if element.enabled != enabled {
413 element.enabled = enabled;
414 if !enabled {
415 element.pressed = false;
419 element.captured = false;
420 element.state_layer.set_pressed(false);
421 }
422 flags |= ChangeFlags::PAINT;
423 }
424
425 if element.error != self.error {
426 element.error = self.error;
427 flags |= ChangeFlags::PAINT;
428 }
429
430 match (&prev.label, &self.label) {
431 (None, None) => {}
432 (Some(p), Some(n)) if p == n => {}
433 (Some(_), Some(n)) => {
434 element.label_text = Some(n.clone());
435 let pod = element.label_pod.as_mut().expect("label pod present");
436 let prev_view = label_view::<State>(prev.label.clone().unwrap_or_default());
437 let next_view = label_view::<State>(n.clone());
438 flags |= frust::authoring::rebuild_child(&prev_view, &next_view, pod, ctx);
439 }
440 (None, Some(n)) => {
441 element.label_text = Some(n.clone());
442 element.label_pod = Some(frust::authoring::build_child(
443 &label_view::<State>(n.clone()),
444 ctx,
445 ));
446 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
447 }
448 (Some(p), None) => {
449 element.label_text = None;
450 let mut pod = element.label_pod.take().expect("label pod present");
451 let prev_view = label_view::<State>(p.clone());
452 frust::authoring::teardown_child(&prev_view, &mut pod, ctx);
453 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
454 }
455 }
456
457 flags
458 }
459
460 fn teardown(&self, element: &mut RadioWidget, ctx: &mut BuildCtx<'_>) {
461 if let (Some(label), Some(pod)) = (&self.label, element.label_pod.as_mut()) {
462 let label_view = label_view::<State>(label.clone());
463 frust::authoring::teardown_child(&label_view, pod, ctx);
464 }
465 }
466}
467
468impl Widget for RadioWidget {
469 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
470 let Some(label) = self.label_pod.as_mut() else {
471 return bc.constrain(Size::new(HIT_SIZE, HIT_SIZE));
472 };
473 let label_max = Size::new(
474 (bc.max().width - HIT_SIZE - LABEL_GAP).max(0.0),
475 bc.max().height,
476 );
477 let label_size = label.layout_child(ctx, &BoxConstraints::loose(label_max));
478 let height = label_size.height.max(HIT_SIZE);
479 label.set_origin(Point::new(
481 HIT_SIZE + LABEL_GAP,
482 (height - label_size.height) / 2.0,
483 ));
484 bc.constrain(Size::new(HIT_SIZE + LABEL_GAP + label_size.width, height))
485 }
486
487 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
488 let theme = Theme::from_paint_ctx(ctx);
489 let (ring_color, state_layer_color) =
490 resolve_colors(theme, self.selected, self.error, self.enabled);
491
492 if self.anim.advance(ctx.frame_time()) {
493 ctx.request_frame();
494 }
495 let progress = self.anim.value();
498
499 let o = ctx.origin();
500 let cy = ctx.size().height / 2.0;
501 let center_local = Point::new(HIT_SIZE / 2.0, cy);
502 let center_abs = Point::new(o.x + HIT_SIZE / 2.0, o.y + cy);
503
504 self.state_layer
511 .set_hovered(self.enabled && ctx.is_hovered());
512
513 self.state_layer.paint(
514 ctx,
515 scene,
516 Rect::from_center_size(center_abs, Size::new(HIT_SIZE, HIT_SIZE)),
517 HIT_SIZE / 2.0,
518 state_layer_color,
519 );
520
521 let ring_radius = (RING_SIZE - BORDER_WIDTH) / 2.0;
522 let ring_path = Circle::new(center_local, ring_radius).to_path(STROKE_TOLERANCE);
523 scene.stroke_path(o, &ring_path, BORDER_WIDTH, &Brush::Solid(ring_color));
524
525 let dot_diam = DOT_SIZE * progress;
526 scene.fill_rounded_rect(
527 Point::new(center_abs.x - dot_diam / 2.0, center_abs.y - dot_diam / 2.0),
528 Size::new(dot_diam, dot_diam),
529 dot_diam / 2.0,
530 ring_color,
531 );
532
533 if let Some(label) = self.label_pod.as_mut() {
534 label.paint_child(ctx, scene);
535 }
536 }
537
538 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
539 let InputEvent::Pointer(p) = event else {
540 return EventResult::Ignored;
541 };
542 if !self.enabled {
546 return EventResult::Ignored;
547 }
548 match p.phase {
549 PointerPhase::Down => {
550 if !presses(p) {
551 return EventResult::Ignored;
552 }
553 self.pressed = true;
554 self.captured = true;
555 self.state_layer.set_pressed(true);
556 ctx.capture_pointer();
557 ctx.request_redraw();
558 EventResult::Handled
559 }
560 PointerPhase::Move => {
561 if !self.captured {
562 let over = inside(p.position, ctx.size());
567 if over {
568 ctx.claim_hover();
569 }
570 if self.state_layer.set_hovered(over) {
571 ctx.request_redraw();
572 }
573 return EventResult::Ignored;
574 }
575 let inside_now = inside(p.position, ctx.size());
576 self.pressed = inside_now;
577 self.state_layer.set_pressed(inside_now);
578 ctx.request_redraw();
579 EventResult::Handled
580 }
581 PointerPhase::Up => {
582 if !self.captured {
583 return EventResult::Ignored;
584 }
585 if inside(p.position, ctx.size())
586 && let Some(on_changed) = self.on_changed.as_mut()
587 {
588 MaterialHaptics::fire(HapticSignal::None);
591 (on_changed)(ctx);
592 }
593 self.pressed = false;
594 self.captured = false;
595 self.state_layer.set_pressed(false);
596 ctx.request_redraw();
597 EventResult::Handled
598 }
599 PointerPhase::Cancel => {
600 if !self.captured {
601 return EventResult::Ignored;
602 }
603 self.pressed = false;
604 self.captured = false;
605 self.state_layer.set_pressed(false);
606 ctx.request_redraw();
607 EventResult::Handled
608 }
609 }
610 }
611
612 fn semantics(&self, ctx: &mut SemanticsCtx) {
613 ctx.push_node(Role::RadioButton, |node| {
614 if let Some(label) = &self.label_text {
615 node.set_label(label.as_str());
616 }
617 node.set_selected(self.selected);
618 if self.enabled {
619 node.add_action(Action::Click);
620 }
621 });
622 }
623
624 frust::authoring::visit_children!(label_pod);
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630 use std::any::Any;
631
632 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
633 enum Freq {
634 Daily,
635 Weekly,
636 Monthly,
637 }
638
639 struct GroupState {
640 selected: Freq,
641 changes: u32,
642 }
643
644 fn on_changed(s: &mut GroupState, v: Freq) {
645 s.selected = v;
646 s.changes += 1;
647 }
648
649 fn built(value: Freq, group_value: Freq, enabled: bool) -> RadioWidget {
650 let view = radio::<GroupState, Freq>(value, group_value);
651 let view = if enabled {
652 view.on_changed(on_changed)
653 } else {
654 view
655 };
656 let mut counter = 0u64;
657 View::<GroupState>::build(&view, &mut BuildCtx::new(&mut counter))
658 }
659
660 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
661 InputEvent::Pointer(frust::authoring::PointerEvent {
662 phase,
663 position: Point::new(x, y),
664 button: frust::authoring::PointerButton::Primary,
665 })
666 }
667
668 fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
669 InputEvent::Pointer(frust::authoring::PointerEvent {
670 phase,
671 position: Point::new(x, y),
672 button: frust::authoring::PointerButton::Secondary,
673 })
674 }
675
676 fn dispatch(w: &mut RadioWidget, state: &mut GroupState, event: &InputEvent) {
677 let state_any: &mut dyn Any = state;
678 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
679 w.event(&mut ctx, event);
680 }
681
682 #[test]
685 fn select_and_deselect_across_a_three_member_group() {
686 let mut state = GroupState {
687 selected: Freq::Daily,
688 changes: 0,
689 };
690
691 let daily = built(Freq::Daily, state.selected, true);
693 let mut weekly = built(Freq::Weekly, state.selected, true);
694 let monthly = built(Freq::Monthly, state.selected, true);
695 assert!(daily.selected);
696 assert!(!weekly.selected);
697 assert!(!monthly.selected);
698
699 dispatch(&mut weekly, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
701 dispatch(&mut weekly, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
702 assert_eq!(state.selected, Freq::Weekly);
703 assert_eq!(state.changes, 1);
704 assert!(
705 !weekly.selected,
706 "must not self-mutate — still false until the app rebuilds"
707 );
708
709 let daily = built(Freq::Daily, state.selected, true);
711 let weekly = built(Freq::Weekly, state.selected, true);
712 let monthly = built(Freq::Monthly, state.selected, true);
713 assert!(!daily.selected, "Daily deselected");
714 assert!(weekly.selected, "Weekly now selected");
715 assert!(!monthly.selected);
716
717 let mut monthly = monthly;
719 dispatch(&mut monthly, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
720 dispatch(&mut monthly, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
721 assert_eq!(state.selected, Freq::Monthly);
722 assert_eq!(state.changes, 2);
723
724 let daily = built(Freq::Daily, state.selected, true);
725 let weekly = built(Freq::Weekly, state.selected, true);
726 let monthly = built(Freq::Monthly, state.selected, true);
727 assert!(!daily.selected);
728 assert!(!weekly.selected, "Weekly deselected");
729 assert!(monthly.selected, "Monthly now selected");
730 }
731
732 #[test]
733 fn already_selected_still_fires() {
734 let mut w = built(Freq::Daily, Freq::Daily, true);
735 let mut state = GroupState {
736 selected: Freq::Daily,
737 changes: 0,
738 };
739 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
740 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
741 assert_eq!(state.changes, 1);
742 assert!(w.selected, "still selected until the app rebuilds it");
743 }
744
745 #[test]
746 fn up_outside_does_not_fire() {
747 let mut w = built(Freq::Weekly, Freq::Daily, true);
748 let mut state = GroupState {
749 selected: Freq::Daily,
750 changes: 0,
751 };
752 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
753 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
754 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
755 assert_eq!(state.changes, 0);
756 }
757
758 #[test]
759 fn hover_move_without_down_is_ignored_noop() {
760 let mut w = built(Freq::Weekly, Freq::Daily, true);
761 let mut state = GroupState {
762 selected: Freq::Daily,
763 changes: 0,
764 };
765 let state_any: &mut dyn Any = &mut state;
766 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
767 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
768 assert!(matches!(result, EventResult::Ignored));
769 assert!(!w.pressed);
770 assert_eq!(state.changes, 0);
771 }
772
773 #[test]
774 fn hover_move_inside_claims_hover_and_requests_redraw_once() {
775 let mut w = built(Freq::Weekly, Freq::Daily, true);
776 let mut state = GroupState {
777 selected: Freq::Daily,
778 changes: 0,
779 };
780 let state_any: &mut dyn Any = &mut state;
781 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
782 w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
783 assert!(ctx.needs_redraw(), "hover gain must request a redraw");
784 assert_eq!(w.state_layer.opacity(), crate::interaction::HOVER_OPACITY);
785 }
786
787 #[test]
788 fn paint_self_corrects_hover_off_when_the_pod_link_is_gone() {
789 let mut w = built(Freq::Weekly, Freq::Daily, true);
790 let mut state = GroupState {
791 selected: Freq::Daily,
792 changes: 0,
793 };
794 let state_any: &mut dyn Any = &mut state;
795 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
796 w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
797 assert!(w.state_layer.opacity() > 0.0, "hover claimed");
798
799 let mut paint_ctx = PaintCtx::new(Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
803 let mut rec = Recorder::default();
804 w.paint(&mut paint_ctx, &mut rec);
805 assert_eq!(
806 w.state_layer.opacity(),
807 0.0,
808 "paint self-corrected the flag"
809 );
810 }
811
812 #[test]
813 fn cancel_clears_armed_state() {
814 let mut w = built(Freq::Weekly, Freq::Daily, true);
815 let mut state = GroupState {
816 selected: Freq::Daily,
817 changes: 0,
818 };
819 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
820 assert!(w.captured);
821 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 12.0));
822 assert!(!w.captured, "Cancel disarms the press");
823 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
824 assert_eq!(state.changes, 0);
825 }
826
827 #[test]
828 fn a_secondary_press_never_presses_captures_or_fires() {
829 let mut w = built(Freq::Weekly, Freq::Daily, true);
830 let mut state = GroupState {
831 selected: Freq::Daily,
832 changes: 0,
833 };
834 dispatch(
835 &mut w,
836 &mut state,
837 &secondary_ev(PointerPhase::Down, 5.0, 12.0),
838 );
839 assert!(!w.pressed);
840 assert!(!w.captured);
841 dispatch(
842 &mut w,
843 &mut state,
844 &secondary_ev(PointerPhase::Up, 5.0, 12.0),
845 );
846 assert_eq!(state.changes, 0);
847
848 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
850 assert!(w.pressed);
851 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
852 assert_eq!(state.changes, 1);
853 }
854
855 #[test]
856 fn disabled_radio_ignores_every_pointer_phase() {
857 let mut w = built(Freq::Weekly, Freq::Daily, false);
858 assert!(!w.enabled);
859 let mut state = GroupState {
860 selected: Freq::Daily,
861 changes: 0,
862 };
863 let state_any: &mut dyn Any = &mut state;
864 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
865 let down = w.event(&mut ctx, &ev(PointerPhase::Down, 5.0, 12.0));
866 assert!(matches!(down, EventResult::Ignored));
867 assert!(!w.pressed);
868 assert!(!w.captured);
869 }
870
871 fn paint_rec(w: &mut RadioWidget, theme: Option<&Theme>) -> Recorder {
874 let mut rec = Recorder::default();
875 let mut ctx = match theme {
876 Some(t) => PaintCtx::new(Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE)).with_theme(t),
877 None => PaintCtx::new(Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE)),
878 };
879 w.paint(&mut ctx, &mut rec);
880 rec
881 }
882
883 #[derive(Default)]
884 struct Recorder {
885 rrects: Vec<(Point, Size, f64, Color)>,
888 strokes: Vec<Color>,
890 }
891
892 impl PaintScene for Recorder {
893 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
894 fn draw_text(&mut self, _o: Point, _t: &str) {}
895 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
896 self.rrects.push((o, s, radius, color));
897 }
898 fn stroke_path(&mut self, _o: Point, _path: &kurbo::BezPath, _width: f64, brush: &Brush) {
899 if let Brush::Solid(c) = brush {
900 self.strokes.push(*c);
901 }
902 }
903 }
904
905 #[test]
906 fn dot_scale_in_progress_observable_at_zero_mid_and_settled() {
907 let mut counter = 0u64;
908 let prev = radio::<GroupState, Freq>(Freq::Daily, Freq::Weekly).on_changed(on_changed);
909 let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
910 assert!(!w.selected);
911 assert_eq!(w.anim.value(), 0.0);
912
913 let next = radio::<GroupState, Freq>(Freq::Daily, Freq::Daily).on_changed(on_changed);
914 let flags =
915 View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
916 assert!(w.selected);
917 assert!(flags.needs_paint());
918 assert!(w.anim.is_animating(), "rebuild started the forward drive");
919
920 let rec0 = paint_rec(&mut w, None);
922 assert_eq!(
923 rec0.rrects[0].1,
924 Size::ZERO,
925 "dot has not started scaling in yet"
926 );
927
928 let half_ns = (MaterialMotion::SHORT_4.as_nanos() / 2) as u64;
933 w.anim.advance(FrameTime::from_nanos(half_ns));
934 let rec_mid = paint_rec(&mut w, None);
935 let mid_diam = rec_mid.rrects[0].1.width;
936 assert!(
937 mid_diam > 0.0 && mid_diam < DOT_SIZE,
938 "dot mid-animation diameter should be strictly between 0 and {DOT_SIZE}, got {mid_diam}"
939 );
940
941 let full_ns = MaterialMotion::SHORT_4.as_nanos() as u64;
943 w.anim.advance(FrameTime::from_nanos(full_ns));
944 assert!(!w.anim.is_animating(), "fling settled");
945 let rec_done = paint_rec(&mut w, None);
946 assert_eq!(rec_done.rrects[0].1, Size::new(DOT_SIZE, DOT_SIZE));
947 }
948
949 #[test]
950 fn deselect_also_animates_both_ways() {
951 let mut counter = 0u64;
952 let prev = radio::<GroupState, Freq>(Freq::Daily, Freq::Daily).on_changed(on_changed);
953 let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
954 assert_eq!(w.anim.value(), 1.0);
956
957 let next = radio::<GroupState, Freq>(Freq::Daily, Freq::Weekly).on_changed(on_changed);
958 View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
959 assert!(!w.selected);
960 assert!(w.anim.is_animating(), "rebuild started the reverse drive");
961
962 let full_ns = MaterialMotion::SHORT_4.as_nanos() as u64;
963 w.anim.advance(FrameTime::ZERO); w.anim.advance(FrameTime::from_nanos(full_ns));
965 assert!(!w.anim.is_animating());
966 assert_eq!(w.anim.value(), 0.0);
967 let rec = paint_rec(&mut w, None);
968 assert_eq!(rec.rrects[0].1, Size::ZERO, "dot fully scaled back out");
969 }
970
971 #[test]
974 fn error_flavor_recolors_ring_unthemed() {
975 let mut w = built(Freq::Weekly, Freq::Daily, true);
976 w.error = true;
977 let rec = paint_rec(&mut w, None);
978 assert_eq!(rec.strokes, vec![ERROR]);
979 }
980
981 #[test]
982 fn error_flavor_recolors_ring_themed() {
983 let theme = crate::baseline();
984 let scheme = theme.scheme();
985 let mut w = built(Freq::Weekly, Freq::Daily, true);
986 w.error = true;
987 let rec = paint_rec(&mut w, Some(&theme));
988 assert_eq!(rec.strokes, vec![scheme.error]);
989
990 let mut selected = built(Freq::Daily, Freq::Daily, true);
993 selected.error = true;
994 let rec = paint_rec(&mut selected, Some(&theme));
995 assert_eq!(rec.strokes, vec![scheme.error]);
996
997 selected.state_layer.set_pressed(true);
999 let rec = paint_rec(&mut selected, Some(&theme));
1000 assert_eq!(rec.rrects[0].3, with_alpha(scheme.primary, 0.10));
1001 }
1002
1003 #[test]
1004 fn disabled_wins_over_error() {
1005 let theme = crate::baseline();
1006 let scheme = theme.scheme();
1007 let mut w = built(Freq::Weekly, Freq::Daily, false);
1008 w.error = true;
1009 let rec = paint_rec(&mut w, Some(&theme));
1010 assert_eq!(rec.strokes, vec![with_alpha(scheme.on_surface, 0.38)]);
1011 }
1012
1013 #[test]
1016 fn unthemed_paint_uses_fallback_constants() {
1017 let mut off = built(Freq::Weekly, Freq::Daily, true);
1018 let rec = paint_rec(&mut off, None);
1019 assert_eq!(rec.strokes, vec![ON_SURFACE_VARIANT]);
1020
1021 let mut on = built(Freq::Daily, Freq::Daily, true);
1022 let rec = paint_rec(&mut on, None);
1023 assert_eq!(rec.strokes, vec![PRIMARY]);
1024 assert_eq!(rec.rrects[0].3, PRIMARY, "the fully-settled dot is PRIMARY");
1025 }
1026
1027 #[test]
1028 fn themed_paint_resolves_roles() {
1029 let theme = crate::baseline();
1030 let scheme = theme.scheme();
1031 let mut off = built(Freq::Weekly, Freq::Daily, true);
1032 let rec = paint_rec(&mut off, Some(&theme));
1033 assert_eq!(rec.strokes, vec![scheme.on_surface_variant]);
1034
1035 let mut on = built(Freq::Daily, Freq::Daily, true);
1036 let rec = paint_rec(&mut on, Some(&theme));
1037 assert_eq!(rec.strokes, vec![scheme.primary]);
1038 }
1039
1040 #[test]
1043 fn rebuild_adopts_new_selected_value() {
1044 let mut counter = 0u64;
1045 let prev = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily).on_changed(on_changed);
1046 let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
1047 assert!(!w.selected);
1048 let next = radio::<GroupState, Freq>(Freq::Weekly, Freq::Weekly).on_changed(on_changed);
1049 let flags =
1050 View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1051 assert!(w.selected);
1052 assert!(flags.needs_paint());
1053 }
1054
1055 #[test]
1056 fn rebuild_toggles_enabled_state() {
1057 let mut counter = 0u64;
1058 let prev = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily);
1059 let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
1060 assert!(!w.enabled);
1061 let next = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily).on_changed(on_changed);
1062 let flags =
1063 View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1064 assert!(w.enabled);
1065 assert!(flags.needs_paint());
1066 }
1067
1068 #[test]
1069 fn disabling_mid_press_clears_the_press_and_the_capture() {
1070 let mut counter = 0u64;
1071 let prev = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily).on_changed(on_changed);
1072 let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
1073 w.pressed = true;
1074 w.captured = true;
1075 w.state_layer.set_pressed(true);
1076
1077 let next = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily);
1078 let flags =
1079 View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1080 assert!(!w.pressed);
1081 assert!(!w.captured);
1082 assert_eq!(
1083 w.state_layer.opacity(),
1084 0.0,
1085 "the state-layer's own pressed flag clears too"
1086 );
1087 assert!(flags.needs_paint());
1088 }
1089
1090 const HOVER_DISABLE_WINDOW: Size = Size::new(200.0, 200.0);
1097
1098 struct HoverDisableHarness {
1099 root: frust_core::RenderRoot<GroupState, RadioView<GroupState, Freq>>,
1100 state: GroupState,
1101 tcx: frust::authoring::text::TextContext,
1102 enabled: bool,
1103 }
1104
1105 impl HoverDisableHarness {
1106 fn new(enabled: bool) -> Self {
1107 let mut h = HoverDisableHarness {
1108 root: frust_core::RenderRoot::new(),
1109 state: GroupState {
1110 selected: Freq::Daily,
1111 changes: 0,
1112 },
1113 tcx: frust::authoring::text::TextContext::new(),
1114 enabled,
1115 };
1116 h.sync();
1117 h
1118 }
1119
1120 fn sync(&mut self) {
1121 let enabled = self.enabled;
1122 let mut app = move |_: &mut GroupState| {
1123 let view = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily);
1124 if enabled {
1125 view.on_changed(on_changed)
1126 } else {
1127 view
1128 }
1129 };
1130 self.root.rebuild(&mut app, &mut self.state);
1131 self.root
1132 .layout_with_text(HOVER_DISABLE_WINDOW, &mut self.tcx as &mut dyn Any);
1133 }
1134
1135 fn dispatch(&mut self, phase: PointerPhase, x: f64, y: f64) {
1136 self.root.event(&mut self.state, &ev(phase, x, y));
1137 }
1138
1139 fn paint(&mut self) -> Recorder {
1140 let mut rec = Recorder::default();
1141 self.root.paint(&mut rec, frust::FrameTime::ZERO);
1142 rec
1143 }
1144 }
1145
1146 #[test]
1147 fn a_disabled_radio_drops_a_standing_hover_claim_at_paint() {
1148 let mut h = HoverDisableHarness::new(true);
1149
1150 h.dispatch(PointerPhase::Move, 5.0, 12.0);
1152 let rec = h.paint();
1153 assert_eq!(
1154 rec.rrects.len(),
1155 2,
1156 "hovered+enabled radio paints the state-layer overlay plus the dot"
1157 );
1158
1159 h.enabled = false;
1164 h.sync();
1165
1166 let rec = h.paint();
1167 assert_eq!(
1168 rec.rrects.len(),
1169 1,
1170 "paint's self-correction must gate on `enabled`: state_layer.opacity() \
1171 drops to 0.0 (the overlay fill is skipped entirely), so only the dot paints"
1172 );
1173 }
1174
1175 #[test]
1178 fn label_presence_change_builds_and_tears_down_the_child() {
1179 let mut counter = 0u64;
1180 let prev = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily).on_changed(on_changed);
1181 let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
1182 assert!(w.label_pod.is_none());
1183
1184 let next = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily)
1185 .on_changed(on_changed)
1186 .label("Weekly");
1187 let flags =
1188 View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1189 assert!(w.label_pod.is_some());
1190 assert_eq!(w.label_text.as_deref(), Some("Weekly"));
1191 assert!(flags.needs_layout());
1192
1193 let prev2 = next;
1194 let next2 = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily).on_changed(on_changed);
1195 let flags =
1196 View::<GroupState>::rebuild(&next2, &prev2, &mut w, &mut BuildCtx::new(&mut counter));
1197 assert!(w.label_pod.is_none());
1198 assert!(w.label_text.is_none());
1199 assert!(flags.needs_layout());
1200 }
1201
1202 #[test]
1205 fn semantics_reports_role_label_selected_and_bounds() {
1206 fn logic(_s: &mut ()) -> RadioView<(), Freq> {
1207 radio::<(), Freq>(Freq::Daily, Freq::Daily)
1208 .on_changed(|_, _| {})
1209 .label("daily")
1210 }
1211 let mut root: frust_core::RenderRoot<(), RadioView<(), Freq>> =
1212 frust_core::RenderRoot::new();
1213 let mut state = ();
1214 root.rebuild(&mut logic, &mut state);
1215 let mut tcx = frust::authoring::text::TextContext::new();
1216 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1217 let update = root.semantics();
1218 let (_, node) = update
1219 .nodes
1220 .iter()
1221 .find(|(_, n)| n.role() == Role::RadioButton)
1222 .expect("radio contributes a Role::RadioButton node");
1223 assert_eq!(node.label(), Some("daily"));
1224 assert_eq!(node.is_selected(), Some(true));
1225 assert!(node.supports_action(Action::Click));
1226 let bounds = node.bounds().expect("radio node has bounds");
1227 assert_eq!((bounds.x0, bounds.y0), (0.0, 0.0));
1228 }
1229
1230 #[test]
1231 fn semantics_omits_click_action_when_disabled() {
1232 fn logic(_s: &mut ()) -> RadioView<(), Freq> {
1233 radio::<(), Freq>(Freq::Daily, Freq::Weekly)
1234 }
1235 let mut root: frust_core::RenderRoot<(), RadioView<(), Freq>> =
1236 frust_core::RenderRoot::new();
1237 let mut state = ();
1238 root.rebuild(&mut logic, &mut state);
1239 let mut tcx = frust::authoring::text::TextContext::new();
1240 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1241 let update = root.semantics();
1242 let (_, node) = update
1243 .nodes
1244 .iter()
1245 .find(|(_, n)| n.role() == Role::RadioButton)
1246 .expect("radio contributes a Role::RadioButton node");
1247 assert!(!node.supports_action(Action::Click));
1248 assert_eq!(node.is_selected(), Some(false));
1249 }
1250}