1use std::rc::Rc;
83use std::time::Duration;
84
85use frust_core::accesskit::{Action, Role};
86use frust_core::{
87 AnimationController, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, Curve, EventCtx,
88 EventResult, FrameTime, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase,
89 SemanticsCtx, Tween, View, Widget, any,
90};
91use frust_theme::Theme;
92use kurbo::{Affine, Arc as KurboArc, Point, RoundedRect, Shape, Size, Vec2};
93use peniko::{Brush, Color};
94
95use crate::authoring::presses;
96use crate::nav::transition::{TransitionDriver, make_driver};
97use crate::shortcuts::{activation, press_claims_focus};
98use crate::text;
99use crate::text::ThemeTextColor;
100use crate::{Alignment, Timing, authoring::PRESSED_OPACITY};
101
102const RADIUS: f64 = 6.0;
105const PAD_X: f64 = 12.0;
111const PAD_Y: f64 = 8.0;
114const SMALL_PAD_X: f64 = 8.0;
118const SMALL_PAD_Y: f64 = 4.0;
120const FILL: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
123const FILL_PRESSED: Color = Color::from_rgb8(0x1D, 0x4E, 0xD8);
126
127const SECONDARY_FILL: Color = Color::from_rgb8(0xE5, 0xE7, 0xEB);
132const SECONDARY_FILL_PRESSED: Color = Color::from_rgb8(0xD1, 0xD5, 0xDB);
134const SECONDARY_BORDER: Color = Color::from_rgb8(0x9C, 0xA3, 0xAF);
136const GHOST_PRESSED_INK: Color = Color::from_rgb8(0x11, 0x18, 0x27);
139const DANGER_BORDER: Color = Color::from_rgb8(0xDC, 0x26, 0x26);
141const DANGER_PRESSED_WASH: Color = Color::from_rgb8(0xFE, 0xE2, 0xE2);
143const UNTHEMED_INK: Color = Color::from_rgb8(0x00, 0x00, 0x00);
146
147const BORDER_WIDTH: f64 = 1.0;
150const BORDER_TOLERANCE: f64 = 0.1;
153
154const PRESSED_DARKEN: f32 = 0.82;
159
160const DISABLED_ALPHA: f32 = 0.38;
171
172const PRESSED_SCALE: f64 = 0.96;
174const REST_SCALE: f64 = 1.0;
176const FALLBACK_PRESS_DURATION: Duration = Duration::from_millis(100);
181
182const SPINNER_RADIUS_RATIO: f64 = 0.4;
186const SPINNER_MIN_RADIUS: f64 = 5.0;
189const SPINNER_STROKE_WIDTH: f64 = 2.0;
191const SPINNER_SWEEP: f64 = std::f64::consts::PI * 1.5;
194const SPINNER_PERIOD_MS: u64 = 900;
200
201fn pressed_overlay(color: Color, factor: f32) -> Color {
205 let c = color.components;
206 Color::new([c[0] * factor, c[1] * factor, c[2] * factor, c[3]])
207}
208
209fn with_alpha(color: Color, alpha: f32) -> Color {
212 let c = color.components;
213 Color::new([c[0], c[1], c[2], alpha])
214}
215
216fn disabled_alpha(color: Color) -> Color {
219 let c = color.components;
220 Color::new([c[0], c[1], c[2], c[3] * DISABLED_ALPHA])
221}
222
223fn scale_about(pivot: Point, scale: f64) -> Affine {
227 Affine::translate((pivot.x, pivot.y))
228 * Affine::scale(scale)
229 * Affine::translate((-pivot.x, -pivot.y))
230}
231
232type OnPress<State> = Rc<dyn Fn(&mut State)>;
234
235#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
239pub enum ButtonStyle {
240 #[default]
242 Primary,
243 Secondary,
246 Ghost,
250 Danger,
253 Icon,
256}
257
258impl ButtonStyle {
259 fn label_role(self) -> ThemeTextColor {
264 match self {
265 ButtonStyle::Primary => ThemeTextColor::OnPrimary,
266 ButtonStyle::Secondary | ButtonStyle::Icon => ThemeTextColor::OnSurface,
267 ButtonStyle::Ghost => ThemeTextColor::OnSurfaceVariant,
268 ButtonStyle::Danger => ThemeTextColor::Error,
269 }
270 }
271
272 fn resolve_ink(self, theme: Option<&Theme>) -> Color {
280 match theme {
281 Some(theme) => {
282 let scheme = theme.scheme();
283 match self {
284 ButtonStyle::Primary => scheme.on_primary,
285 ButtonStyle::Secondary | ButtonStyle::Icon => scheme.on_surface,
286 ButtonStyle::Ghost => scheme.on_surface_variant,
287 ButtonStyle::Danger => scheme.error,
288 }
289 }
290 None => UNTHEMED_INK,
291 }
292 }
293
294 fn resolve(self, theme: Option<&Theme>) -> StylePaint {
300 match theme {
301 Some(theme) => {
302 let scheme = theme.scheme();
303 match self {
304 ButtonStyle::Primary => StylePaint {
305 fill: scheme.primary,
306 fill_pressed: pressed_overlay(scheme.primary, PRESSED_DARKEN),
307 border: None,
308 },
309 ButtonStyle::Secondary | ButtonStyle::Icon => StylePaint {
310 fill: scheme.surface_container_high,
311 fill_pressed: pressed_overlay(
312 scheme.surface_container_high,
313 PRESSED_DARKEN,
314 ),
315 border: Some(scheme.outline),
316 },
317 ButtonStyle::Ghost => StylePaint {
318 fill: Color::TRANSPARENT,
319 fill_pressed: with_alpha(scheme.on_surface, PRESSED_OPACITY),
320 border: None,
321 },
322 ButtonStyle::Danger => StylePaint {
323 fill: Color::TRANSPARENT,
324 fill_pressed: scheme.error_container,
325 border: Some(scheme.error),
326 },
327 }
328 }
329 None => match self {
330 ButtonStyle::Primary => StylePaint {
331 fill: FILL,
332 fill_pressed: FILL_PRESSED,
333 border: None,
334 },
335 ButtonStyle::Secondary | ButtonStyle::Icon => StylePaint {
336 fill: SECONDARY_FILL,
337 fill_pressed: SECONDARY_FILL_PRESSED,
338 border: Some(SECONDARY_BORDER),
339 },
340 ButtonStyle::Ghost => StylePaint {
341 fill: Color::TRANSPARENT,
342 fill_pressed: with_alpha(GHOST_PRESSED_INK, PRESSED_OPACITY),
343 border: None,
344 },
345 ButtonStyle::Danger => StylePaint {
346 fill: Color::TRANSPARENT,
347 fill_pressed: DANGER_PRESSED_WASH,
348 border: Some(DANGER_BORDER),
349 },
350 },
351 }
352 }
353}
354
355struct StylePaint {
357 fill: Color,
358 fill_pressed: Color,
359 border: Option<Color>,
360}
361
362fn resolve_press_timing(theme: Option<&Theme>, pressed: bool) -> Timing {
368 match theme {
369 Some(theme) => {
370 if pressed {
371 let instant_ms = theme.motion.durations.instant.max(0.0);
372 Timing::Duration(
373 Duration::from_secs_f64(instant_ms / 1000.0),
374 theme.motion.easing.exit,
375 )
376 } else {
377 Timing::Spring(theme.motion.default_spatial)
378 }
379 }
380 None => {
381 let curve = if pressed {
382 Curve::EaseIn
383 } else {
384 Curve::EaseOut
385 };
386 Timing::Duration(FALLBACK_PRESS_DURATION, curve)
387 }
388 }
389}
390
391struct PressAnim {
400 target: f64,
401 driving_target: f64,
402 tween: Tween<f64>,
403 driver: TransitionDriver,
404}
405
406impl PressAnim {
407 fn new() -> Self {
409 let (driver, _) = make_driver(Timing::Duration(Duration::ZERO, Curve::Linear));
410 Self {
411 target: REST_SCALE,
412 driving_target: REST_SCALE,
413 tween: Tween::new(REST_SCALE, REST_SCALE),
414 driver,
415 }
416 }
417
418 fn value(&self) -> f64 {
420 self.tween.lerp(self.driver.value())
421 }
422
423 fn set_pressed(&mut self, pressed: bool) {
426 self.target = if pressed { PRESSED_SCALE } else { REST_SCALE };
427 }
428
429 fn advance(&mut self, now: FrameTime, timing: Timing) -> bool {
432 if self.target != self.driving_target {
433 let from = self.value();
434 self.tween = Tween::new(from, self.target);
435 self.driving_target = self.target;
436 let (driver, _) = make_driver(timing);
437 self.driver = driver;
438 }
439 self.driver.advance(now).animating
440 }
441}
442
443fn label_view<State: 'static>(label: String, style: ButtonStyle) -> frust_core::AnyView<State> {
449 any::<State, _>(text(label).themed_role(style.label_role()))
450}
451
452pub struct ButtonView<State: 'static> {
454 label: String,
455 on_press: OnPress<State>,
456 style: ButtonStyle,
457 small: bool,
458 loading: bool,
459 disabled: bool,
460 label_alignment: Option<Alignment>,
463}
464
465pub fn button<State: 'static, F: Fn(&mut State) + 'static>(
468 label: impl Into<String>,
469 on_press: F,
470) -> ButtonView<State> {
471 ButtonView {
472 label: label.into(),
473 on_press: Rc::new(on_press),
474 style: ButtonStyle::default(),
475 small: false,
476 loading: false,
477 disabled: false,
478 label_alignment: None,
479 }
480}
481
482#[allow(non_snake_case)]
484pub fn Button<State: 'static, F: Fn(&mut State) + 'static>(
485 label: impl Into<String>,
486 on_press: F,
487) -> ButtonView<State> {
488 button(label, on_press)
489}
490
491impl<State: 'static> ButtonView<State> {
492 pub fn style(mut self, style: ButtonStyle) -> Self {
494 self.style = style;
495 self
496 }
497
498 pub fn small(mut self) -> Self {
500 self.small = true;
501 self
502 }
503
504 pub fn loading(mut self, loading: bool) -> Self {
508 self.loading = loading;
509 self
510 }
511
512 pub fn disabled(mut self, disabled: bool) -> Self {
518 self.disabled = disabled;
519 self
520 }
521
522 pub fn label_alignment(mut self, alignment: Alignment) -> Self {
527 self.label_alignment = Some(alignment);
528 self
529 }
530}
531
532pub struct ButtonWidget {
535 label: ChildPod,
536 label_text: String,
540 pressed: bool,
544 captured: bool,
549 on_press: crate::authoring::ErasedCallback,
550 style: ButtonStyle,
551 small: bool,
552 loading: bool,
553 disabled: bool,
555 label_alignment: Option<Alignment>,
558 press: PressAnim,
560 spinner: AnimationController,
563}
564
565fn inside(pos: Point, size: Size) -> bool {
568 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
569}
570
571impl ButtonWidget {
572 fn resolve_radius(theme: Option<&Theme>, size: Size) -> f64 {
578 match theme {
579 Some(theme) => {
580 frust_theme::ShapeScale::resolve(theme.shape.small, size.width, size.height)
581 }
582 None => RADIUS,
583 }
584 }
585
586 fn resolve_label_origin(
594 alignment: Option<Alignment>,
595 pad_x: f64,
596 pad_y: f64,
597 final_size: Size,
598 content_size: Size,
599 ) -> Point {
600 let free_x = (final_size.width - content_size.width).max(0.0);
606 let free_y = (final_size.height - content_size.height).max(0.0);
607 let effective = alignment
614 .unwrap_or_else(|| Alignment::new(if free_x > 0.0 { 0.0 } else { -1.0 }, -1.0));
615 let frac = |component: f64| (component + 1.0) / 2.0;
616 Point::new(
617 pad_x + free_x * frac(effective.x),
618 pad_y + free_y * frac(effective.y),
619 )
620 }
621
622 fn paint_spinner(&self, ctx: &PaintCtx, scene: &mut dyn PaintScene, color: Color) {
625 let size = ctx.size();
626 let center_local = Point::new(size.width / 2.0, size.height / 2.0);
627 let radius = (size.height / 2.0 * SPINNER_RADIUS_RATIO).max(SPINNER_MIN_RADIUS);
628 let angle = self.spinner.value() * std::f64::consts::TAU;
629 let arc = KurboArc::new(
630 center_local,
631 Vec2::new(radius, radius),
632 angle,
633 SPINNER_SWEEP,
634 0.0,
635 );
636 let path = arc.to_path(0.1);
637 scene.stroke_path(
638 ctx.origin(),
639 &path,
640 SPINNER_STROKE_WIDTH,
641 &Brush::Solid(color),
642 );
643 }
644}
645
646impl<State: 'static> View<State> for ButtonView<State> {
647 type Element = ButtonWidget;
648
649 fn build(&self, ctx: &mut BuildCtx<'_>) -> ButtonWidget {
650 let label_view = label_view::<State>(self.label.clone(), self.style);
651 let mut spinner = AnimationController::new(Duration::from_millis(SPINNER_PERIOD_MS))
652 .with_curve(Curve::Linear);
653 spinner.repeat();
654 ButtonWidget {
655 label: crate::authoring::build_child(&label_view, ctx),
656 label_text: self.label.clone(),
657 pressed: false,
658 captured: false,
659 on_press: crate::authoring::erase_callback(&self.on_press),
660 style: self.style,
661 small: self.small,
662 loading: self.loading,
663 disabled: self.disabled,
664 label_alignment: self.label_alignment,
665 press: PressAnim::new(),
666 spinner,
667 }
668 }
669
670 fn rebuild(
671 &self,
672 prev: &Self,
673 element: &mut ButtonWidget,
674 ctx: &mut BuildCtx<'_>,
675 ) -> ChangeFlags {
676 element.on_press = crate::authoring::erase_callback(&self.on_press);
678 let mut flags = ChangeFlags::NONE;
679 if prev.label != self.label || prev.style != self.style {
680 element.label_text = self.label.clone();
681 let prev_view = label_view::<State>(prev.label.clone(), prev.style);
682 let next_view = label_view::<State>(self.label.clone(), self.style);
683 flags |=
684 crate::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
685 }
686 if prev.style != self.style {
687 element.style = self.style;
688 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
689 }
690 if prev.small != self.small {
691 element.small = self.small;
692 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
693 }
694 if prev.label_alignment != self.label_alignment {
695 element.label_alignment = self.label_alignment;
696 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
701 }
702 if prev.loading != self.loading {
703 element.loading = self.loading;
704 flags |= ChangeFlags::PAINT;
705 if self.loading {
706 element.pressed = false;
711 element.captured = false;
712 element.press.set_pressed(false);
713 }
714 }
715 if prev.disabled != self.disabled {
716 element.disabled = self.disabled;
717 flags |= ChangeFlags::PAINT;
718 if self.disabled {
719 element.pressed = false;
723 element.captured = false;
724 element.press.set_pressed(false);
725 }
726 }
727 flags
728 }
729
730 fn teardown(&self, element: &mut ButtonWidget, ctx: &mut BuildCtx<'_>) {
731 let label_view = label_view::<State>(self.label.clone(), self.style);
732 crate::authoring::teardown_child(&label_view, &mut element.label, ctx);
733 }
734}
735
736impl Widget for ButtonWidget {
737 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
738 let (pad_x, pad_y) = if self.small {
739 (SMALL_PAD_X, SMALL_PAD_Y)
740 } else {
741 (PAD_X, PAD_Y)
742 };
743 let inset = Size::new(pad_x * 2.0, pad_y * 2.0);
745 let inner_max = Size::new(
746 (bc.max().width - inset.width).max(0.0),
747 (bc.max().height - inset.height).max(0.0),
748 );
749 let label_size = self
750 .label
751 .layout_child(ctx, &BoxConstraints::loose(inner_max));
752 let mut size = Size::new(
753 label_size.width + inset.width,
754 label_size.height + inset.height,
755 );
756 let content_size = size;
761 if self.style == ButtonStyle::Icon {
762 let side = size.width.max(size.height);
767 size = Size::new(side, side);
768 self.label.set_origin(Point::new(
769 (side - label_size.width) / 2.0,
770 (side - label_size.height) / 2.0,
771 ));
772 } else {
773 let final_size = bc.constrain(size);
774 self.label.set_origin(Self::resolve_label_origin(
775 self.label_alignment,
776 pad_x,
777 pad_y,
778 final_size,
779 content_size,
780 ));
781 }
782 bc.constrain(size)
783 }
784
785 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
786 let theme = Theme::from_paint_ctx(ctx);
792 let mut paint = self.style.resolve(theme);
793 let radius = Self::resolve_radius(theme, ctx.size());
794 let press_timing = resolve_press_timing(theme, self.pressed);
798 let mut ink = self.style.resolve_ink(theme);
799 let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
803
804 if self.disabled {
809 paint.fill = disabled_alpha(paint.fill);
810 paint.fill_pressed = disabled_alpha(paint.fill_pressed);
811 if let Some(ref mut border) = paint.border {
812 *border = disabled_alpha(*border);
813 }
814 ink = disabled_alpha(ink);
815 }
816
817 let fill = if self.pressed {
818 paint.fill_pressed
819 } else {
820 paint.fill
821 };
822
823 if self.press.advance(ctx.frame_time(), press_timing) {
824 ctx.request_frame();
825 }
826 let scale = self.press.value();
827 let origin = ctx.origin();
828 let size = ctx.size();
829 let pivot = Point::new(origin.x + size.width / 2.0, origin.y + size.height / 2.0);
830 scene.push_transform(scale_about(pivot, scale));
831
832 if fill != Color::TRANSPARENT {
833 scene.fill_rounded_rect(origin, size, radius, fill);
834 }
835 if let Some(border) = paint.border {
836 let half = BORDER_WIDTH / 2.0;
840 let rr = RoundedRect::new(
841 half,
842 half,
843 size.width - half,
844 size.height - half,
845 (radius - half).max(0.0),
846 );
847 let path = rr.to_path(BORDER_TOLERANCE);
848 scene.stroke_path(origin, &path, BORDER_WIDTH, &Brush::Solid(border));
849 }
850
851 if self.loading {
852 if !reduce_motion {
857 self.spinner.advance(ctx.frame_time());
858 ctx.request_frame_paced();
861 }
862 self.paint_spinner(ctx, scene, ink);
863 } else {
864 self.label.paint_child(ctx, scene);
865 }
866
867 scene.pop_transform();
868 }
869
870 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
871 if self.loading || self.disabled {
874 return EventResult::Ignored;
875 }
876 if activation(ctx, event) {
877 (self.on_press)(ctx);
878 ctx.request_redraw();
879 return EventResult::Handled;
880 }
881 let InputEvent::Pointer(p) = event else {
882 return EventResult::Ignored;
883 };
884 match p.phase {
885 PointerPhase::Down => {
886 if !presses(p) {
887 return EventResult::Ignored;
888 }
889 if press_claims_focus(ctx) {
890 ctx.request_focus();
891 }
892 self.pressed = true;
893 self.captured = true;
894 self.press.set_pressed(true);
895 ctx.capture_pointer();
896 ctx.request_redraw();
897 EventResult::Handled
898 }
899 PointerPhase::Move => {
900 if !self.captured {
903 return EventResult::Ignored;
904 }
905 self.pressed = inside(p.position, ctx.size());
907 self.press.set_pressed(self.pressed);
908 ctx.request_redraw();
909 EventResult::Handled
910 }
911 PointerPhase::Up => {
912 if !self.captured {
913 return EventResult::Ignored;
914 }
915 if inside(p.position, ctx.size()) {
917 (self.on_press)(ctx);
918 }
919 self.pressed = false;
920 self.captured = false;
921 self.press.set_pressed(false);
922 ctx.request_redraw();
923 EventResult::Handled
924 }
925 PointerPhase::Cancel => {
926 if !self.captured {
927 return EventResult::Ignored;
928 }
929 self.pressed = false;
930 self.captured = false;
931 self.press.set_pressed(false);
932 ctx.request_redraw();
933 EventResult::Handled
934 }
935 }
936 }
937
938 fn semantics(&self, ctx: &mut SemanticsCtx) {
939 ctx.push_node(Role::Button, |node| {
944 node.set_label(self.label_text.as_str());
945 if self.loading || self.disabled {
946 node.set_disabled();
947 } else {
948 node.add_action(Action::Click);
949 }
950 });
951 }
952
953 fn is_focusable(&self) -> bool {
956 !self.disabled
957 }
958
959 crate::authoring::visit_children!(label);
960}
961
962#[cfg(test)]
963mod tests {
964 use super::*;
965 use std::any::Any;
966
967 #[derive(Default)]
968 struct Counter {
969 presses: u32,
970 }
971
972 fn widget() -> ButtonWidget {
975 let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1);
976 let mut counter = 0u64;
977 View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter))
978 }
979
980 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
981 InputEvent::Pointer(frust_core::PointerEvent {
982 phase,
983 position: Point::new(x, y),
984 button: frust_core::PointerButton::Primary,
985 })
986 }
987
988 fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
990 InputEvent::Pointer(frust_core::PointerEvent {
991 phase,
992 position: Point::new(x, y),
993 button: frust_core::PointerButton::Secondary,
994 })
995 }
996
997 fn dispatch(w: &mut ButtonWidget, state: &mut Counter, event: &InputEvent) {
998 let state_any: &mut dyn Any = state;
999 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
1000 w.event(&mut ctx, event);
1001 }
1002
1003 #[test]
1004 fn a_secondary_press_neither_presses_nor_captures_nor_fires() {
1005 let mut w = widget();
1006 let mut state = Counter::default();
1007 dispatch(
1008 &mut w,
1009 &mut state,
1010 &secondary_ev(PointerPhase::Down, 10.0, 10.0),
1011 );
1012 assert!(!w.pressed, "no pressed chrome on a right-click");
1013 assert!(!w.captured, "and no capture for the shell to wedge on");
1014 dispatch(
1015 &mut w,
1016 &mut state,
1017 &secondary_ev(PointerPhase::Up, 10.0, 10.0),
1018 );
1019 assert_eq!(state.presses, 0);
1020
1021 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1023 assert!(w.pressed);
1024 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1025 assert_eq!(state.presses, 1);
1026 }
1027
1028 #[test]
1029 fn down_then_up_inside_fires_once() {
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 assert!(w.pressed);
1034 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 12.0, 12.0));
1035 assert_eq!(state.presses, 1);
1036 assert!(!w.pressed);
1037 }
1038
1039 #[test]
1040 fn down_inside_move_out_up_outside_does_not_fire() {
1041 let mut w = widget();
1042 let mut state = Counter::default();
1043 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1044 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 200.0, 10.0));
1045 assert!(!w.pressed, "moving out clears the pressed visual");
1046 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 200.0, 10.0));
1047 assert_eq!(state.presses, 0, "up outside must not fire");
1048 }
1049
1050 #[test]
1051 fn cancel_clears_pressed_without_firing() {
1052 let mut w = widget();
1053 let mut state = Counter::default();
1054 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1055 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
1056 assert!(!w.pressed);
1057 assert_eq!(state.presses, 0);
1058 }
1059
1060 #[test]
1061 fn hover_move_without_down_is_ignored_noop() {
1062 let mut w = widget();
1063 let mut state = Counter::default();
1064 let state_any: &mut dyn Any = &mut state;
1067 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
1068 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 20.0, 20.0));
1069 assert!(matches!(result, EventResult::Ignored));
1070 assert!(!w.pressed, "hover must not press");
1071 assert!(!ctx.needs_redraw(), "hover must not request a redraw");
1072 assert_eq!(state.presses, 0);
1073 }
1074
1075 #[test]
1076 fn up_without_down_does_not_fire() {
1077 let mut w = widget();
1078 let mut state = Counter::default();
1079 let state_any: &mut dyn Any = &mut state;
1080 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
1081 let result = w.event(&mut ctx, &ev(PointerPhase::Up, 20.0, 20.0));
1082 assert!(matches!(result, EventResult::Ignored));
1083 assert_eq!(state.presses, 0, "an unarmed Up must never fire");
1084 }
1085
1086 #[test]
1087 fn cancel_clears_armed_state() {
1088 let mut w = widget();
1089 let mut state = Counter::default();
1090 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1091 assert!(w.captured);
1092 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
1093 assert!(!w.captured, "Cancel disarms the press");
1094 let state_any: &mut dyn Any = &mut state;
1096 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
1097 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 12.0, 12.0));
1098 assert!(matches!(result, EventResult::Ignored));
1099 assert!(!w.pressed);
1100 }
1101
1102 #[derive(Default)]
1107 struct RRectRecorder {
1108 rrects: Vec<(f64, Color)>,
1109 strokes: Vec<(f64, Color)>,
1110 transforms: Vec<Affine>,
1111 transform_pops: u32,
1112 }
1113
1114 impl PaintScene for RRectRecorder {
1115 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1116 fn draw_text(&mut self, _o: Point, _t: &str) {}
1117 fn fill_rounded_rect(&mut self, _o: Point, _s: Size, radius: f64, color: Color) {
1118 self.rrects.push((radius, color));
1119 }
1120 fn stroke_path(
1121 &mut self,
1122 _origin: Point,
1123 _path: &kurbo::BezPath,
1124 width: f64,
1125 brush: &Brush,
1126 ) {
1127 if let Brush::Solid(color) = brush {
1128 self.strokes.push((width, *color));
1129 }
1130 }
1131 fn push_transform(&mut self, transform: Affine) {
1132 self.transforms.push(transform);
1133 }
1134 fn pop_transform(&mut self) {
1135 self.transform_pops += 1;
1136 }
1137 }
1138
1139 fn paint_bg(w: &mut ButtonWidget, theme: Option<&frust_theme::Theme>) -> (f64, Color) {
1140 let mut rec = RRectRecorder::default();
1141 let mut ctx = match theme {
1142 Some(t) => PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(t),
1143 None => PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)),
1144 };
1145 w.paint(&mut ctx, &mut rec);
1146 *rec.rrects.first().expect("button paints its background")
1147 }
1148
1149 #[test]
1150 fn unthemed_paint_uses_fallback_constants() {
1151 let mut w = widget();
1153 assert_eq!(paint_bg(&mut w, None), (RADIUS, FILL));
1154 w.pressed = true;
1156 assert_eq!(paint_bg(&mut w, None), (RADIUS, FILL_PRESSED));
1157 }
1158
1159 #[test]
1160 fn themed_paint_resolves_primary_and_shape_small() {
1161 let theme = frust_theme::Theme::neutral();
1162 let mut w = widget();
1163 let (radius, color) = paint_bg(&mut w, Some(&theme));
1164 assert_eq!(color, theme.scheme().primary, "resting fill is primary");
1165 assert_eq!(radius, theme.shape.small, "radius is shape.small (8dp)");
1166 w.pressed = true;
1168 let (_, pressed) = paint_bg(&mut w, Some(&theme));
1169 assert_eq!(
1170 pressed,
1171 pressed_overlay(theme.scheme().primary, PRESSED_DARKEN)
1172 );
1173 }
1174
1175 #[test]
1176 fn move_back_inside_then_up_fires() {
1177 let mut w = widget();
1179 let mut state = Counter::default();
1180 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1181 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 200.0, 10.0));
1182 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 20.0, 10.0));
1183 assert!(w.pressed);
1184 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 20.0, 10.0));
1185 assert_eq!(state.presses, 1);
1186 }
1187
1188 fn styled_widget(style: ButtonStyle) -> ButtonWidget {
1191 let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1).style(style);
1192 let mut counter = 0u64;
1193 View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter))
1194 }
1195
1196 #[test]
1197 fn default_style_is_primary_and_matches_pre_task22_rendering() {
1198 assert_eq!(ButtonStyle::default(), ButtonStyle::Primary);
1203 let mut w = widget();
1204 assert_eq!(w.style, ButtonStyle::Primary);
1205 assert_eq!(paint_bg(&mut w, None), (RADIUS, FILL));
1206 let theme = frust_theme::Theme::neutral();
1207 let mut w2 = widget();
1208 assert_eq!(paint_bg(&mut w2, Some(&theme)).1, theme.scheme().primary);
1209 }
1210
1211 #[test]
1212 fn secondary_style_paints_raised_surface_and_outline_border() {
1213 let theme = frust_theme::Theme::neutral();
1214 let mut w = styled_widget(ButtonStyle::Secondary);
1215 let mut rec = RRectRecorder::default();
1216 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1217 w.paint(&mut ctx, &mut rec);
1218 assert_eq!(rec.rrects[0].1, theme.scheme().surface_container_high);
1219 assert_eq!(rec.strokes[0].1, theme.scheme().outline);
1220 }
1221
1222 #[test]
1223 fn ghost_style_is_transparent_at_rest_and_washes_on_press() {
1224 let theme = frust_theme::Theme::neutral();
1225 let mut w = styled_widget(ButtonStyle::Ghost);
1226 let mut rec = RRectRecorder::default();
1227 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1228 w.paint(&mut ctx, &mut rec);
1229 assert!(
1230 rec.rrects.is_empty(),
1231 "a transparent resting fill paints no rect"
1232 );
1233 w.pressed = true;
1234 let mut rec2 = RRectRecorder::default();
1235 let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1236 w.paint(&mut ctx2, &mut rec2);
1237 assert_eq!(
1238 rec2.rrects[0].1,
1239 with_alpha(theme.scheme().on_surface, PRESSED_OPACITY)
1240 );
1241 }
1242
1243 #[test]
1244 fn danger_style_paints_error_border_and_error_faint_pressed_wash() {
1245 let theme = frust_theme::Theme::neutral();
1246 let mut w = styled_widget(ButtonStyle::Danger);
1247 let mut rec = RRectRecorder::default();
1248 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1249 w.paint(&mut ctx, &mut rec);
1250 assert!(rec.rrects.is_empty(), "Danger is transparent at rest");
1251 assert_eq!(rec.strokes[0].1, theme.scheme().error);
1252 w.pressed = true;
1253 let mut rec2 = RRectRecorder::default();
1254 let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1255 w.paint(&mut ctx2, &mut rec2);
1256 assert_eq!(rec2.rrects[0].1, theme.scheme().error_container);
1257 }
1258
1259 #[test]
1260 fn per_style_fill_border_label_hold_across_baselines() {
1261 let baselines = [frust_theme::Theme::neutral()];
1270 for theme in &baselines {
1271 let scheme = theme.scheme();
1272
1273 let mut secondary = styled_widget(ButtonStyle::Secondary);
1275 assert_eq!(
1276 ButtonStyle::Secondary.label_role(),
1277 ThemeTextColor::OnSurface
1278 );
1279 let mut rec = RRectRecorder::default();
1280 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(theme);
1281 secondary.paint(&mut ctx, &mut rec);
1282 assert_eq!(rec.rrects[0].1, scheme.surface_container_high);
1283 assert_eq!(rec.strokes[0].1, scheme.outline);
1284
1285 assert_eq!(
1287 ButtonStyle::Ghost.label_role(),
1288 ThemeTextColor::OnSurfaceVariant
1289 );
1290 let mut ghost = styled_widget(ButtonStyle::Ghost);
1291 let mut rec_g = RRectRecorder::default();
1292 let mut ctx_g = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(theme);
1293 ghost.paint(&mut ctx_g, &mut rec_g);
1294 assert!(rec_g.rrects.is_empty());
1295
1296 assert_eq!(ButtonStyle::Danger.label_role(), ThemeTextColor::Error);
1298 let mut danger = styled_widget(ButtonStyle::Danger);
1299 danger.pressed = true;
1300 let mut rec_d = RRectRecorder::default();
1301 let mut ctx_d = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(theme);
1302 danger.paint(&mut ctx_d, &mut rec_d);
1303 assert_eq!(rec_d.rrects[0].1, scheme.error_container);
1304 }
1305 }
1306
1307 #[test]
1308 fn icon_style_layout_is_square() {
1309 let mut w = styled_widget(ButtonStyle::Icon);
1310 let mut text_ctx = frust_text::TextContext::new();
1311 let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
1312 let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
1313 assert_eq!(size.width, size.height, "Icon style must be square");
1314 }
1315
1316 #[test]
1317 fn small_uses_reduced_padding() {
1318 let normal_view = button::<Counter, _>("go", |_: &mut Counter| {});
1319 let small_view = button::<Counter, _>("go", |_: &mut Counter| {}).small();
1320 let mut counter = 0u64;
1321 let mut normal_w = View::<Counter>::build(&normal_view, &mut BuildCtx::new(&mut counter));
1322 let mut small_w = View::<Counter>::build(&small_view, &mut BuildCtx::new(&mut counter));
1323
1324 let mut text_ctx = frust_text::TextContext::new();
1325 let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
1326 let normal_size =
1327 normal_w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
1328 let small_size = small_w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
1329 assert!(
1330 small_size.width < normal_size.width && small_size.height < normal_size.height,
1331 "small() must produce a smaller laid-out box: small={small_size:?} normal={normal_size:?}"
1332 );
1333 }
1334
1335 fn layout_with(w: &mut ButtonWidget, bc: &BoxConstraints) -> (Size, Point) {
1339 let mut text_ctx = frust_text::TextContext::new();
1340 let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
1341 let size = w.layout(&mut lctx, bc);
1342 (size, w.label.origin())
1343 }
1344
1345 #[test]
1346 fn natural_width_button_label_origin_is_unchanged_by_default() {
1347 let view = button::<Counter, _>("go", |_: &mut Counter| {});
1352 let mut counter = 0u64;
1353 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1354 let (size, origin) = layout_with(&mut w, &BoxConstraints::loose(Size::new(200.0, 200.0)));
1355 assert!(size.width < 200.0, "button must not have been stretched");
1356 assert_eq!(origin, Point::new(PAD_X, PAD_Y));
1357 }
1358
1359 #[test]
1360 fn stretched_button_defaults_to_centered_label() {
1361 let view = button::<Counter, _>("go", |_: &mut Counter| {});
1365 let mut counter = 0u64;
1366 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1367 let bc = BoxConstraints::new(Size::new(200.0, 0.0), Size::new(200.0, 200.0));
1368 let (size, origin) = layout_with(&mut w, &bc);
1369 assert_eq!(size.width, 200.0, "min forces the full stretched width");
1370 let label_width = w.label.size().width;
1371 let expected_x = (size.width - label_width) / 2.0;
1372 assert!(
1373 (origin.x - expected_x).abs() < 1e-6,
1374 "stretched button must default to a centered label: got {origin:?}, expected x={expected_x}"
1375 );
1376 assert_eq!(origin.y, PAD_Y);
1378 }
1379
1380 #[test]
1381 fn explicit_leading_alignment_overrides_the_stretched_default() {
1382 let view =
1383 button::<Counter, _>("go", |_: &mut Counter| {}).label_alignment(Alignment::TOP_LEFT);
1384 let mut counter = 0u64;
1385 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1386 let bc = BoxConstraints::new(Size::new(200.0, 0.0), Size::new(200.0, 200.0));
1387 let (size, origin) = layout_with(&mut w, &bc);
1388 assert_eq!(size.width, 200.0, "min forces the full stretched width");
1389 assert_eq!(
1390 origin,
1391 Point::new(PAD_X, PAD_Y),
1392 "an explicit leading alignment pins at the padding even when stretched"
1393 );
1394 }
1395
1396 #[test]
1397 fn explicit_center_alignment_also_centers_vertically_when_stretched() {
1398 let view =
1401 button::<Counter, _>("go", |_: &mut Counter| {}).label_alignment(Alignment::CENTER);
1402 let mut counter = 0u64;
1403 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1404 let bc = BoxConstraints::tight(Size::new(200.0, 100.0));
1405 let (size, origin) = layout_with(&mut w, &bc);
1406 assert_eq!(size, Size::new(200.0, 100.0));
1407 assert!(
1408 origin.y > PAD_Y,
1409 "an explicit CENTER alignment must also center vertically: got {origin:?}"
1410 );
1411 }
1412
1413 #[test]
1414 fn icon_style_ignores_explicit_label_alignment() {
1415 let plain = button::<Counter, _>("i", |_: &mut Counter| {}).style(ButtonStyle::Icon);
1419 let with_alignment = button::<Counter, _>("i", |_: &mut Counter| {})
1420 .style(ButtonStyle::Icon)
1421 .label_alignment(Alignment::TOP_LEFT);
1422 let mut counter = 0u64;
1423 let mut plain_w = View::<Counter>::build(&plain, &mut BuildCtx::new(&mut counter));
1424 let mut aligned_w =
1425 View::<Counter>::build(&with_alignment, &mut BuildCtx::new(&mut counter));
1426 let bc = BoxConstraints::loose(Size::new(200.0, 200.0));
1427 let (plain_size, plain_origin) = layout_with(&mut plain_w, &bc);
1428 let (aligned_size, aligned_origin) = layout_with(&mut aligned_w, &bc);
1429 assert_eq!(
1430 plain_size.width, plain_size.height,
1431 "Icon style must be square"
1432 );
1433 assert_eq!(
1434 (plain_size, plain_origin),
1435 (aligned_size, aligned_origin),
1436 "an explicit label_alignment must be ignored under ButtonStyle::Icon"
1437 );
1438 }
1439
1440 #[test]
1441 fn loading_suppresses_on_press_and_reports_disabled() {
1442 let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1).loading(true);
1443 let mut counter = 0u64;
1444 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1445 let mut state = Counter::default();
1446 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1447 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1448 assert_eq!(state.presses, 0, "loading must suppress on_press");
1449 }
1450
1451 #[test]
1452 fn disabled_suppresses_on_press() {
1453 let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1).disabled(true);
1454 let mut counter = 0u64;
1455 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1456 let mut state = Counter::default();
1457 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1458 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1459 assert_eq!(state.presses, 0, "disabled must suppress on_press");
1460 }
1461
1462 #[test]
1463 fn disabled_blocks_focus_and_press_suppression() {
1464 let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1).disabled(true);
1467 let mut counter = 0u64;
1468 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1469 let mut state = Counter::default();
1470 let state_any: &mut dyn Any = &mut state;
1471 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
1472 let result = w.event(&mut ctx, &ev(PointerPhase::Down, 10.0, 10.0));
1473 assert!(
1474 matches!(result, EventResult::Ignored),
1475 "disabled must return Ignored, not Handled"
1476 );
1477 assert!(!w.captured, "disabled must not capture pointer");
1478 assert!(!w.pressed, "disabled must not set pressed state");
1479 }
1480
1481 #[test]
1482 fn disabled_dims_appearance_by_alpha_multiplication() {
1483 let theme = frust_theme::Theme::neutral();
1488 let mut w = widget();
1489 w.disabled = true;
1490 let mut rec = RRectRecorder::default();
1491 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1492 w.paint(&mut ctx, &mut rec);
1493
1494 let undimmed = theme.scheme().primary;
1496 let dimmed = disabled_alpha(undimmed);
1497 assert_eq!(
1498 rec.rrects[0].1, dimmed,
1499 "disabled button must dim the fill by multiplying alpha: got {:?}, expected {:?}",
1500 rec.rrects[0].1, dimmed
1501 );
1502 }
1503
1504 #[test]
1505 fn disabled_and_loading_interaction() {
1506 let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1)
1510 .loading(true)
1511 .disabled(true);
1512 let mut counter = 0u64;
1513 let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1514 let mut state = Counter::default();
1515 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1516 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1517 assert_eq!(state.presses, 0, "disabled+loading must suppress on_press");
1518 assert!(w.loading, "loading flag is preserved");
1520 assert!(w.disabled, "disabled flag is preserved");
1521 }
1522
1523 #[test]
1532 fn loading_spinner_freezes_and_stops_requesting_frames_under_reduce_motion() {
1533 let mut w = widget();
1534 w.loading = true;
1535
1536 let mut theme = frust_theme::Theme::neutral();
1537 theme.motion.reduce_motion = false;
1538 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1539 let mut scene = RRectRecorder::default();
1540 w.paint(&mut ctx, &mut scene);
1541 assert!(
1542 ctx.needs_frame(),
1543 "with reduce_motion off, the loading spinner must keep requesting frames"
1544 );
1545 assert!(
1546 ctx.needs_frame_paced_only(),
1547 "the loading spinner is a CosmeticLoop request — the frame gate must be able to pace it"
1548 );
1549
1550 theme.motion.reduce_motion = true;
1551 let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1552 let mut scene2 = RRectRecorder::default();
1553 w.paint(&mut ctx2, &mut scene2);
1554 assert!(
1555 !ctx2.needs_frame(),
1556 "with reduce_motion on, the loading spinner must stop requesting frames"
1557 );
1558 }
1559
1560 #[test]
1561 fn press_scale_timeline_down_mid_up_cancel() {
1562 let mut w = widget();
1570 let mut state = Counter::default();
1571
1572 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1573 let down_timing = resolve_press_timing(None, true);
1574 assert!(
1575 w.press.advance(FrameTime::from_nanos(0), down_timing),
1576 "the seed frame must still report animating"
1577 );
1578 let mid = FrameTime::from_nanos(50_000_000);
1580 w.press.advance(mid, down_timing);
1581 assert!(
1582 w.press.value() < 1.0,
1583 "mid-press the scale must read below 1.0, got {}",
1584 w.press.value()
1585 );
1586
1587 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1588 assert_eq!(state.presses, 1);
1589 let up_timing = resolve_press_timing(None, false);
1590 let mut t = mid.as_nanos();
1593 let mut still_animating = true;
1594 for _ in 0..1000 {
1595 still_animating = w.press.advance(FrameTime::from_nanos(t), up_timing);
1596 if !still_animating {
1597 break;
1598 }
1599 t += 1_000_000; }
1601 assert!(
1602 !still_animating,
1603 "the release animation must settle within a bounded number of steps"
1604 );
1605 assert!(
1606 (w.press.value() - REST_SCALE).abs() < 1e-6,
1607 "Up must restore to REST_SCALE, got {}",
1608 w.press.value()
1609 );
1610
1611 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1613 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
1614 assert_eq!(state.presses, 1, "Cancel must not fire");
1615 assert_eq!(w.press.target, REST_SCALE, "Cancel retargets to rest");
1616 }
1617
1618 mod keyboard {
1619 use super::*;
1620 use frust_core::{DiscardScene, Key, KeyEvent, Modifiers, NamedKey, RenderRoot};
1621
1622 fn key(key: Key, modifiers: Modifiers) -> InputEvent {
1623 InputEvent::Key(KeyEvent {
1624 key,
1625 modifiers,
1626 repeat: false,
1627 })
1628 }
1629
1630 fn space() -> InputEvent {
1631 key(Key::Character(" ".to_string()), Modifiers::default())
1632 }
1633
1634 fn root(disabled: bool) -> RenderRoot<Counter, crate::FlexView<Counter>> {
1635 let mut root = RenderRoot::new();
1636 let mut logic = move |_: &mut Counter| {
1637 let go = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1);
1638 crate::column().child(go.disabled(disabled))
1639 };
1640 root.rebuild(&mut logic, &mut Counter::default());
1641 let mut tcx = frust_text::TextContext::new();
1642 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1643 root.paint(&mut DiscardScene, FrameTime::ZERO);
1644 root
1645 }
1646
1647 #[test]
1648 fn an_enabled_button_is_focusable_and_a_disabled_one_is_not() {
1649 assert!(widget().is_focusable());
1650 let view = button::<Counter, _>("go", |_| {}).disabled(true);
1651 let w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut 0u64));
1652 assert!(!w.is_focusable());
1653 }
1654
1655 #[test]
1656 fn tab_focuses_the_button_then_space_or_enter_presses_it() {
1657 let mut root = root(false);
1658 let mut state = Counter::default();
1659 root.event(&mut state, &space());
1660 assert_eq!(state.presses, 0, "an unfocused button takes no keys");
1661 root.event(
1662 &mut state,
1663 &key(Key::Named(NamedKey::Tab), Modifiers::default()),
1664 );
1665 assert!(root.is_focus_active());
1666 assert!(root.event(&mut state, &space()).handled);
1667 let enter = key(Key::Named(NamedKey::Enter), Modifiers::default());
1668 assert!(root.event(&mut state, &enter).handled);
1669 assert_eq!(state.presses, 2);
1670 let ctrl = Modifiers {
1671 ctrl: true,
1672 ..Modifiers::default()
1673 };
1674 root.event(&mut state, &key(Key::Named(NamedKey::Enter), ctrl));
1675 assert_eq!(state.presses, 2, "a chorded Enter is left for shortcuts");
1676 }
1677
1678 #[test]
1679 fn a_click_claims_focus_and_a_disabled_button_is_skipped() {
1680 let mut root = root(false);
1681 let mut state = Counter::default();
1682 root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1683 root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1684 assert_eq!(state.presses, 1);
1685 assert!(root.is_focus_active(), "a press claims focus");
1686
1687 let mut root = self::root(true);
1688 root.event(
1689 &mut state,
1690 &key(Key::Named(NamedKey::Tab), Modifiers::default()),
1691 );
1692 assert!(!root.is_focus_active(), "no stop to land on");
1693 }
1694
1695 #[test]
1696 fn a_press_keeps_focus_so_the_next_tab_moves_to_the_next_stop() {
1697 let mut root: RenderRoot<Counter, crate::FlexView<Counter>> = RenderRoot::new();
1698 let mut logic = |_: &mut Counter| {
1699 crate::column()
1700 .child(button::<Counter, _>("a", |s: &mut Counter| s.presses += 1))
1701 .child(button::<Counter, _>("b", |s: &mut Counter| s.presses += 10))
1702 };
1703 let mut state = Counter::default();
1704 root.rebuild(&mut logic, &mut state);
1705 let mut tcx = frust_text::TextContext::new();
1706 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1707 root.paint(&mut DiscardScene, FrameTime::ZERO);
1708 root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1709 root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1710 assert!(root.is_focus_active());
1711 root.event(
1712 &mut state,
1713 &key(Key::Named(NamedKey::Tab), Modifiers::default()),
1714 );
1715 root.event(&mut state, &space());
1716 assert_eq!(state.presses, 11, "Tab went to the second button");
1717 }
1718
1719 #[test]
1720 fn a_touch_tap_presses_without_claiming_focus() {
1721 let mut root = root(false);
1722 let mut state = Counter::default();
1723 for phase in [PointerPhase::Down, PointerPhase::Up] {
1724 let InputEvent::Pointer(event) = ev(phase, 5.0, 5.0) else {
1725 unreachable!()
1726 };
1727 let contact = InputEvent::PointerContact {
1728 pointer_id: frust_core::event::PointerId::touch(0),
1729 event,
1730 };
1731 root.event(&mut state, &contact);
1732 }
1733 assert_eq!(state.presses, 1);
1734 assert!(!root.is_focus_active(), "a tap leaves focus alone");
1735 }
1736
1737 #[test]
1738 fn an_unfocused_key_event_is_ignored() {
1739 let mut w = widget();
1740 let mut state = Counter::default();
1741 let state_any: &mut dyn Any = &mut state;
1742 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
1743 assert_eq!(w.event(&mut ctx, &space()), EventResult::Ignored);
1744 }
1745 }
1746}