1use std::rc::Rc;
76use std::time::Duration;
77
78use frust_core::accesskit::{Action, Role};
79use frust_core::{
80 AnimationController, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, Curve, EventCtx,
81 EventResult, FrameTime, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase,
82 SemanticsCtx, Tween, View, Widget, any,
83};
84use frust_theme::Theme;
85use kurbo::{Affine, Arc as KurboArc, Point, RoundedRect, Shape, Size, Vec2};
86use peniko::{Brush, Color};
87
88use crate::authoring::presses;
89use crate::nav::transition::{TransitionDriver, make_driver};
90use crate::text;
91use crate::text::ThemeTextColor;
92use crate::{Alignment, Timing, authoring::PRESSED_OPACITY};
93
94const RADIUS: f64 = 6.0;
97const PAD_X: f64 = 12.0;
103const PAD_Y: f64 = 8.0;
106const SMALL_PAD_X: f64 = 8.0;
110const SMALL_PAD_Y: f64 = 4.0;
112const FILL: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
115const FILL_PRESSED: Color = Color::from_rgb8(0x1D, 0x4E, 0xD8);
118
119const SECONDARY_FILL: Color = Color::from_rgb8(0xE5, 0xE7, 0xEB);
124const SECONDARY_FILL_PRESSED: Color = Color::from_rgb8(0xD1, 0xD5, 0xDB);
126const SECONDARY_BORDER: Color = Color::from_rgb8(0x9C, 0xA3, 0xAF);
128const GHOST_PRESSED_INK: Color = Color::from_rgb8(0x11, 0x18, 0x27);
131const DANGER_BORDER: Color = Color::from_rgb8(0xDC, 0x26, 0x26);
133const DANGER_PRESSED_WASH: Color = Color::from_rgb8(0xFE, 0xE2, 0xE2);
135const UNTHEMED_INK: Color = Color::from_rgb8(0x00, 0x00, 0x00);
138
139const BORDER_WIDTH: f64 = 1.0;
142const BORDER_TOLERANCE: f64 = 0.1;
145
146const PRESSED_DARKEN: f32 = 0.82;
151
152const DISABLED_ALPHA: f32 = 0.38;
163
164const PRESSED_SCALE: f64 = 0.96;
166const REST_SCALE: f64 = 1.0;
168const FALLBACK_PRESS_DURATION: Duration = Duration::from_millis(100);
173
174const SPINNER_RADIUS_RATIO: f64 = 0.4;
178const SPINNER_MIN_RADIUS: f64 = 5.0;
181const SPINNER_STROKE_WIDTH: f64 = 2.0;
183const SPINNER_SWEEP: f64 = std::f64::consts::PI * 1.5;
186const SPINNER_PERIOD_MS: u64 = 900;
192
193fn pressed_overlay(color: Color, factor: f32) -> Color {
197 let c = color.components;
198 Color::new([c[0] * factor, c[1] * factor, c[2] * factor, c[3]])
199}
200
201fn with_alpha(color: Color, alpha: f32) -> Color {
204 let c = color.components;
205 Color::new([c[0], c[1], c[2], alpha])
206}
207
208fn disabled_alpha(color: Color) -> Color {
211 let c = color.components;
212 Color::new([c[0], c[1], c[2], c[3] * DISABLED_ALPHA])
213}
214
215fn scale_about(pivot: Point, scale: f64) -> Affine {
219 Affine::translate((pivot.x, pivot.y))
220 * Affine::scale(scale)
221 * Affine::translate((-pivot.x, -pivot.y))
222}
223
224type OnPress<State> = Rc<dyn Fn(&mut State)>;
226
227#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
231pub enum ButtonStyle {
232 #[default]
234 Primary,
235 Secondary,
238 Ghost,
242 Danger,
245 Icon,
248}
249
250impl ButtonStyle {
251 fn label_role(self) -> ThemeTextColor {
256 match self {
257 ButtonStyle::Primary => ThemeTextColor::OnPrimary,
258 ButtonStyle::Secondary | ButtonStyle::Icon => ThemeTextColor::OnSurface,
259 ButtonStyle::Ghost => ThemeTextColor::OnSurfaceVariant,
260 ButtonStyle::Danger => ThemeTextColor::Error,
261 }
262 }
263
264 fn resolve_ink(self, theme: Option<&Theme>) -> Color {
272 match theme {
273 Some(theme) => {
274 let scheme = theme.scheme();
275 match self {
276 ButtonStyle::Primary => scheme.on_primary,
277 ButtonStyle::Secondary | ButtonStyle::Icon => scheme.on_surface,
278 ButtonStyle::Ghost => scheme.on_surface_variant,
279 ButtonStyle::Danger => scheme.error,
280 }
281 }
282 None => UNTHEMED_INK,
283 }
284 }
285
286 fn resolve(self, theme: Option<&Theme>) -> StylePaint {
292 match theme {
293 Some(theme) => {
294 let scheme = theme.scheme();
295 match self {
296 ButtonStyle::Primary => StylePaint {
297 fill: scheme.primary,
298 fill_pressed: pressed_overlay(scheme.primary, PRESSED_DARKEN),
299 border: None,
300 },
301 ButtonStyle::Secondary | ButtonStyle::Icon => StylePaint {
302 fill: scheme.surface_container_high,
303 fill_pressed: pressed_overlay(
304 scheme.surface_container_high,
305 PRESSED_DARKEN,
306 ),
307 border: Some(scheme.outline),
308 },
309 ButtonStyle::Ghost => StylePaint {
310 fill: Color::TRANSPARENT,
311 fill_pressed: with_alpha(scheme.on_surface, PRESSED_OPACITY),
312 border: None,
313 },
314 ButtonStyle::Danger => StylePaint {
315 fill: Color::TRANSPARENT,
316 fill_pressed: scheme.error_container,
317 border: Some(scheme.error),
318 },
319 }
320 }
321 None => match self {
322 ButtonStyle::Primary => StylePaint {
323 fill: FILL,
324 fill_pressed: FILL_PRESSED,
325 border: None,
326 },
327 ButtonStyle::Secondary | ButtonStyle::Icon => StylePaint {
328 fill: SECONDARY_FILL,
329 fill_pressed: SECONDARY_FILL_PRESSED,
330 border: Some(SECONDARY_BORDER),
331 },
332 ButtonStyle::Ghost => StylePaint {
333 fill: Color::TRANSPARENT,
334 fill_pressed: with_alpha(GHOST_PRESSED_INK, PRESSED_OPACITY),
335 border: None,
336 },
337 ButtonStyle::Danger => StylePaint {
338 fill: Color::TRANSPARENT,
339 fill_pressed: DANGER_PRESSED_WASH,
340 border: Some(DANGER_BORDER),
341 },
342 },
343 }
344 }
345}
346
347struct StylePaint {
349 fill: Color,
350 fill_pressed: Color,
351 border: Option<Color>,
352}
353
354fn resolve_press_timing(theme: Option<&Theme>, pressed: bool) -> Timing {
360 match theme {
361 Some(theme) => {
362 if pressed {
363 let instant_ms = theme.motion.durations.instant.max(0.0);
364 Timing::Duration(
365 Duration::from_secs_f64(instant_ms / 1000.0),
366 theme.motion.easing.exit,
367 )
368 } else {
369 Timing::Spring(theme.motion.default_spatial)
370 }
371 }
372 None => {
373 let curve = if pressed {
374 Curve::EaseIn
375 } else {
376 Curve::EaseOut
377 };
378 Timing::Duration(FALLBACK_PRESS_DURATION, curve)
379 }
380 }
381}
382
383struct PressAnim {
392 target: f64,
393 driving_target: f64,
394 tween: Tween<f64>,
395 driver: TransitionDriver,
396}
397
398impl PressAnim {
399 fn new() -> Self {
401 let (driver, _) = make_driver(Timing::Duration(Duration::ZERO, Curve::Linear));
402 Self {
403 target: REST_SCALE,
404 driving_target: REST_SCALE,
405 tween: Tween::new(REST_SCALE, REST_SCALE),
406 driver,
407 }
408 }
409
410 fn value(&self) -> f64 {
412 self.tween.lerp(self.driver.value())
413 }
414
415 fn set_pressed(&mut self, pressed: bool) {
418 self.target = if pressed { PRESSED_SCALE } else { REST_SCALE };
419 }
420
421 fn advance(&mut self, now: FrameTime, timing: Timing) -> bool {
424 if self.target != self.driving_target {
425 let from = self.value();
426 self.tween = Tween::new(from, self.target);
427 self.driving_target = self.target;
428 let (driver, _) = make_driver(timing);
429 self.driver = driver;
430 }
431 self.driver.advance(now).animating
432 }
433}
434
435fn label_view<State: 'static>(label: String, style: ButtonStyle) -> frust_core::AnyView<State> {
441 any::<State, _>(text(label).themed_role(style.label_role()))
442}
443
444pub struct ButtonView<State: 'static> {
446 label: String,
447 on_press: OnPress<State>,
448 style: ButtonStyle,
449 small: bool,
450 loading: bool,
451 disabled: bool,
452 label_alignment: Option<Alignment>,
455}
456
457pub fn button<State: 'static, F: Fn(&mut State) + 'static>(
460 label: impl Into<String>,
461 on_press: F,
462) -> ButtonView<State> {
463 ButtonView {
464 label: label.into(),
465 on_press: Rc::new(on_press),
466 style: ButtonStyle::default(),
467 small: false,
468 loading: false,
469 disabled: false,
470 label_alignment: None,
471 }
472}
473
474#[allow(non_snake_case)]
476pub fn Button<State: 'static, F: Fn(&mut State) + 'static>(
477 label: impl Into<String>,
478 on_press: F,
479) -> ButtonView<State> {
480 button(label, on_press)
481}
482
483impl<State: 'static> ButtonView<State> {
484 pub fn style(mut self, style: ButtonStyle) -> Self {
486 self.style = style;
487 self
488 }
489
490 pub fn small(mut self) -> Self {
492 self.small = true;
493 self
494 }
495
496 pub fn loading(mut self, loading: bool) -> Self {
500 self.loading = loading;
501 self
502 }
503
504 pub fn disabled(mut self, disabled: bool) -> Self {
510 self.disabled = disabled;
511 self
512 }
513
514 pub fn label_alignment(mut self, alignment: Alignment) -> Self {
519 self.label_alignment = Some(alignment);
520 self
521 }
522}
523
524pub struct ButtonWidget {
527 label: ChildPod,
528 label_text: String,
532 pressed: bool,
536 captured: bool,
541 on_press: crate::authoring::ErasedCallback,
542 style: ButtonStyle,
543 small: bool,
544 loading: bool,
545 disabled: bool,
547 label_alignment: Option<Alignment>,
550 press: PressAnim,
552 spinner: AnimationController,
555}
556
557fn inside(pos: Point, size: Size) -> bool {
560 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
561}
562
563impl ButtonWidget {
564 fn resolve_radius(theme: Option<&Theme>, size: Size) -> f64 {
570 match theme {
571 Some(theme) => {
572 frust_theme::ShapeScale::resolve(theme.shape.small, size.width, size.height)
573 }
574 None => RADIUS,
575 }
576 }
577
578 fn resolve_label_origin(
586 alignment: Option<Alignment>,
587 pad_x: f64,
588 pad_y: f64,
589 final_size: Size,
590 content_size: Size,
591 ) -> Point {
592 let free_x = (final_size.width - content_size.width).max(0.0);
598 let free_y = (final_size.height - content_size.height).max(0.0);
599 let effective = alignment
606 .unwrap_or_else(|| Alignment::new(if free_x > 0.0 { 0.0 } else { -1.0 }, -1.0));
607 let frac = |component: f64| (component + 1.0) / 2.0;
608 Point::new(
609 pad_x + free_x * frac(effective.x),
610 pad_y + free_y * frac(effective.y),
611 )
612 }
613
614 fn paint_spinner(&self, ctx: &PaintCtx, scene: &mut dyn PaintScene, color: Color) {
617 let size = ctx.size();
618 let center_local = Point::new(size.width / 2.0, size.height / 2.0);
619 let radius = (size.height / 2.0 * SPINNER_RADIUS_RATIO).max(SPINNER_MIN_RADIUS);
620 let angle = self.spinner.value() * std::f64::consts::TAU;
621 let arc = KurboArc::new(
622 center_local,
623 Vec2::new(radius, radius),
624 angle,
625 SPINNER_SWEEP,
626 0.0,
627 );
628 let path = arc.to_path(0.1);
629 scene.stroke_path(
630 ctx.origin(),
631 &path,
632 SPINNER_STROKE_WIDTH,
633 &Brush::Solid(color),
634 );
635 }
636}
637
638impl<State: 'static> View<State> for ButtonView<State> {
639 type Element = ButtonWidget;
640
641 fn build(&self, ctx: &mut BuildCtx<'_>) -> ButtonWidget {
642 let label_view = label_view::<State>(self.label.clone(), self.style);
643 let mut spinner = AnimationController::new(Duration::from_millis(SPINNER_PERIOD_MS))
644 .with_curve(Curve::Linear);
645 spinner.repeat();
646 ButtonWidget {
647 label: crate::authoring::build_child(&label_view, ctx),
648 label_text: self.label.clone(),
649 pressed: false,
650 captured: false,
651 on_press: crate::authoring::erase_callback(&self.on_press),
652 style: self.style,
653 small: self.small,
654 loading: self.loading,
655 disabled: self.disabled,
656 label_alignment: self.label_alignment,
657 press: PressAnim::new(),
658 spinner,
659 }
660 }
661
662 fn rebuild(
663 &self,
664 prev: &Self,
665 element: &mut ButtonWidget,
666 ctx: &mut BuildCtx<'_>,
667 ) -> ChangeFlags {
668 element.on_press = crate::authoring::erase_callback(&self.on_press);
670 let mut flags = ChangeFlags::NONE;
671 if prev.label != self.label || prev.style != self.style {
672 element.label_text = self.label.clone();
673 let prev_view = label_view::<State>(prev.label.clone(), prev.style);
674 let next_view = label_view::<State>(self.label.clone(), self.style);
675 flags |=
676 crate::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
677 }
678 if prev.style != self.style {
679 element.style = self.style;
680 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
681 }
682 if prev.small != self.small {
683 element.small = self.small;
684 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
685 }
686 if prev.label_alignment != self.label_alignment {
687 element.label_alignment = self.label_alignment;
688 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
693 }
694 if prev.loading != self.loading {
695 element.loading = self.loading;
696 flags |= ChangeFlags::PAINT;
697 if self.loading {
698 element.pressed = false;
703 element.captured = false;
704 element.press.set_pressed(false);
705 }
706 }
707 if prev.disabled != self.disabled {
708 element.disabled = self.disabled;
709 flags |= ChangeFlags::PAINT;
710 if self.disabled {
711 element.pressed = false;
715 element.captured = false;
716 element.press.set_pressed(false);
717 }
718 }
719 flags
720 }
721
722 fn teardown(&self, element: &mut ButtonWidget, ctx: &mut BuildCtx<'_>) {
723 let label_view = label_view::<State>(self.label.clone(), self.style);
724 crate::authoring::teardown_child(&label_view, &mut element.label, ctx);
725 }
726}
727
728impl Widget for ButtonWidget {
729 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
730 let (pad_x, pad_y) = if self.small {
731 (SMALL_PAD_X, SMALL_PAD_Y)
732 } else {
733 (PAD_X, PAD_Y)
734 };
735 let inset = Size::new(pad_x * 2.0, pad_y * 2.0);
737 let inner_max = Size::new(
738 (bc.max().width - inset.width).max(0.0),
739 (bc.max().height - inset.height).max(0.0),
740 );
741 let label_size = self
742 .label
743 .layout_child(ctx, &BoxConstraints::loose(inner_max));
744 let mut size = Size::new(
745 label_size.width + inset.width,
746 label_size.height + inset.height,
747 );
748 let content_size = size;
753 if self.style == ButtonStyle::Icon {
754 let side = size.width.max(size.height);
759 size = Size::new(side, side);
760 self.label.set_origin(Point::new(
761 (side - label_size.width) / 2.0,
762 (side - label_size.height) / 2.0,
763 ));
764 } else {
765 let final_size = bc.constrain(size);
766 self.label.set_origin(Self::resolve_label_origin(
767 self.label_alignment,
768 pad_x,
769 pad_y,
770 final_size,
771 content_size,
772 ));
773 }
774 bc.constrain(size)
775 }
776
777 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
778 let theme = Theme::from_paint_ctx(ctx);
784 let mut paint = self.style.resolve(theme);
785 let radius = Self::resolve_radius(theme, ctx.size());
786 let press_timing = resolve_press_timing(theme, self.pressed);
790 let mut ink = self.style.resolve_ink(theme);
791 let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
795
796 if self.disabled {
801 paint.fill = disabled_alpha(paint.fill);
802 paint.fill_pressed = disabled_alpha(paint.fill_pressed);
803 if let Some(ref mut border) = paint.border {
804 *border = disabled_alpha(*border);
805 }
806 ink = disabled_alpha(ink);
807 }
808
809 let fill = if self.pressed {
810 paint.fill_pressed
811 } else {
812 paint.fill
813 };
814
815 if self.press.advance(ctx.frame_time(), press_timing) {
816 ctx.request_frame();
817 }
818 let scale = self.press.value();
819 let origin = ctx.origin();
820 let size = ctx.size();
821 let pivot = Point::new(origin.x + size.width / 2.0, origin.y + size.height / 2.0);
822 scene.push_transform(scale_about(pivot, scale));
823
824 if fill != Color::TRANSPARENT {
825 scene.fill_rounded_rect(origin, size, radius, fill);
826 }
827 if let Some(border) = paint.border {
828 let half = BORDER_WIDTH / 2.0;
832 let rr = RoundedRect::new(
833 half,
834 half,
835 size.width - half,
836 size.height - half,
837 (radius - half).max(0.0),
838 );
839 let path = rr.to_path(BORDER_TOLERANCE);
840 scene.stroke_path(origin, &path, BORDER_WIDTH, &Brush::Solid(border));
841 }
842
843 if self.loading {
844 if !reduce_motion {
849 self.spinner.advance(ctx.frame_time());
850 ctx.request_frame_paced();
853 }
854 self.paint_spinner(ctx, scene, ink);
855 } else {
856 self.label.paint_child(ctx, scene);
857 }
858
859 scene.pop_transform();
860 }
861
862 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
863 if self.loading || self.disabled {
866 return EventResult::Ignored;
867 }
868 let InputEvent::Pointer(p) = event else {
869 return EventResult::Ignored;
870 };
871 match p.phase {
872 PointerPhase::Down => {
873 if !presses(p) {
874 return EventResult::Ignored;
875 }
876 self.pressed = true;
877 self.captured = true;
878 self.press.set_pressed(true);
879 ctx.capture_pointer();
880 ctx.request_redraw();
881 EventResult::Handled
882 }
883 PointerPhase::Move => {
884 if !self.captured {
887 return EventResult::Ignored;
888 }
889 self.pressed = inside(p.position, ctx.size());
891 self.press.set_pressed(self.pressed);
892 ctx.request_redraw();
893 EventResult::Handled
894 }
895 PointerPhase::Up => {
896 if !self.captured {
897 return EventResult::Ignored;
898 }
899 if inside(p.position, ctx.size()) {
901 (self.on_press)(ctx);
902 }
903 self.pressed = false;
904 self.captured = false;
905 self.press.set_pressed(false);
906 ctx.request_redraw();
907 EventResult::Handled
908 }
909 PointerPhase::Cancel => {
910 if !self.captured {
911 return EventResult::Ignored;
912 }
913 self.pressed = false;
914 self.captured = false;
915 self.press.set_pressed(false);
916 ctx.request_redraw();
917 EventResult::Handled
918 }
919 }
920 }
921
922 fn semantics(&self, ctx: &mut SemanticsCtx) {
923 ctx.push_node(Role::Button, |node| {
928 node.set_label(self.label_text.as_str());
929 if self.loading || self.disabled {
930 node.set_disabled();
931 } else {
932 node.add_action(Action::Click);
933 }
934 });
935 }
936
937 crate::authoring::visit_children!(label);
938}
939
940#[cfg(test)]
941mod tests {
942 use super::*;
943 use std::any::Any;
944
945 #[derive(Default)]
946 struct Counter {
947 presses: u32,
948 }
949
950 fn widget() -> ButtonWidget {
953 let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1);
954 let mut counter = 0u64;
955 View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter))
956 }
957
958 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
959 InputEvent::Pointer(frust_core::PointerEvent {
960 phase,
961 position: Point::new(x, y),
962 button: frust_core::PointerButton::Primary,
963 })
964 }
965
966 fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
968 InputEvent::Pointer(frust_core::PointerEvent {
969 phase,
970 position: Point::new(x, y),
971 button: frust_core::PointerButton::Secondary,
972 })
973 }
974
975 fn dispatch(w: &mut ButtonWidget, state: &mut Counter, event: &InputEvent) {
976 let state_any: &mut dyn Any = state;
977 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
978 w.event(&mut ctx, event);
979 }
980
981 #[test]
982 fn a_secondary_press_neither_presses_nor_captures_nor_fires() {
983 let mut w = widget();
984 let mut state = Counter::default();
985 dispatch(
986 &mut w,
987 &mut state,
988 &secondary_ev(PointerPhase::Down, 10.0, 10.0),
989 );
990 assert!(!w.pressed, "no pressed chrome on a right-click");
991 assert!(!w.captured, "and no capture for the shell to wedge on");
992 dispatch(
993 &mut w,
994 &mut state,
995 &secondary_ev(PointerPhase::Up, 10.0, 10.0),
996 );
997 assert_eq!(state.presses, 0);
998
999 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1001 assert!(w.pressed);
1002 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1003 assert_eq!(state.presses, 1);
1004 }
1005
1006 #[test]
1007 fn down_then_up_inside_fires_once() {
1008 let mut w = widget();
1009 let mut state = Counter::default();
1010 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1011 assert!(w.pressed);
1012 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 12.0, 12.0));
1013 assert_eq!(state.presses, 1);
1014 assert!(!w.pressed);
1015 }
1016
1017 #[test]
1018 fn down_inside_move_out_up_outside_does_not_fire() {
1019 let mut w = widget();
1020 let mut state = Counter::default();
1021 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1022 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 200.0, 10.0));
1023 assert!(!w.pressed, "moving out clears the pressed visual");
1024 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 200.0, 10.0));
1025 assert_eq!(state.presses, 0, "up outside must not fire");
1026 }
1027
1028 #[test]
1029 fn cancel_clears_pressed_without_firing() {
1030 let mut w = widget();
1031 let mut state = Counter::default();
1032 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1033 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
1034 assert!(!w.pressed);
1035 assert_eq!(state.presses, 0);
1036 }
1037
1038 #[test]
1039 fn hover_move_without_down_is_ignored_noop() {
1040 let mut w = widget();
1041 let mut state = Counter::default();
1042 let state_any: &mut dyn Any = &mut state;
1045 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
1046 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 20.0, 20.0));
1047 assert!(matches!(result, EventResult::Ignored));
1048 assert!(!w.pressed, "hover must not press");
1049 assert!(!ctx.needs_redraw(), "hover must not request a redraw");
1050 assert_eq!(state.presses, 0);
1051 }
1052
1053 #[test]
1054 fn up_without_down_does_not_fire() {
1055 let mut w = widget();
1056 let mut state = Counter::default();
1057 let state_any: &mut dyn Any = &mut state;
1058 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
1059 let result = w.event(&mut ctx, &ev(PointerPhase::Up, 20.0, 20.0));
1060 assert!(matches!(result, EventResult::Ignored));
1061 assert_eq!(state.presses, 0, "an unarmed Up must never fire");
1062 }
1063
1064 #[test]
1065 fn cancel_clears_armed_state() {
1066 let mut w = widget();
1067 let mut state = Counter::default();
1068 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1069 assert!(w.captured);
1070 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
1071 assert!(!w.captured, "Cancel disarms the press");
1072 let state_any: &mut dyn Any = &mut state;
1074 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
1075 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 12.0, 12.0));
1076 assert!(matches!(result, EventResult::Ignored));
1077 assert!(!w.pressed);
1078 }
1079
1080 #[derive(Default)]
1085 struct RRectRecorder {
1086 rrects: Vec<(f64, Color)>,
1087 strokes: Vec<(f64, Color)>,
1088 transforms: Vec<Affine>,
1089 transform_pops: u32,
1090 }
1091
1092 impl PaintScene for RRectRecorder {
1093 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1094 fn draw_text(&mut self, _o: Point, _t: &str) {}
1095 fn fill_rounded_rect(&mut self, _o: Point, _s: Size, radius: f64, color: Color) {
1096 self.rrects.push((radius, color));
1097 }
1098 fn stroke_path(
1099 &mut self,
1100 _origin: Point,
1101 _path: &kurbo::BezPath,
1102 width: f64,
1103 brush: &Brush,
1104 ) {
1105 if let Brush::Solid(color) = brush {
1106 self.strokes.push((width, *color));
1107 }
1108 }
1109 fn push_transform(&mut self, transform: Affine) {
1110 self.transforms.push(transform);
1111 }
1112 fn pop_transform(&mut self) {
1113 self.transform_pops += 1;
1114 }
1115 }
1116
1117 fn paint_bg(w: &mut ButtonWidget, theme: Option<&frust_theme::Theme>) -> (f64, Color) {
1118 let mut rec = RRectRecorder::default();
1119 let mut ctx = match theme {
1120 Some(t) => PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(t),
1121 None => PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)),
1122 };
1123 w.paint(&mut ctx, &mut rec);
1124 *rec.rrects.first().expect("button paints its background")
1125 }
1126
1127 #[test]
1128 fn unthemed_paint_uses_fallback_constants() {
1129 let mut w = widget();
1131 assert_eq!(paint_bg(&mut w, None), (RADIUS, FILL));
1132 w.pressed = true;
1134 assert_eq!(paint_bg(&mut w, None), (RADIUS, FILL_PRESSED));
1135 }
1136
1137 #[test]
1138 fn themed_paint_resolves_primary_and_shape_small() {
1139 let theme = frust_theme::Theme::neutral();
1140 let mut w = widget();
1141 let (radius, color) = paint_bg(&mut w, Some(&theme));
1142 assert_eq!(color, theme.scheme().primary, "resting fill is primary");
1143 assert_eq!(radius, theme.shape.small, "radius is shape.small (8dp)");
1144 w.pressed = true;
1146 let (_, pressed) = paint_bg(&mut w, Some(&theme));
1147 assert_eq!(
1148 pressed,
1149 pressed_overlay(theme.scheme().primary, PRESSED_DARKEN)
1150 );
1151 }
1152
1153 #[test]
1154 fn move_back_inside_then_up_fires() {
1155 let mut w = widget();
1157 let mut state = Counter::default();
1158 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1159 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 200.0, 10.0));
1160 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 20.0, 10.0));
1161 assert!(w.pressed);
1162 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 20.0, 10.0));
1163 assert_eq!(state.presses, 1);
1164 }
1165
1166 fn styled_widget(style: ButtonStyle) -> ButtonWidget {
1169 let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1).style(style);
1170 let mut counter = 0u64;
1171 View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter))
1172 }
1173
1174 #[test]
1175 fn default_style_is_primary_and_matches_pre_task22_rendering() {
1176 assert_eq!(ButtonStyle::default(), ButtonStyle::Primary);
1181 let mut w = widget();
1182 assert_eq!(w.style, ButtonStyle::Primary);
1183 assert_eq!(paint_bg(&mut w, None), (RADIUS, FILL));
1184 let theme = frust_theme::Theme::neutral();
1185 let mut w2 = widget();
1186 assert_eq!(paint_bg(&mut w2, Some(&theme)).1, theme.scheme().primary);
1187 }
1188
1189 #[test]
1190 fn secondary_style_paints_raised_surface_and_outline_border() {
1191 let theme = frust_theme::Theme::neutral();
1192 let mut w = styled_widget(ButtonStyle::Secondary);
1193 let mut rec = RRectRecorder::default();
1194 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1195 w.paint(&mut ctx, &mut rec);
1196 assert_eq!(rec.rrects[0].1, theme.scheme().surface_container_high);
1197 assert_eq!(rec.strokes[0].1, theme.scheme().outline);
1198 }
1199
1200 #[test]
1201 fn ghost_style_is_transparent_at_rest_and_washes_on_press() {
1202 let theme = frust_theme::Theme::neutral();
1203 let mut w = styled_widget(ButtonStyle::Ghost);
1204 let mut rec = RRectRecorder::default();
1205 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1206 w.paint(&mut ctx, &mut rec);
1207 assert!(
1208 rec.rrects.is_empty(),
1209 "a transparent resting fill paints no rect"
1210 );
1211 w.pressed = true;
1212 let mut rec2 = RRectRecorder::default();
1213 let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1214 w.paint(&mut ctx2, &mut rec2);
1215 assert_eq!(
1216 rec2.rrects[0].1,
1217 with_alpha(theme.scheme().on_surface, PRESSED_OPACITY)
1218 );
1219 }
1220
1221 #[test]
1222 fn danger_style_paints_error_border_and_error_faint_pressed_wash() {
1223 let theme = frust_theme::Theme::neutral();
1224 let mut w = styled_widget(ButtonStyle::Danger);
1225 let mut rec = RRectRecorder::default();
1226 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1227 w.paint(&mut ctx, &mut rec);
1228 assert!(rec.rrects.is_empty(), "Danger is transparent at rest");
1229 assert_eq!(rec.strokes[0].1, theme.scheme().error);
1230 w.pressed = true;
1231 let mut rec2 = RRectRecorder::default();
1232 let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1233 w.paint(&mut ctx2, &mut rec2);
1234 assert_eq!(rec2.rrects[0].1, theme.scheme().error_container);
1235 }
1236
1237 #[test]
1238 fn per_style_fill_border_label_hold_across_baselines() {
1239 let baselines = [frust_theme::Theme::neutral()];
1248 for theme in &baselines {
1249 let scheme = theme.scheme();
1250
1251 let mut secondary = styled_widget(ButtonStyle::Secondary);
1253 assert_eq!(
1254 ButtonStyle::Secondary.label_role(),
1255 ThemeTextColor::OnSurface
1256 );
1257 let mut rec = RRectRecorder::default();
1258 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(theme);
1259 secondary.paint(&mut ctx, &mut rec);
1260 assert_eq!(rec.rrects[0].1, scheme.surface_container_high);
1261 assert_eq!(rec.strokes[0].1, scheme.outline);
1262
1263 assert_eq!(
1265 ButtonStyle::Ghost.label_role(),
1266 ThemeTextColor::OnSurfaceVariant
1267 );
1268 let mut ghost = styled_widget(ButtonStyle::Ghost);
1269 let mut rec_g = RRectRecorder::default();
1270 let mut ctx_g = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(theme);
1271 ghost.paint(&mut ctx_g, &mut rec_g);
1272 assert!(rec_g.rrects.is_empty());
1273
1274 assert_eq!(ButtonStyle::Danger.label_role(), ThemeTextColor::Error);
1276 let mut danger = styled_widget(ButtonStyle::Danger);
1277 danger.pressed = true;
1278 let mut rec_d = RRectRecorder::default();
1279 let mut ctx_d = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(theme);
1280 danger.paint(&mut ctx_d, &mut rec_d);
1281 assert_eq!(rec_d.rrects[0].1, scheme.error_container);
1282 }
1283 }
1284
1285 #[test]
1286 fn icon_style_layout_is_square() {
1287 let mut w = styled_widget(ButtonStyle::Icon);
1288 let mut text_ctx = frust_text::TextContext::new();
1289 let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
1290 let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
1291 assert_eq!(size.width, size.height, "Icon style must be square");
1292 }
1293
1294 #[test]
1295 fn small_uses_reduced_padding() {
1296 let normal_view = button::<Counter, _>("go", |_: &mut Counter| {});
1297 let small_view = button::<Counter, _>("go", |_: &mut Counter| {}).small();
1298 let mut counter = 0u64;
1299 let mut normal_w = View::<Counter>::build(&normal_view, &mut BuildCtx::new(&mut counter));
1300 let mut small_w = View::<Counter>::build(&small_view, &mut BuildCtx::new(&mut counter));
1301
1302 let mut text_ctx = frust_text::TextContext::new();
1303 let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
1304 let normal_size =
1305 normal_w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
1306 let small_size = small_w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
1307 assert!(
1308 small_size.width < normal_size.width && small_size.height < normal_size.height,
1309 "small() must produce a smaller laid-out box: small={small_size:?} normal={normal_size:?}"
1310 );
1311 }
1312
1313 fn layout_with(w: &mut ButtonWidget, bc: &BoxConstraints) -> (Size, Point) {
1317 let mut text_ctx = frust_text::TextContext::new();
1318 let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
1319 let size = w.layout(&mut lctx, bc);
1320 (size, w.label.origin())
1321 }
1322
1323 #[test]
1324 fn natural_width_button_label_origin_is_unchanged_by_default() {
1325 let view = button::<Counter, _>("go", |_: &mut Counter| {});
1330 let mut counter = 0u64;
1331 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1332 let (size, origin) = layout_with(&mut w, &BoxConstraints::loose(Size::new(200.0, 200.0)));
1333 assert!(size.width < 200.0, "button must not have been stretched");
1334 assert_eq!(origin, Point::new(PAD_X, PAD_Y));
1335 }
1336
1337 #[test]
1338 fn stretched_button_defaults_to_centered_label() {
1339 let view = button::<Counter, _>("go", |_: &mut Counter| {});
1343 let mut counter = 0u64;
1344 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1345 let bc = BoxConstraints::new(Size::new(200.0, 0.0), Size::new(200.0, 200.0));
1346 let (size, origin) = layout_with(&mut w, &bc);
1347 assert_eq!(size.width, 200.0, "min forces the full stretched width");
1348 let label_width = w.label.size().width;
1349 let expected_x = (size.width - label_width) / 2.0;
1350 assert!(
1351 (origin.x - expected_x).abs() < 1e-6,
1352 "stretched button must default to a centered label: got {origin:?}, expected x={expected_x}"
1353 );
1354 assert_eq!(origin.y, PAD_Y);
1356 }
1357
1358 #[test]
1359 fn explicit_leading_alignment_overrides_the_stretched_default() {
1360 let view =
1361 button::<Counter, _>("go", |_: &mut Counter| {}).label_alignment(Alignment::TOP_LEFT);
1362 let mut counter = 0u64;
1363 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1364 let bc = BoxConstraints::new(Size::new(200.0, 0.0), Size::new(200.0, 200.0));
1365 let (size, origin) = layout_with(&mut w, &bc);
1366 assert_eq!(size.width, 200.0, "min forces the full stretched width");
1367 assert_eq!(
1368 origin,
1369 Point::new(PAD_X, PAD_Y),
1370 "an explicit leading alignment pins at the padding even when stretched"
1371 );
1372 }
1373
1374 #[test]
1375 fn explicit_center_alignment_also_centers_vertically_when_stretched() {
1376 let view =
1379 button::<Counter, _>("go", |_: &mut Counter| {}).label_alignment(Alignment::CENTER);
1380 let mut counter = 0u64;
1381 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1382 let bc = BoxConstraints::tight(Size::new(200.0, 100.0));
1383 let (size, origin) = layout_with(&mut w, &bc);
1384 assert_eq!(size, Size::new(200.0, 100.0));
1385 assert!(
1386 origin.y > PAD_Y,
1387 "an explicit CENTER alignment must also center vertically: got {origin:?}"
1388 );
1389 }
1390
1391 #[test]
1392 fn icon_style_ignores_explicit_label_alignment() {
1393 let plain = button::<Counter, _>("i", |_: &mut Counter| {}).style(ButtonStyle::Icon);
1397 let with_alignment = button::<Counter, _>("i", |_: &mut Counter| {})
1398 .style(ButtonStyle::Icon)
1399 .label_alignment(Alignment::TOP_LEFT);
1400 let mut counter = 0u64;
1401 let mut plain_w = View::<Counter>::build(&plain, &mut BuildCtx::new(&mut counter));
1402 let mut aligned_w =
1403 View::<Counter>::build(&with_alignment, &mut BuildCtx::new(&mut counter));
1404 let bc = BoxConstraints::loose(Size::new(200.0, 200.0));
1405 let (plain_size, plain_origin) = layout_with(&mut plain_w, &bc);
1406 let (aligned_size, aligned_origin) = layout_with(&mut aligned_w, &bc);
1407 assert_eq!(
1408 plain_size.width, plain_size.height,
1409 "Icon style must be square"
1410 );
1411 assert_eq!(
1412 (plain_size, plain_origin),
1413 (aligned_size, aligned_origin),
1414 "an explicit label_alignment must be ignored under ButtonStyle::Icon"
1415 );
1416 }
1417
1418 #[test]
1419 fn loading_suppresses_on_press_and_reports_disabled() {
1420 let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1).loading(true);
1421 let mut counter = 0u64;
1422 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1423 let mut state = Counter::default();
1424 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1425 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1426 assert_eq!(state.presses, 0, "loading must suppress on_press");
1427 }
1428
1429 #[test]
1430 fn disabled_suppresses_on_press() {
1431 let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1).disabled(true);
1432 let mut counter = 0u64;
1433 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1434 let mut state = Counter::default();
1435 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1436 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1437 assert_eq!(state.presses, 0, "disabled must suppress on_press");
1438 }
1439
1440 #[test]
1441 fn disabled_blocks_focus_and_press_suppression() {
1442 let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1).disabled(true);
1445 let mut counter = 0u64;
1446 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1447 let mut state = Counter::default();
1448 let state_any: &mut dyn Any = &mut state;
1449 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
1450 let result = w.event(&mut ctx, &ev(PointerPhase::Down, 10.0, 10.0));
1451 assert!(
1452 matches!(result, EventResult::Ignored),
1453 "disabled must return Ignored, not Handled"
1454 );
1455 assert!(!w.captured, "disabled must not capture pointer");
1456 assert!(!w.pressed, "disabled must not set pressed state");
1457 }
1458
1459 #[test]
1460 fn disabled_dims_appearance_by_alpha_multiplication() {
1461 let theme = frust_theme::Theme::neutral();
1466 let mut w = widget();
1467 w.disabled = true;
1468 let mut rec = RRectRecorder::default();
1469 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1470 w.paint(&mut ctx, &mut rec);
1471
1472 let undimmed = theme.scheme().primary;
1474 let dimmed = disabled_alpha(undimmed);
1475 assert_eq!(
1476 rec.rrects[0].1, dimmed,
1477 "disabled button must dim the fill by multiplying alpha: got {:?}, expected {:?}",
1478 rec.rrects[0].1, dimmed
1479 );
1480 }
1481
1482 #[test]
1483 fn disabled_and_loading_interaction() {
1484 let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1)
1488 .loading(true)
1489 .disabled(true);
1490 let mut counter = 0u64;
1491 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1492 let mut state = Counter::default();
1493 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1494 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1495 assert_eq!(state.presses, 0, "disabled+loading must suppress on_press");
1496 assert!(w.loading, "loading flag is preserved");
1498 assert!(w.disabled, "disabled flag is preserved");
1499 }
1500
1501 #[test]
1510 fn loading_spinner_freezes_and_stops_requesting_frames_under_reduce_motion() {
1511 let mut w = widget();
1512 w.loading = true;
1513
1514 let mut theme = frust_theme::Theme::neutral();
1515 theme.motion.reduce_motion = false;
1516 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1517 let mut scene = RRectRecorder::default();
1518 w.paint(&mut ctx, &mut scene);
1519 assert!(
1520 ctx.needs_frame(),
1521 "with reduce_motion off, the loading spinner must keep requesting frames"
1522 );
1523 assert!(
1524 ctx.needs_frame_paced_only(),
1525 "the loading spinner is a CosmeticLoop request — the frame gate must be able to pace it"
1526 );
1527
1528 theme.motion.reduce_motion = true;
1529 let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1530 let mut scene2 = RRectRecorder::default();
1531 w.paint(&mut ctx2, &mut scene2);
1532 assert!(
1533 !ctx2.needs_frame(),
1534 "with reduce_motion on, the loading spinner must stop requesting frames"
1535 );
1536 }
1537
1538 #[test]
1539 fn press_scale_timeline_down_mid_up_cancel() {
1540 let mut w = widget();
1548 let mut state = Counter::default();
1549
1550 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1551 let down_timing = resolve_press_timing(None, true);
1552 assert!(
1553 w.press.advance(FrameTime::from_nanos(0), down_timing),
1554 "the seed frame must still report animating"
1555 );
1556 let mid = FrameTime::from_nanos(50_000_000);
1558 w.press.advance(mid, down_timing);
1559 assert!(
1560 w.press.value() < 1.0,
1561 "mid-press the scale must read below 1.0, got {}",
1562 w.press.value()
1563 );
1564
1565 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1566 assert_eq!(state.presses, 1);
1567 let up_timing = resolve_press_timing(None, false);
1568 let mut t = mid.as_nanos();
1571 let mut still_animating = true;
1572 for _ in 0..1000 {
1573 still_animating = w.press.advance(FrameTime::from_nanos(t), up_timing);
1574 if !still_animating {
1575 break;
1576 }
1577 t += 1_000_000; }
1579 assert!(
1580 !still_animating,
1581 "the release animation must settle within a bounded number of steps"
1582 );
1583 assert!(
1584 (w.press.value() - REST_SCALE).abs() < 1e-6,
1585 "Up must restore to REST_SCALE, got {}",
1586 w.press.value()
1587 );
1588
1589 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1591 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
1592 assert_eq!(state.presses, 1, "Cancel must not fire");
1593 assert_eq!(w.press.target, REST_SCALE, "Cancel retargets to rest");
1594 }
1595}