1use std::rc::Rc;
78use std::time::Duration;
79
80use frust::authoring::text::TextOverflow;
81use frust::authoring::{
82 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedCallback, EventCtx,
83 EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, Role, SemanticsCtx,
84 ThemeTextColor, ThemeTextType, View, Widget, any, build_child, erase_callback, rebuild_child,
85 rebuild_children, route_event, route_event_single, teardown_child, visit_children,
86};
87use frust::{AnimationController, Color, FrameTime, SpringDesc, Theme, icon, text};
88use kurbo::{Point, Rect, Size};
89
90use super::{
91 ACTION_SLOT, BAR_HORIZONTAL_PADDING, BODY_LARGE_LINE_HEIGHT, BODY_LARGE_SIZE, DISABLED_OPACITY,
92 SEARCH_BAR_MIN_HEIGHT, SEARCH_BAR_MIN_WIDTH,
93};
94use crate::icon_button::icon_button;
95use crate::overlay::{finite_or_zero, reduce_motion};
96use crate::press::presses;
97use crate::state_layer::StateLayer;
98
99pub const EXPAND_REST: f64 = 8.0;
102
103pub const EXPAND_ACTIVE: f64 = EXPAND_REST / 2.0;
107
108const EXPAND_SPRING: SpringDesc = SpringDesc {
116 mass: 1.0,
117 stiffness: 380.0,
118 damping_ratio: 0.55,
119};
120
121const EXPAND_PERIOD: Duration = Duration::from_millis(300);
126
127const EXPAND_FLING_VELOCITY: f64 = 4.0;
130
131const RETARGET_TOLERANCE: f64 = 0.1;
135
136const FALLBACK_CONTAINER_HIGH: Color = Color::from_rgb8(0xEC, 0xE6, 0xF0);
139
140const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
142
143type OnText<State> = Rc<dyn Fn(&mut State, String)>;
145
146type OnState<State> = Rc<dyn Fn(&mut State)>;
148
149struct ExpandMotion {
152 from: f64,
153 to: f64,
154 anim: AnimationController,
155}
156
157impl ExpandMotion {
158 fn new() -> Self {
160 Self {
161 from: EXPAND_REST,
162 to: EXPAND_REST,
163 anim: AnimationController::new(EXPAND_PERIOD),
164 }
165 }
166
167 fn retarget(&mut self, target: f64) -> bool {
170 if (self.to - target).abs() <= RETARGET_TOLERANCE {
171 return false;
172 }
173 self.from = self.value();
174 self.to = target;
175 self.anim = AnimationController::new(EXPAND_PERIOD);
176 self.anim.fling(EXPAND_FLING_VELOCITY, EXPAND_SPRING);
177 true
178 }
179
180 fn snap(&mut self) {
183 self.from = self.to;
184 self.anim = AnimationController::new(EXPAND_PERIOD);
185 }
186
187 fn advance(&mut self, now: FrameTime) -> bool {
189 self.anim.advance(now)
190 }
191
192 fn value(&self) -> f64 {
196 let t = self.anim.value();
197 let t = if t.is_finite() { t } else { 1.0 };
198 (self.from + (self.to - self.from) * t).max(0.0)
199 }
200}
201
202pub struct SearchBarView<State: 'static> {
204 query: String,
205 on_query_changed: OnText<State>,
206 hint: Option<String>,
207 leading: Option<AnyView<State>>,
208 trailing: Vec<AnyView<State>>,
209 on_tap: Option<OnState<State>>,
210 enabled: bool,
211}
212
213pub fn search_bar<State: 'static, F: Fn(&mut State, String) + 'static>(
226 query: impl Into<String>,
227 on_query_changed: F,
228) -> SearchBarView<State> {
229 SearchBarView {
230 query: query.into(),
231 on_query_changed: Rc::new(on_query_changed),
232 hint: None,
233 leading: None,
234 trailing: Vec::new(),
235 on_tap: None,
236 enabled: true,
237 }
238}
239
240impl<State: 'static> SearchBarView<State> {
241 pub fn hint(mut self, hint: impl Into<String>) -> Self {
244 self.hint = Some(hint.into());
245 self
246 }
247
248 pub fn leading(mut self, view: impl View<State>) -> Self {
255 self.leading = Some(AnyView::new(view));
256 self
257 }
258
259 pub fn trailing(mut self, view: impl View<State>) -> Self {
264 self.trailing.push(AnyView::new(view));
265 self
266 }
267
268 pub fn on_tap<F: Fn(&mut State) + 'static>(mut self, on_tap: F) -> Self {
272 self.on_tap = Some(Rc::new(on_tap));
273 self
274 }
275
276 pub fn enabled(mut self, enabled: bool) -> Self {
279 self.enabled = enabled;
280 self
281 }
282
283 fn default_leading(&self) -> AnyView<State> {
287 any::<State, _>(icon(crate::icons::SEARCH))
288 }
289
290 fn leading_ref<'a>(&'a self, default: &'a AnyView<State>) -> &'a AnyView<State> {
292 self.leading.as_ref().unwrap_or(default)
293 }
294
295 fn generated_trailing(&self) -> Vec<AnyView<State>> {
298 if !self.trailing.is_empty() || self.query.is_empty() {
299 return Vec::new();
300 }
301 let on_query_changed = self.on_query_changed.clone();
302 vec![any::<State, _>(
303 icon_button(any::<State, _>(icon(crate::icons::CLOSE)), move |state| {
304 on_query_changed(state, String::new());
305 })
306 .semantic_label(CLEAR_LABEL),
307 )]
308 }
309
310 fn trailing_refs<'a>(&'a self, generated: &'a [AnyView<State>]) -> Vec<&'a AnyView<State>> {
313 if self.trailing.is_empty() {
314 generated.iter().collect()
315 } else {
316 self.trailing.iter().collect()
317 }
318 }
319
320 fn label_view(&self) -> AnyView<State> {
323 let (content, role) = if self.query.is_empty() {
324 (
325 self.hint.clone().unwrap_or_default(),
326 ThemeTextColor::OnSurfaceVariant,
327 )
328 } else {
329 (self.query.clone(), ThemeTextColor::OnSurface)
330 };
331 any::<State, _>(
332 text(content)
333 .size(BODY_LARGE_SIZE)
334 .line_height(BODY_LARGE_LINE_HEIGHT)
335 .themed_role(role)
336 .themed_family(ThemeTextType::BodyLarge)
337 .max_lines(1)
338 .overflow(TextOverflow::Ellipsis),
339 )
340 }
341}
342
343const CLEAR_LABEL: &str = "Clear";
345
346impl<State: 'static> View<State> for SearchBarView<State> {
347 type Element = SearchBarWidget;
348
349 fn build(&self, ctx: &mut BuildCtx<'_>) -> SearchBarWidget {
350 let default_leading = self.default_leading();
351 let generated = self.generated_trailing();
352 SearchBarWidget {
353 leading: build_child(self.leading_ref(&default_leading), ctx),
354 label: build_child(&self.label_view(), ctx),
355 trailing: self
356 .trailing_refs(&generated)
357 .into_iter()
358 .map(|v| build_child(v, ctx))
359 .collect(),
360 on_tap: self.on_tap.as_ref().map(erase_callback),
361 enabled: self.enabled,
362 hint: self.hint.clone(),
363 state: StateLayer::new(),
364 captured: false,
365 expand: ExpandMotion::new(),
366 pill: Rect::ZERO,
367 }
368 }
369
370 fn rebuild(
371 &self,
372 prev: &Self,
373 element: &mut SearchBarWidget,
374 ctx: &mut BuildCtx<'_>,
375 ) -> ChangeFlags {
376 let prev_default_leading = prev.default_leading();
377 let next_default_leading = self.default_leading();
378 let mut flags = rebuild_child(
379 prev.leading_ref(&prev_default_leading),
380 self.leading_ref(&next_default_leading),
381 &mut element.leading,
382 ctx,
383 );
384 flags |= rebuild_child(
385 &prev.label_view(),
386 &self.label_view(),
387 &mut element.label,
388 ctx,
389 );
390
391 let prev_generated = prev.generated_trailing();
392 let next_generated = self.generated_trailing();
393 let prev_refs = prev.trailing_refs(&prev_generated);
394 let next_refs = self.trailing_refs(&next_generated);
395 flags |= rebuild_children(
396 &prev_refs,
397 &next_refs,
398 &mut element.trailing,
399 ctx,
400 |view: &&AnyView<State>| *view,
401 |_| None,
402 );
403
404 element.on_tap = self.on_tap.as_ref().map(erase_callback);
405 if element.enabled != self.enabled {
406 element.enabled = self.enabled;
407 if !self.enabled {
408 element.state.set_pressed(false);
409 element.state.set_hovered(false);
410 element.captured = false;
411 }
412 flags |= ChangeFlags::PAINT;
413 }
414 if element.hint != self.hint {
415 element.hint = self.hint.clone();
416 }
417 flags
418 }
419
420 fn teardown(&self, element: &mut SearchBarWidget, ctx: &mut BuildCtx<'_>) {
421 let default_leading = self.default_leading();
422 teardown_child(
423 self.leading_ref(&default_leading),
424 &mut element.leading,
425 ctx,
426 );
427 teardown_child(&self.label_view(), &mut element.label, ctx);
428 let generated = self.generated_trailing();
429 for (view, pod) in self
430 .trailing_refs(&generated)
431 .into_iter()
432 .zip(element.trailing.iter_mut())
433 {
434 teardown_child(view, pod, ctx);
435 }
436 }
437}
438
439pub struct SearchBarWidget {
441 leading: ChildPod,
442 label: ChildPod,
443 trailing: Vec<ChildPod>,
444 on_tap: Option<ErasedCallback>,
445 enabled: bool,
446 hint: Option<String>,
448 state: StateLayer,
449 captured: bool,
450 expand: ExpandMotion,
451 pill: Rect,
454}
455
456impl SearchBarWidget {
457 pub fn pill_rect(&self) -> Rect {
460 self.pill
461 }
462
463 pub fn expand_inset(&self) -> f64 {
465 self.expand.value()
466 }
467}
468
469impl Widget for SearchBarWidget {
470 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
471 let avail = finite_or_zero(bc.max().width);
472 let width = if avail > 0.0 {
473 avail.max(bc.min().width)
474 } else {
475 SEARCH_BAR_MIN_WIDTH
476 };
477 let height = SEARCH_BAR_MIN_HEIGHT.max(bc.min().height);
481 let height = if bc.max().height.is_finite() {
482 height.min(bc.max().height)
483 } else {
484 height
485 };
486
487 let inset = self.expand.value();
488 let inset = inset.min(width / 2.0);
491 self.pill = Rect::new(inset, 0.0, width - inset, height);
492
493 let content_x0 = self.pill.x0 + BAR_HORIZONTAL_PADDING;
494 let content_x1 = self.pill.x1 - BAR_HORIZONTAL_PADDING;
495 let slot_bc = BoxConstraints::loose(Size::new(ACTION_SLOT, height));
496
497 let leading_size = self.leading.layout_child(ctx, &slot_bc);
498 self.leading.set_origin(Point::new(
499 content_x0 + (ACTION_SLOT - leading_size.width) / 2.0,
500 (height - leading_size.height) / 2.0,
501 ));
502
503 let mut trailing_x = content_x1;
506 for pod in self.trailing.iter_mut().rev() {
507 let size = pod.layout_child(ctx, &slot_bc);
508 trailing_x -= ACTION_SLOT;
509 pod.set_origin(Point::new(
510 trailing_x + (ACTION_SLOT - size.width) / 2.0,
511 (height - size.height) / 2.0,
512 ));
513 }
514
515 let label_x0 = content_x0 + ACTION_SLOT;
516 let label_w = (trailing_x - label_x0).max(0.0);
517 let label_size = self
518 .label
519 .layout_child(ctx, &BoxConstraints::loose(Size::new(label_w, height)));
520 self.label
521 .set_origin(Point::new(label_x0, (height - label_size.height) / 2.0));
522
523 Size::new(width, height)
524 }
525
526 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
527 let (reduce, fill, ink) = {
530 let theme = Theme::from_paint_ctx(ctx);
531 (
532 reduce_motion(theme),
533 theme.map_or(FALLBACK_CONTAINER_HIGH, |t| {
534 t.scheme().surface_container_high
535 }),
536 theme.map_or(FALLBACK_ON_SURFACE, |t| t.scheme().on_surface),
537 )
538 };
539 let before = self.expand.value();
550 if reduce {
551 self.expand.snap();
552 } else if self.expand.advance(ctx.frame_time()) {
553 ctx.request_layout();
554 ctx.request_frame();
555 }
556 if self.expand.value() != before {
557 ctx.request_layout();
558 }
559
560 let hovered = ctx.is_hovered() && self.enabled;
564 self.state.set_hovered(hovered);
565
566 let origin = ctx.origin();
567 let pill_origin = Point::new(origin.x + self.pill.x0, origin.y + self.pill.y0);
568 let pill = Rect::from_origin_size(pill_origin, self.pill.size());
569 let radius = pill.height() / 2.0;
570
571 let dimmed = !self.enabled;
574 if dimmed {
575 scene.push_layer(pill.origin(), pill.size(), DISABLED_OPACITY);
576 }
577 scene.fill_rounded_rect(pill.origin(), pill.size(), radius, fill);
578 self.state.paint(ctx, scene, pill, radius, ink);
579 self.leading.paint_child(ctx, scene);
580 self.label.paint_child(ctx, scene);
581 for pod in &mut self.trailing {
582 pod.paint_child(ctx, scene);
583 }
584 if dimmed {
585 scene.pop_layer();
586 }
587 }
588
589 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
590 if event.is_broadcast() {
592 self.leading.event_child(ctx, event);
593 self.label.event_child(ctx, event);
594 for pod in &mut self.trailing {
595 pod.event_child(ctx, event);
596 }
597 return EventResult::Ignored;
598 }
599 if !self.enabled {
600 return EventResult::Ignored;
603 }
604 if !self.captured {
608 if route_event_single(&mut self.leading, ctx, event) == EventResult::Handled {
609 return EventResult::Handled;
610 }
611 if route_event(&mut self.trailing, ctx, event) == EventResult::Handled {
612 return EventResult::Handled;
613 }
614 }
615
616 let InputEvent::Pointer(p) = event else {
617 return EventResult::Ignored;
618 };
619 match p.phase {
620 PointerPhase::Down => {
621 if !presses(p) || !self.pill.contains(p.position) {
622 return EventResult::Ignored;
623 }
624 self.state.set_pressed(true);
625 self.captured = true;
626 self.expand.retarget(EXPAND_ACTIVE);
627 ctx.capture_pointer();
628 ctx.request_redraw();
629 EventResult::Handled
630 }
631 PointerPhase::Move => {
632 if !self.captured {
633 let over = self.pill.contains(p.position);
637 if over {
638 ctx.claim_hover();
639 }
640 if self.state.set_hovered(over) {
641 ctx.request_redraw();
642 }
643 return EventResult::Ignored;
644 }
645 if self.state.set_pressed(self.pill.contains(p.position)) {
646 ctx.request_redraw();
647 }
648 EventResult::Handled
649 }
650 PointerPhase::Up => {
651 if !self.captured {
652 return EventResult::Ignored;
653 }
654 let inside = self.pill.contains(p.position);
655 self.state.set_pressed(false);
656 self.captured = false;
657 self.expand.retarget(EXPAND_REST);
658 if inside && let Some(on_tap) = self.on_tap.as_mut() {
659 on_tap(ctx);
660 }
661 ctx.request_redraw();
662 EventResult::Handled
663 }
664 PointerPhase::Cancel => {
665 if !self.captured {
666 return EventResult::Ignored;
667 }
668 self.state.set_pressed(false);
670 self.captured = false;
671 self.expand.retarget(EXPAND_REST);
672 ctx.request_redraw();
673 EventResult::Handled
674 }
675 }
676 }
677
678 fn semantics(&self, ctx: &mut SemanticsCtx) {
679 let label = self.hint.clone();
680 ctx.push_container(
681 Role::SearchInput,
682 |node| {
683 if let Some(label) = &label {
684 node.set_label(label.as_str());
685 }
686 },
687 |ctx| {
688 self.leading.semantics_child(ctx);
689 self.label.semantics_child(ctx);
690 for pod in &self.trailing {
691 pod.semantics_child(ctx);
692 }
693 },
694 );
695 }
696
697 visit_children!(leading, label, trailing);
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703 use frust::authoring::text::TextContext;
704 use frust::authoring::{PointerButton, PointerEvent, any as core_any};
705 use frust_core::RenderRoot;
706 use std::any::Any;
707
708 const WINDOW: Size = Size::new(400.0, 200.0);
710
711 #[derive(Default)]
712 struct App {
713 query: String,
714 taps: u32,
715 }
716
717 #[derive(Default)]
718 struct Recorder {
719 rrects: Vec<(Point, Size, f64, Color)>,
720 layers: Vec<f32>,
721 }
722
723 impl PaintScene for Recorder {
724 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
725 fn draw_text(&mut self, _o: Point, _t: &str) {}
726 fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
727 self.rrects.push((origin, size, radius, color));
728 }
729 fn push_layer(&mut self, _o: Point, _s: Size, alpha: f32) {
730 self.layers.push(alpha);
731 }
732 fn pop_layer(&mut self) {}
733 }
734
735 fn build<S: 'static>(view: &SearchBarView<S>) -> SearchBarWidget {
736 let mut counter = 0u64;
737 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
738 }
739
740 fn layout(w: &mut SearchBarWidget, bc: &BoxConstraints) -> Size {
741 let mut tcx = TextContext::new();
742 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
743 w.layout(&mut lctx, bc)
744 }
745
746 fn paint(w: &mut SearchBarWidget) -> Recorder {
747 let mut rec = Recorder::default();
748 let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::ZERO);
749 w.paint(&mut pctx, &mut rec);
750 rec
751 }
752
753 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
754 InputEvent::Pointer(PointerEvent {
755 phase,
756 position: Point::new(x, y),
757 button: PointerButton::Primary,
758 })
759 }
760
761 fn dispatch(w: &mut SearchBarWidget, state: &mut App, event: &InputEvent) -> EventResult {
762 let state_any: &mut dyn Any = state;
763 let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
764 w.event(&mut ctx, event)
765 }
766
767 fn bar(query: &str) -> SearchBarView<App> {
768 search_bar(query.to_string(), |s: &mut App, q| s.query = q)
769 }
770
771 #[test]
774 fn the_bar_is_a_56dp_pill_inset_by_the_resting_expand_padding() {
775 let view = bar("").hint("Search");
776 let mut w = build(&view);
777 let size = layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
778
779 assert_eq!(size, Size::new(400.0, SEARCH_BAR_MIN_HEIGHT));
780 let pill = w.pill_rect();
781 assert_eq!(pill.x0, EXPAND_REST);
782 assert_eq!(pill.x1, 400.0 - EXPAND_REST);
783 assert_eq!(pill.height(), SEARCH_BAR_MIN_HEIGHT);
784 }
785
786 #[test]
787 fn a_loose_box_still_floors_the_height_at_the_spec_56dp() {
788 let view = bar("");
789 let mut w = build(&view);
790 let size = layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 500.0)));
791 assert_eq!(size.height, SEARCH_BAR_MIN_HEIGHT);
792 }
793
794 #[test]
795 fn an_unbounded_width_falls_back_to_the_reference_min_width() {
796 let view = bar("");
797 let mut w = build(&view);
798 let size = layout(
799 &mut w,
800 &BoxConstraints::loose(Size::new(f64::INFINITY, 500.0)),
801 );
802 assert_eq!(size.width, SEARCH_BAR_MIN_WIDTH);
803 }
804
805 #[test]
806 fn a_narrow_parent_clamps_rather_than_overflowing() {
807 let view = bar("");
808 let mut w = build(&view);
809 let size = layout(&mut w, &BoxConstraints::tight(Size::new(300.0, 56.0)));
810 assert_eq!(
811 size.width, 300.0,
812 "the reference's ConstrainedBox would overflow; this port clamps"
813 );
814 }
815
816 #[test]
817 fn the_leading_slot_sits_in_a_48dp_target_at_the_pill_start() {
818 let view = bar("");
819 let mut w = build(&view);
820 layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
821 let expected_x = EXPAND_REST + BAR_HORIZONTAL_PADDING + (ACTION_SLOT - 24.0) / 2.0;
823 assert_eq!(w.leading.origin().x, expected_x);
824 }
825
826 #[test]
827 fn a_non_empty_query_grows_the_built_in_clear_action_at_the_end_edge() {
828 let empty = bar("");
829 let w = build(&empty);
830 assert!(
831 w.trailing.is_empty(),
832 "an empty query carries no clear action"
833 );
834
835 let filled = bar("pasta");
836 let mut w = build(&filled);
837 assert_eq!(w.trailing.len(), 1, "a non-empty query grows one");
838 layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
839 let slot_x = w.trailing[0].origin().x;
840 assert!(
841 slot_x > 400.0 - EXPAND_REST - BAR_HORIZONTAL_PADDING - ACTION_SLOT - 1.0,
842 "the clear action packs against the end edge"
843 );
844 }
845
846 #[test]
847 fn an_explicit_trailing_action_replaces_the_built_in_clear() {
848 let view = bar("pasta").trailing(leaf_any_app(24.0, 24.0));
849 let w = build(&view);
850 assert_eq!(w.trailing.len(), 1);
851 }
854
855 #[test]
858 fn the_pill_paints_the_surface_container_high_role() {
859 let theme = crate::baseline();
860 let view = bar("").hint("Search");
861 let mut w = build(&view);
862 layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
863
864 let mut rec = Recorder::default();
865 let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::ZERO).with_theme(&theme);
866 w.paint(&mut pctx, &mut rec);
867
868 let (origin, size, radius, color) = rec.rrects[0];
869 assert_eq!(origin, Point::new(EXPAND_REST, 0.0));
870 assert_eq!(size, Size::new(400.0 - 2.0 * EXPAND_REST, 56.0));
871 assert_eq!(radius, 28.0, "a stadium pill rounds to half its height");
872 assert_eq!(color, theme.scheme().surface_container_high);
873 }
874
875 #[test]
876 fn an_unthemed_pill_paints_the_m3_baseline_light_container() {
877 let light = crate::baseline().with_brightness(frust::Brightness::Light);
878 let view = bar("");
879 let mut w = build(&view);
880 layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
881 let rec = paint(&mut w);
882 assert_eq!(rec.rrects[0].3, light.scheme().surface_container_high);
883 }
884
885 #[test]
886 fn a_disabled_bar_composites_at_the_reference_disabled_opacity() {
887 let view = bar("").enabled(false);
888 let mut w = build(&view);
889 layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
890 let rec = paint(&mut w);
891 assert_eq!(rec.layers, vec![DISABLED_OPACITY]);
892 }
893
894 #[test]
897 fn a_press_and_release_inside_the_pill_fires_on_tap() {
898 let view = bar("").on_tap(|s: &mut App| s.taps += 1);
899 let mut w = build(&view);
900 layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
901
902 let mut state = App::default();
903 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, 28.0));
904 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 200.0, 28.0));
905 assert_eq!(state.taps, 1);
906 }
907
908 #[test]
909 fn a_release_outside_the_pill_fires_nothing() {
910 let view = bar("").on_tap(|s: &mut App| s.taps += 1);
911 let mut w = build(&view);
912 layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
913
914 let mut state = App::default();
915 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, 28.0));
916 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 200.0, 400.0));
917 assert_eq!(state.taps, 0);
918 }
919
920 #[test]
921 fn a_secondary_press_never_arms_the_bar() {
922 let view = bar("").on_tap(|s: &mut App| s.taps += 1);
923 let mut w = build(&view);
924 layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
925
926 let mut state = App::default();
927 let down = InputEvent::Pointer(PointerEvent {
928 phase: PointerPhase::Down,
929 position: Point::new(200.0, 28.0),
930 button: PointerButton::Secondary,
931 });
932 assert_eq!(
933 dispatch(&mut w, &mut state, &down),
934 EventResult::Ignored,
935 "a context gesture is not an activation"
936 );
937 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 200.0, 28.0));
938 assert_eq!(state.taps, 0);
939 }
940
941 #[test]
942 fn a_disabled_bar_arms_nothing() {
943 let view = bar("").enabled(false).on_tap(|s: &mut App| s.taps += 1);
944 let mut w = build(&view);
945 layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
946
947 let mut state = App::default();
948 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, 28.0));
949 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 200.0, 28.0));
950 assert_eq!(state.taps, 0);
951 }
952
953 #[test]
954 fn a_cancel_clears_the_press_without_firing() {
955 let view = bar("").on_tap(|s: &mut App| s.taps += 1);
956 let mut w = build(&view);
957 layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
958
959 let mut state = App::default();
960 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, 28.0));
961 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 200.0, 28.0));
962 assert_eq!(state.taps, 0);
963 assert!(!w.captured);
964 }
965
966 #[test]
967 fn the_clear_action_reports_an_empty_query_through_on_query_changed() {
968 fn logic(state: &mut App) -> SearchBarView<App> {
969 search_bar(state.query.clone(), |s: &mut App, q| s.query = q).hint("Search")
970 }
971 let mut root: RenderRoot<App, SearchBarView<App>> = RenderRoot::new();
972 let mut state = App {
973 query: "pasta".into(),
974 taps: 0,
975 };
976 root.rebuild(&mut logic, &mut state);
977 let mut tcx = TextContext::new();
978 root.layout_with_text(Size::new(400.0, 56.0), &mut tcx as &mut dyn Any);
979
980 let x = 400.0 - EXPAND_REST - BAR_HORIZONTAL_PADDING - ACTION_SLOT / 2.0;
982 root.event(&mut state, &ev(PointerPhase::Down, x, 28.0));
983 root.event(&mut state, &ev(PointerPhase::Up, x, 28.0));
984 assert_eq!(state.query, "", "the built-in clear empties the query");
985 }
986
987 #[test]
990 fn the_expand_spring_matches_the_reference_focus_expand_spring() {
991 let token = crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS;
994 assert_eq!(EXPAND_SPRING.stiffness, token.stiffness);
995 assert_eq!(EXPAND_SPRING.damping_ratio, token.damping_ratio);
996 assert_eq!(EXPAND_SPRING.mass, 1.0);
997 assert_eq!(
998 EXPAND_ACTIVE,
999 EXPAND_REST / 2.0,
1000 "the reference's _focusedExpandPadding is half the resting inset"
1001 );
1002 }
1003
1004 #[test]
1005 fn a_press_springs_the_inset_toward_the_active_value_and_back() {
1006 let view = bar("").on_tap(|s: &mut App| s.taps += 1);
1007 let mut w = build(&view);
1008 layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
1009 assert_eq!(w.expand_inset(), EXPAND_REST);
1010
1011 let mut state = App::default();
1012 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, 28.0));
1013 let mut rec = Recorder::default();
1015 let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::ZERO);
1016 w.paint(&mut pctx, &mut rec);
1017 let mut pctx =
1018 PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::from_nanos(3_000_000_000));
1019 w.paint(&mut pctx, &mut rec);
1020 assert!(
1021 (w.expand_inset() - EXPAND_ACTIVE).abs() < 0.5,
1022 "a held press settles at the active inset, got {}",
1023 w.expand_inset()
1024 );
1025
1026 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 200.0, 28.0));
1027 for t in [3_500_000_000u64, 7_000_000_000] {
1030 let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::from_nanos(t));
1031 w.paint(&mut pctx, &mut rec);
1032 }
1033 assert!(
1034 (w.expand_inset() - EXPAND_REST).abs() < 0.5,
1035 "the release springs back to the resting inset, got {}",
1036 w.expand_inset()
1037 );
1038 }
1039
1040 #[test]
1041 fn paint_requests_layout_through_the_press_settle_frame() {
1042 let view = bar("").on_tap(|s: &mut App| s.taps += 1);
1051 let mut w = build(&view);
1052 layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
1053 assert_eq!(w.pill.x0, EXPAND_REST);
1054
1055 let mut state = App::default();
1056 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, 28.0));
1057
1058 let mut needs_layout = true;
1059 let mut last_x0 = w.pill.x0;
1060 let mut t = 0u64;
1061 let mut frames = 0u32;
1062 loop {
1063 if !needs_layout {
1064 break;
1065 }
1066 layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
1067 last_x0 = w.pill.x0;
1068 let mut rec = Recorder::default();
1069 let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::from_nanos(t));
1070 w.paint(&mut pctx, &mut rec);
1071 needs_layout = pctx.needs_layout();
1072 frames += 1;
1073 assert!(
1074 frames < 600,
1075 "the press spring should settle well inside 600 frames"
1076 );
1077 t += 16_000_000;
1078 }
1079 assert!(frames > 1, "the press spring spans more than one frame");
1080 assert!(
1081 (last_x0 - EXPAND_ACTIVE).abs() < 1e-9,
1082 "the last layout a shell-honest driver runs lands exactly on the active inset, got {last_x0}"
1083 );
1084 }
1085
1086 #[test]
1087 fn reduce_motion_snaps_the_inset_with_no_ramp() {
1088 let theme = crate::baseline();
1089 let mut theme = theme;
1090 theme.motion.reduce_motion = true;
1091 let view = bar("").on_tap(|s: &mut App| s.taps += 1);
1092 let mut w = build(&view);
1093 layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
1094 assert_eq!(w.pill.x0, EXPAND_REST, "starts at the resting inset");
1095
1096 let mut state = App::default();
1097 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, 28.0));
1098 let mut rec = Recorder::default();
1099 let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::ZERO).with_theme(&theme);
1100 w.paint(&mut pctx, &mut rec);
1101 assert_eq!(w.expand_inset(), EXPAND_ACTIVE);
1102 assert!(
1103 pctx.needs_layout(),
1104 "the snap moved a layout-relevant value (the pill inset), so it must request a relayout"
1105 );
1106
1107 layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
1114 assert_eq!(
1115 w.pill.x0, EXPAND_ACTIVE,
1116 "the painted/hit-tested pill reaches the snapped inset after the requested relayout"
1117 );
1118 }
1119
1120 #[test]
1123 fn semantics_is_a_search_input_labelled_by_the_hint() {
1124 fn logic(_s: &mut App) -> SearchBarView<App> {
1125 search_bar("", |_: &mut App, _| {}).hint("Search recipes")
1126 }
1127 let mut root: RenderRoot<App, SearchBarView<App>> = RenderRoot::new();
1128 let mut state = App::default();
1129 root.rebuild(&mut logic, &mut state);
1130 let mut tcx = TextContext::new();
1131 root.layout_with_text(Size::new(400.0, 56.0), &mut tcx as &mut dyn Any);
1132
1133 let update = root.semantics();
1134 let (_, node) = update
1135 .nodes
1136 .iter()
1137 .find(|(_, n)| n.role() == Role::SearchInput)
1138 .expect("a Role::SearchInput node is contributed");
1139 assert_eq!(node.label(), Some("Search recipes"));
1140 }
1141
1142 fn leaf_any_app(w: f64, h: f64) -> AnyView<App> {
1145 core_any::<App, _>(FixedLeaf {
1146 size: Size::new(w, h),
1147 })
1148 }
1149 struct FixedLeaf {
1150 size: Size,
1151 }
1152 struct FixedLeafW {
1153 size: Size,
1154 }
1155 impl View<App> for FixedLeaf {
1156 type Element = FixedLeafW;
1157 fn build(&self, _c: &mut BuildCtx<'_>) -> FixedLeafW {
1158 FixedLeafW { size: self.size }
1159 }
1160 fn rebuild(&self, _p: &Self, _e: &mut FixedLeafW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1161 ChangeFlags::NONE
1162 }
1163 }
1164 impl Widget for FixedLeafW {
1165 fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1166 bc.constrain(self.size)
1167 }
1168 fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
1169 }
1170}