1use std::rc::Rc;
62
63use frust::authoring::{
64 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CornerRadii, EventCtx, EventResult,
65 InputEvent, LayoutCtx, PaintCtx, PaintScene, Role, SemanticsCtx, View, Widget, any,
66 build_child, rebuild_child, rebuild_children, route_event, route_event_single, teardown_child,
67 visit_children,
68};
69use frust::{NavigatorController, PopResult, Theme, icon, text_input};
70use kurbo::{Point, Size};
71
72use super::{
73 ACTION_SLOT, SEARCH_VIEW_HEIGHT_FRACTION, SEARCH_VIEW_MIN_HEIGHT, SEARCH_VIEW_MIN_WIDTH,
74 SearchViewMode,
75};
76use crate::icon_button::icon_button;
77use crate::overlay::anchored::{AnchoredOverlayView, AnchoredOverlayWidget};
78use crate::overlay::{
79 OverlayAlign, OverlayAnchor, OverlayContainer, OverlayCorners, OverlayElevation,
80 OverlayEntrance, OverlayExtent, OverlayLimit, OverlayModalConfig, OverlayModalContent,
81 OverlayModalView, OverlayModalWidget, OverlayPlacement, OverlaySide, anchored_overlay,
82 container, finite_or_zero, overlay_modal, radius, shadow, show_overlay_modal,
83};
84
85const BACK_LABEL: &str = "Back";
87
88const CLEAR_LABEL: &str = "Clear";
91
92type OnText<State> = Rc<dyn Fn(&mut State, String)>;
94
95type OnState<State> = Rc<dyn Fn(&mut State)>;
97
98fn full_screen_config() -> OverlayModalConfig {
113 OverlayModalConfig::edge(OverlaySide::Top)
114 .extent(OverlayExtent::Fraction(1.0), OverlayLimit::None)
115 .corners(OverlayCorners::None)
116 .container(OverlayContainer::High)
117 .elevation(OverlayElevation::None)
118 .entrance(OverlayEntrance::Slide)
119}
120
121fn docked_placement(anchor: &OverlayAnchor) -> OverlayPlacement {
132 OverlayPlacement::on(OverlaySide::Bottom)
133 .align(OverlayAlign::Start)
134 .offset(-anchor.rect().height())
135 .flip(false)
136 .clamp(true)
137}
138
139pub struct SearchView<State: 'static> {
142 query: String,
143 on_query_changed: OnText<State>,
144 hint: Option<String>,
145 leading: Option<AnyView<State>>,
146 trailing: Vec<AnyView<State>>,
147 suggestions: Option<AnyView<State>>,
148 on_submit: Option<OnText<State>>,
149 on_back: Option<OnState<State>>,
150 dismissable: bool,
151 on_dismiss: Option<OnState<State>>,
152}
153
154pub fn search_view<State: 'static, F: Fn(&mut State, String) + 'static>(
173 query: impl Into<String>,
174 on_query_changed: F,
175) -> SearchView<State> {
176 SearchView {
177 query: query.into(),
178 on_query_changed: Rc::new(on_query_changed),
179 hint: None,
180 leading: None,
181 trailing: Vec::new(),
182 suggestions: None,
183 on_submit: None,
184 on_back: None,
185 dismissable: true,
186 on_dismiss: None,
187 }
188}
189
190impl<State: 'static> SearchView<State> {
191 pub fn hint(mut self, hint: impl Into<String>) -> Self {
194 self.hint = Some(hint.into());
195 self
196 }
197
198 pub fn leading(mut self, view: AnyView<State>) -> Self {
201 self.leading = Some(view);
202 self
203 }
204
205 pub fn trailing(mut self, view: AnyView<State>) -> Self {
209 self.trailing.push(view);
210 self
211 }
212
213 pub fn suggestions<V: View<State>>(mut self, view: V) -> Self {
217 self.suggestions = Some(any(view));
218 self
219 }
220
221 pub fn on_submit<F: Fn(&mut State, String) + 'static>(mut self, on_submit: F) -> Self {
223 self.on_submit = Some(Rc::new(on_submit));
224 self
225 }
226
227 pub fn on_back<F: Fn(&mut State) + 'static>(mut self, on_back: F) -> Self {
233 self.on_back = Some(Rc::new(on_back));
234 self
235 }
236
237 pub fn dismissable(mut self, dismissable: bool) -> Self {
242 self.dismissable = dismissable;
243 self
244 }
245
246 pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
257 self.on_dismiss = Some(Rc::new(on_dismiss));
258 self
259 }
260
261 pub fn full_screen(self) -> SearchViewView<State> {
265 let label = self.hint.clone();
266 let dismissable = self.dismissable;
267 let on_dismiss = self.on_dismiss.clone();
268 let content = self.into_content(SearchViewMode::FullScreen, None);
269 let mut host = overlay_modal(content, full_screen_config()).dismissable(dismissable);
270 if let Some(label) = label {
271 host = host.label(label);
272 }
273 if let Some(on_dismiss) = on_dismiss {
274 host = host.on_modal_dismiss(on_dismiss);
275 }
276 SearchViewView(host)
277 }
278
279 pub fn docked(mut self, anchor: &OverlayAnchor) -> DockedSearchViewView<State> {
299 let placement = docked_placement(anchor);
300 let on_dismiss = self.on_dismiss.clone();
301 if self.on_back.is_none() {
304 self.on_back = on_dismiss.clone();
305 }
306 let content = self.into_content(SearchViewMode::Docked, Some(anchor.clone()));
307 let mut host = anchored_overlay(content)
308 .anchor(anchor)
309 .placement(placement);
310 if let Some(on_dismiss) = on_dismiss {
311 host = host.on_dismiss(move |state: &mut State| on_dismiss(state));
312 }
313 DockedSearchViewView(host)
314 }
315
316 fn into_content(
318 self,
319 mode: SearchViewMode,
320 anchor: Option<OverlayAnchor>,
321 ) -> SearchViewContentView<State> {
322 SearchViewContentView {
323 mode,
324 anchor,
325 query: self.query,
326 on_query_changed: self.on_query_changed,
327 hint: self.hint,
328 leading: self.leading,
329 trailing: self.trailing,
330 suggestions: self.suggestions,
331 on_submit: self.on_submit,
332 on_back: self.on_back,
333 }
334 }
335}
336
337struct SearchViewContentView<State: 'static> {
339 mode: SearchViewMode,
340 anchor: Option<OverlayAnchor>,
342 query: String,
343 on_query_changed: OnText<State>,
344 hint: Option<String>,
345 leading: Option<AnyView<State>>,
346 trailing: Vec<AnyView<State>>,
347 suggestions: Option<AnyView<State>>,
348 on_submit: Option<OnText<State>>,
349 on_back: Option<OnState<State>>,
350}
351
352impl<State: 'static> SearchViewContentView<State> {
353 fn field_view(&self) -> AnyView<State> {
357 let on_change = self.on_query_changed.clone();
358 let (hpad, _) = self.mode.header_padding();
359 let mut field = text_input(self.query.clone(), move |state: &mut State, text| {
360 on_change(state, text)
361 })
362 .border_width(0.0)
363 .focus_ring_width(0.0)
364 .corner_radius(0.0)
365 .padding(hpad + ACTION_SLOT, 0.0);
366 if let Some(hint) = &self.hint {
367 field = field.placeholder(hint.clone());
368 }
369 if let Some(on_submit) = self.on_submit.clone() {
370 field = field.on_submit(move |state: &mut State, text| on_submit(state, text));
371 }
372 any(field)
373 }
374
375 fn default_leading(&self) -> AnyView<State> {
377 let on_back = self.on_back.clone();
378 any::<State, _>(
379 icon_button(
380 any::<State, _>(icon(crate::icons::ARROW_BACK)),
381 move |state: &mut State| {
382 if let Some(on_back) = &on_back {
383 on_back(state);
384 }
385 },
386 )
387 .semantic_label(BACK_LABEL),
388 )
389 }
390
391 fn leading_ref<'a>(&'a self, default: &'a AnyView<State>) -> &'a AnyView<State> {
393 self.leading.as_ref().unwrap_or(default)
394 }
395
396 fn generated_trailing(&self) -> Vec<AnyView<State>> {
399 if !self.trailing.is_empty() || self.query.is_empty() {
400 return Vec::new();
401 }
402 let on_query_changed = self.on_query_changed.clone();
403 vec![any::<State, _>(
404 icon_button(any::<State, _>(icon(crate::icons::CLOSE)), move |state| {
405 on_query_changed(state, String::new());
406 })
407 .semantic_label(CLEAR_LABEL),
408 )]
409 }
410
411 fn trailing_refs<'a>(&'a self, generated: &'a [AnyView<State>]) -> Vec<&'a AnyView<State>> {
413 if self.trailing.is_empty() {
414 generated.iter().collect()
415 } else {
416 self.trailing.iter().collect()
417 }
418 }
419
420 fn body_view(&self) -> AnyView<State> {
424 any::<State, _>(frust::text(String::new()))
425 }
426
427 fn divider_view(&self) -> AnyView<State> {
429 any::<State, _>(crate::divider::divider())
430 }
431}
432
433impl<State: 'static> View<State> for SearchViewContentView<State> {
434 type Element = SearchViewContentWidget;
435
436 fn build(&self, ctx: &mut BuildCtx<'_>) -> SearchViewContentWidget {
437 let default_leading = self.default_leading();
438 let generated = self.generated_trailing();
439 let body = self.body_view();
440 SearchViewContentWidget {
441 mode: self.mode,
442 anchor: self.anchor.clone(),
443 label: self.hint.clone(),
444 field: build_child(&self.field_view(), ctx),
445 leading: build_child(self.leading_ref(&default_leading), ctx),
446 trailing: self
447 .trailing_refs(&generated)
448 .into_iter()
449 .map(|v| build_child(v, ctx))
450 .collect(),
451 divider: build_child(&self.divider_view(), ctx),
452 body: build_child(self.suggestions.as_ref().unwrap_or(&body), ctx),
453 }
454 }
455
456 fn rebuild(
457 &self,
458 prev: &Self,
459 element: &mut SearchViewContentWidget,
460 ctx: &mut BuildCtx<'_>,
461 ) -> ChangeFlags {
462 element.mode = self.mode;
463 element.anchor = self.anchor.clone();
464 element.label = self.hint.clone();
465
466 let mut flags = rebuild_child(
467 &prev.field_view(),
468 &self.field_view(),
469 &mut element.field,
470 ctx,
471 );
472
473 let prev_default_leading = prev.default_leading();
474 let next_default_leading = self.default_leading();
475 flags |= rebuild_child(
476 prev.leading_ref(&prev_default_leading),
477 self.leading_ref(&next_default_leading),
478 &mut element.leading,
479 ctx,
480 );
481
482 let prev_generated = prev.generated_trailing();
483 let next_generated = self.generated_trailing();
484 let prev_refs = prev.trailing_refs(&prev_generated);
485 let next_refs = self.trailing_refs(&next_generated);
486 flags |= rebuild_children(
487 &prev_refs,
488 &next_refs,
489 &mut element.trailing,
490 ctx,
491 |view: &&AnyView<State>| *view,
492 |_| None,
493 );
494
495 flags |= rebuild_child(
496 &prev.divider_view(),
497 &self.divider_view(),
498 &mut element.divider,
499 ctx,
500 );
501
502 let prev_body = prev.body_view();
503 let next_body = self.body_view();
504 flags |= rebuild_child(
505 prev.suggestions.as_ref().unwrap_or(&prev_body),
506 self.suggestions.as_ref().unwrap_or(&next_body),
507 &mut element.body,
508 ctx,
509 );
510 flags
511 }
512
513 fn teardown(&self, element: &mut SearchViewContentWidget, ctx: &mut BuildCtx<'_>) {
514 teardown_child(&self.field_view(), &mut element.field, ctx);
515 let default_leading = self.default_leading();
516 teardown_child(
517 self.leading_ref(&default_leading),
518 &mut element.leading,
519 ctx,
520 );
521 let generated = self.generated_trailing();
522 for (view, pod) in self
523 .trailing_refs(&generated)
524 .into_iter()
525 .zip(element.trailing.iter_mut())
526 {
527 teardown_child(view, pod, ctx);
528 }
529 teardown_child(&self.divider_view(), &mut element.divider, ctx);
530 let body = self.body_view();
531 teardown_child(
532 self.suggestions.as_ref().unwrap_or(&body),
533 &mut element.body,
534 ctx,
535 );
536 }
537}
538
539pub struct SearchViewContentWidget {
543 mode: SearchViewMode,
544 anchor: Option<OverlayAnchor>,
545 label: Option<String>,
548 field: ChildPod,
549 leading: ChildPod,
550 trailing: Vec<ChildPod>,
551 divider: ChildPod,
552 body: ChildPod,
553}
554
555impl SearchViewContentWidget {
556 pub fn mode(&self) -> SearchViewMode {
558 self.mode
559 }
560
561 pub fn header_height(&self) -> f64 {
563 self.mode.header_height()
564 }
565
566 fn panel_size(&self, area: Size) -> Size {
571 if self.mode.is_full_screen() {
572 return area;
573 }
574 let anchor_width = self.anchor.as_ref().map_or(0.0, |a| a.rect().width());
575 let width = anchor_width.max(SEARCH_VIEW_MIN_WIDTH).min(area.width);
579 let height = (area.height * SEARCH_VIEW_HEIGHT_FRACTION)
580 .max(SEARCH_VIEW_MIN_HEIGHT)
581 .min(area.height);
582 Size::new(width.max(0.0), height.max(0.0))
583 }
584}
585
586impl Widget for SearchViewContentWidget {
587 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
588 let area = Size::new(
589 finite_or_zero(bc.max().width),
590 finite_or_zero(bc.max().height),
591 );
592 let panel = self.panel_size(area);
593 let header_h = self.mode.header_height();
594 let (hpad, vpad) = self.mode.header_padding();
595
596 self.field.layout_child(
599 ctx,
600 &BoxConstraints::tight(Size::new(panel.width, header_h)),
601 );
602 self.field.set_origin(Point::ZERO);
603
604 let slot_bc =
605 BoxConstraints::loose(Size::new(ACTION_SLOT, (header_h - 2.0 * vpad).max(0.0)));
606 let leading_size = self.leading.layout_child(ctx, &slot_bc);
607 self.leading.set_origin(Point::new(
608 hpad + (ACTION_SLOT - leading_size.width) / 2.0,
609 (header_h - leading_size.height) / 2.0,
610 ));
611
612 let mut trailing_x = panel.width - hpad;
613 for pod in self.trailing.iter_mut().rev() {
614 let size = pod.layout_child(ctx, &slot_bc);
615 trailing_x -= ACTION_SLOT;
616 pod.set_origin(Point::new(
617 trailing_x + (ACTION_SLOT - size.width) / 2.0,
618 (header_h - size.height) / 2.0,
619 ));
620 }
621
622 let divider_size = self
623 .divider
624 .layout_child(ctx, &BoxConstraints::loose(panel));
625 self.divider.set_origin(Point::new(0.0, header_h));
626
627 let body_y = header_h + divider_size.height;
628 let body_h = (panel.height - body_y).max(0.0);
629 self.body
630 .layout_child(ctx, &BoxConstraints::tight(Size::new(panel.width, body_h)));
631 self.body.set_origin(Point::new(0.0, body_y));
632
633 panel
634 }
635
636 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
637 let theme = Theme::from_paint_ctx(ctx);
638 let origin = ctx.origin();
639 let size = ctx.size();
640
641 let owns_chrome = !self.mode.is_full_screen();
645 if owns_chrome {
646 let r = radius(theme);
647 let radii = CornerRadii::new(r, r, r, r);
648 if let Some((blur, y_offset, color)) = shadow(theme, OverlayElevation::Level3) {
649 scene.draw_shadow(
650 Point::new(origin.x, origin.y + y_offset),
651 size,
652 r,
653 blur,
654 color,
655 );
656 }
657 scene.fill_rounded_rect_radii(
658 origin,
659 size,
660 radii,
661 container(theme, OverlayContainer::High),
662 );
663 scene.push_clip_rounded_radii(origin, size, radii);
664 }
665
666 self.field.paint_child(ctx, scene);
667 self.leading.paint_child(ctx, scene);
668 for pod in &mut self.trailing {
669 pod.paint_child(ctx, scene);
670 }
671 self.divider.paint_child(ctx, scene);
672 self.body.paint_child(ctx, scene);
673
674 if owns_chrome {
675 scene.pop_clip();
676 }
677 }
678
679 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
680 if event.is_broadcast() {
681 self.field.event_child(ctx, event);
682 self.leading.event_child(ctx, event);
683 for pod in &mut self.trailing {
684 pod.event_child(ctx, event);
685 }
686 self.body.event_child(ctx, event);
687 return EventResult::Ignored;
688 }
689 if route_event_single(&mut self.leading, ctx, event) == EventResult::Handled {
693 return EventResult::Handled;
694 }
695 if route_event(&mut self.trailing, ctx, event) == EventResult::Handled {
696 return EventResult::Handled;
697 }
698 if route_event_single(&mut self.field, ctx, event) == EventResult::Handled {
699 return EventResult::Handled;
700 }
701 route_event_single(&mut self.body, ctx, event)
702 }
703
704 fn semantics(&self, ctx: &mut SemanticsCtx) {
705 let children = |ctx: &mut SemanticsCtx| {
706 self.leading.semantics_child(ctx);
707 self.field.semantics_child(ctx);
708 for pod in &self.trailing {
709 pod.semantics_child(ctx);
710 }
711 self.divider.semantics_child(ctx);
712 self.body.semantics_child(ctx);
713 };
714 if self.mode.is_full_screen() {
715 children(ctx);
718 return;
719 }
720 let label = self.label.clone();
725 ctx.push_container(
726 Role::Dialog,
727 |node| {
728 if let Some(label) = &label {
729 node.set_label(label.as_str());
730 }
731 },
732 children,
733 );
734 }
735
736 visit_children!(field, leading, trailing, divider, body);
737}
738
739pub struct SearchViewView<State: 'static>(OverlayModalView<State>);
743
744impl<State: 'static> View<State> for SearchViewView<State> {
745 type Element = OverlayModalWidget;
746
747 fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
748 View::build(&self.0, ctx)
749 }
750
751 fn rebuild(
752 &self,
753 prev: &Self,
754 element: &mut OverlayModalWidget,
755 ctx: &mut BuildCtx<'_>,
756 ) -> ChangeFlags {
757 View::rebuild(&self.0, &prev.0, element, ctx)
758 }
759
760 fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
761 View::teardown(&self.0, element, ctx);
762 }
763}
764
765impl<State: 'static> OverlayModalContent<State> for SearchViewView<State> {
766 fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
767 self.0 = self.0.on_modal_dismiss(on_dismiss);
768 self
769 }
770
771 fn modal_dismissable(&self) -> bool {
772 self.0.modal_dismissable()
773 }
774}
775
776pub struct DockedSearchViewView<State: 'static>(AnchoredOverlayView<State>);
780
781impl<State: 'static> DockedSearchViewView<State> {
782 pub fn open(mut self, open: bool) -> Self {
786 self.0 = self.0.open(open);
787 self
788 }
789
790 pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
797 self.0 = self.0.on_dismiss(on_dismiss);
798 self
799 }
800}
801
802impl<State: 'static> View<State> for DockedSearchViewView<State> {
803 type Element = AnchoredOverlayWidget;
804
805 fn build(&self, ctx: &mut BuildCtx<'_>) -> AnchoredOverlayWidget {
806 View::build(&self.0, ctx)
807 }
808
809 fn rebuild(
810 &self,
811 prev: &Self,
812 element: &mut AnchoredOverlayWidget,
813 ctx: &mut BuildCtx<'_>,
814 ) -> ChangeFlags {
815 View::rebuild(&self.0, &prev.0, element, ctx)
816 }
817
818 fn teardown(&self, element: &mut AnchoredOverlayWidget, ctx: &mut BuildCtx<'_>) {
819 View::teardown(&self.0, element, ctx);
820 }
821}
822
823pub fn show_search_view<State, B, R>(
833 controller: &NavigatorController<State>,
834 build: B,
835 on_result: R,
836) where
837 State: 'static,
838 B: Fn() -> SearchView<State> + 'static,
839 R: Fn(&mut State, PopResult) + 'static,
840{
841 let back_controller = controller.clone();
842 show_overlay_modal(
843 controller,
844 move || {
845 let ctrl = back_controller.clone();
846 build()
847 .on_back(move |_state: &mut State| ctrl.pop())
848 .full_screen()
849 },
850 on_result,
851 )
852}
853
854#[cfg(test)]
855mod tests {
856 use super::*;
857 use frust::authoring::text::TextContext;
858 use frust::authoring::{
859 Key, KeyEvent, Modifiers, PointerButton, PointerEvent, PointerPhase, any as core_any,
860 };
861 use frust::{Color, FrameTime, NavigatorView};
862 use frust_core::RenderRoot;
863 use frust_widgets::navigator;
864 use kurbo::Rect;
865 use std::any::Any;
866
867 const COMPACT: Size = Size::new(392.0, 800.0);
869 const EXPANDED: Size = Size::new(1280.0, 800.0);
870
871 #[derive(Default)]
872 struct App {
873 query: String,
874 open: bool,
875 }
876
877 #[derive(Default)]
878 struct Recorder {
879 rects: Vec<(Point, Size, Color)>,
880 panels: Vec<(Point, Size, CornerRadii, Color)>,
881 clips: Vec<(Point, Size)>,
882 }
883
884 impl PaintScene for Recorder {
885 fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
886 self.rects.push((origin, size, color));
887 }
888 fn draw_text(&mut self, _o: Point, _t: &str) {}
889 fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
890 self.panels.push((
891 origin,
892 size,
893 CornerRadii::new(radius, radius, radius, radius),
894 color,
895 ));
896 }
897 fn fill_rounded_rect_radii(
898 &mut self,
899 origin: Point,
900 size: Size,
901 radii: CornerRadii,
902 color: Color,
903 ) {
904 self.panels.push((origin, size, radii, color));
905 }
906 fn push_clip(&mut self, origin: Point, size: Size) {
907 self.clips.push((origin, size));
908 }
909 fn push_clip_rounded_radii(&mut self, origin: Point, size: Size, _radii: CornerRadii) {
910 self.clips.push((origin, size));
911 }
912 fn pop_clip(&mut self) {}
913 }
914
915 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
916 InputEvent::Pointer(PointerEvent {
917 phase,
918 position: Point::new(x, y),
919 button: PointerButton::Primary,
920 })
921 }
922
923 fn ft(ms: u64) -> FrameTime {
924 FrameTime::from_nanos(ms * 1_000_000)
925 }
926
927 fn content<State: 'static>(
928 mode: SearchViewMode,
929 anchor: Option<OverlayAnchor>,
930 query: &str,
931 ) -> SearchViewContentView<State> {
932 search_view(query.to_string(), |_: &mut State, _| {})
933 .hint("Search")
934 .into_content(mode, anchor)
935 }
936
937 fn build_content<State: 'static>(
938 view: &SearchViewContentView<State>,
939 ) -> SearchViewContentWidget {
940 let mut counter = 0u64;
941 View::<State>::build(view, &mut BuildCtx::new(&mut counter))
942 }
943
944 fn layout(w: &mut SearchViewContentWidget, area: Size) -> Size {
945 let mut tcx = TextContext::new();
946 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
947 w.layout(&mut lctx, &BoxConstraints::loose(area))
948 }
949
950 fn dispatch(
951 w: &mut SearchViewContentWidget,
952 state: &mut App,
953 area: Size,
954 event: &InputEvent,
955 ) -> EventResult {
956 let state_any: &mut dyn Any = state;
957 let mut ctx = EventCtx::new(state_any, Point::ZERO, area);
958 w.event(&mut ctx, event)
959 }
960
961 fn centre(pod: &ChildPod) -> Point {
963 let o = pod.origin();
964 let s = pod.size();
965 Point::new(o.x + s.width / 2.0, o.y + s.height / 2.0)
966 }
967
968 #[test]
971 fn the_full_screen_content_fills_a_compact_area() {
972 let view: SearchViewContentView<()> = content(SearchViewMode::FullScreen, None, "");
973 let mut w = build_content(&view);
974 let size = layout(&mut w, COMPACT);
975 assert_eq!(
976 size, COMPACT,
977 "the full-screen content takes the whole area"
978 );
979 assert_eq!(w.header_height(), super::super::FULL_SCREEN_HEADER_HEIGHT);
980 assert_eq!(w.body.origin().x, 0.0);
981 assert!(
982 w.body.size().height > 0.0,
983 "the suggestion slot fills what the header leaves"
984 );
985 }
986
987 #[test]
988 fn the_docked_content_takes_the_anchor_width_and_two_thirds_of_the_height() {
989 let anchor = OverlayAnchor::new();
990 anchor.set(Rect::new(24.0, 24.0, 744.0, 80.0));
992 let view: SearchViewContentView<()> =
993 content(SearchViewMode::Docked, Some(anchor.clone()), "");
994 let mut w = build_content(&view);
995 let size = layout(&mut w, EXPANDED);
996
997 assert_eq!(
998 size.width, 720.0,
999 "the reference clamps to the anchor width"
1000 );
1001 assert_eq!(
1002 size.height,
1003 EXPANDED.height * SEARCH_VIEW_HEIGHT_FRACTION,
1004 "…and to two thirds of the window height"
1005 );
1006 assert_eq!(w.header_height(), super::super::SEARCH_BAR_MIN_HEIGHT);
1007 }
1008
1009 #[test]
1010 fn a_narrow_anchor_still_gets_the_reference_min_width() {
1011 let anchor = OverlayAnchor::new();
1012 anchor.set(Rect::new(0.0, 0.0, 120.0, 56.0));
1013 let view: SearchViewContentView<()> = content(SearchViewMode::Docked, Some(anchor), "");
1014 let mut w = build_content(&view);
1015 let size = layout(&mut w, EXPANDED);
1016 assert_eq!(size.width, SEARCH_VIEW_MIN_WIDTH);
1017 }
1018
1019 #[test]
1020 fn a_short_area_clamps_the_panel_to_it_rather_than_to_the_min_height() {
1021 let anchor = OverlayAnchor::new();
1022 anchor.set(Rect::new(0.0, 0.0, 400.0, 56.0));
1023 let view: SearchViewContentView<()> = content(SearchViewMode::Docked, Some(anchor), "");
1024 let mut w = build_content(&view);
1025 let size = layout(&mut w, Size::new(900.0, 200.0));
1028 assert_eq!(size.height, 200.0);
1029 }
1030
1031 #[test]
1032 fn only_the_docked_panel_paints_its_own_chrome() {
1033 let theme = crate::baseline();
1034 let anchor = OverlayAnchor::new();
1035 anchor.set(Rect::new(0.0, 0.0, 400.0, 56.0));
1036
1037 let docked: SearchViewContentView<()> = content(SearchViewMode::Docked, Some(anchor), "");
1038 let mut w = build_content(&docked);
1039 let size = layout(&mut w, EXPANDED);
1040 let mut rec = Recorder::default();
1041 let mut pctx = PaintCtx::for_test(Point::ZERO, size, ft(0)).with_theme(&theme);
1042 w.paint(&mut pctx, &mut rec);
1043 let panel = rec
1044 .panels
1045 .first()
1046 .expect("the docked panel paints its own container");
1047 assert_eq!(panel.3, theme.scheme().surface_container_high);
1048 assert_eq!(panel.2.top_left, theme.shape.extra_large);
1049 assert!(!rec.clips.is_empty(), "…and clips its content to it");
1050
1051 let full: SearchViewContentView<()> = content(SearchViewMode::FullScreen, None, "");
1052 let mut w = build_content(&full);
1053 let size = layout(&mut w, COMPACT);
1054 let mut rec = Recorder::default();
1055 let mut pctx = PaintCtx::for_test(Point::ZERO, size, ft(0)).with_theme(&theme);
1056 w.paint(&mut pctx, &mut rec);
1057 assert!(
1058 rec.clips.is_empty(),
1059 "the modal host already clipped the full-screen panel"
1060 );
1061 }
1062
1063 #[test]
1064 fn a_view_with_no_suggestions_paints_nothing_in_its_body() {
1065 let view: SearchViewContentView<()> = content(SearchViewMode::FullScreen, None, "");
1066 let mut w = build_content(&view);
1067 let size = layout(&mut w, COMPACT);
1068 let mut rec = Recorder::default();
1069 let mut pctx = PaintCtx::for_test(Point::ZERO, size, ft(0));
1070 w.paint(&mut pctx, &mut rec);
1071
1072 let header_h = w.header_height();
1073 for (origin, painted, _) in &rec.rects {
1074 assert!(
1075 origin.y + painted.height <= header_h + 2.0,
1076 "only the header band and the hairline divider fill a rect; \
1077 found one at {origin:?} sized {painted:?}"
1078 );
1079 }
1080 }
1081
1082 #[test]
1085 fn the_header_puts_the_back_slot_over_the_band_spanning_field() {
1086 let view: SearchViewContentView<()> = content(SearchViewMode::FullScreen, None, "");
1087 let mut w = build_content(&view);
1088 layout(&mut w, COMPACT);
1089
1090 assert_eq!(w.field.origin(), Point::ZERO);
1091 assert_eq!(
1092 w.field.size(),
1093 Size::new(COMPACT.width, super::super::FULL_SCREEN_HEADER_HEIGHT),
1094 "the field spans the whole header band"
1095 );
1096 let (hpad, _) = SearchViewMode::FullScreen.header_padding();
1097 assert!(
1098 w.leading.origin().x >= hpad,
1099 "the back affordance sits inside the header inset"
1100 );
1101 assert!(
1102 w.leading.origin().x < hpad + ACTION_SLOT,
1103 "…centred in its own 48dp slot"
1104 );
1105 }
1106
1107 #[test]
1108 fn a_non_empty_query_grows_the_clear_affordance() {
1109 let empty: SearchViewContentView<()> = content(SearchViewMode::FullScreen, None, "");
1110 assert!(build_content(&empty).trailing.is_empty());
1111
1112 let filled: SearchViewContentView<()> = content(SearchViewMode::FullScreen, None, "abc");
1113 let mut w = build_content(&filled);
1114 assert_eq!(w.trailing.len(), 1);
1115 layout(&mut w, COMPACT);
1116 assert!(
1117 w.trailing[0].origin().x > COMPACT.width - 2.0 * ACTION_SLOT,
1118 "the clear affordance packs against the end edge"
1119 );
1120 }
1121
1122 #[test]
1125 fn typing_reports_through_on_query_changed_and_rebuilds_the_suggestions() {
1126 fn logic(state: &mut App) -> SearchViewContentView<App> {
1129 let rows = state.query.chars().count().max(1) as f64;
1130 search_view(state.query.clone(), |s: &mut App, q| s.query = q)
1131 .hint("Search")
1132 .suggestions(leaf_any_app(100.0, 24.0 * rows))
1133 .into_content(SearchViewMode::FullScreen, None)
1134 }
1135 let mut root: RenderRoot<App, SearchViewContentView<App>> = RenderRoot::new();
1136 let mut state = App::default();
1137 root.rebuild(&mut logic, &mut state);
1138 let mut tcx = TextContext::new();
1139 root.layout_with_text(COMPACT, &mut tcx as &mut dyn Any);
1140
1141 root.event(&mut state, &ev(PointerPhase::Down, 200.0, 36.0));
1144 root.event(&mut state, &ev(PointerPhase::Up, 200.0, 36.0));
1145 root.event(&mut state, &key('h'));
1146 root.event(&mut state, &key('i'));
1147 assert_eq!(
1148 state.query, "hi",
1149 "the header field reports through on_query_changed"
1150 );
1151
1152 root.rebuild(&mut logic, &mut state);
1155 root.layout_with_text(COMPACT, &mut tcx as &mut dyn Any);
1156 }
1157
1158 fn key(ch: char) -> InputEvent {
1160 InputEvent::Key(KeyEvent {
1161 key: Key::Character(ch.to_string()),
1162 modifiers: Modifiers::default(),
1163 repeat: false,
1164 })
1165 }
1166
1167 #[derive(Default)]
1170 struct NavState {
1171 query: String,
1172 results: Vec<Option<i32>>,
1173 }
1174
1175 #[test]
1176 fn show_search_view_stages_a_back_request_and_pops() {
1177 let controller: NavigatorController<NavState> = NavigatorController::new();
1178 let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
1179 let mut app = {
1180 let ctrl = controller.clone();
1181 move |_: &mut NavState| {
1182 navigator(&ctrl, || {
1183 core_any::<NavState, _>(BgPage {
1184 size: Size::new(COMPACT.width, COMPACT.height),
1185 })
1186 })
1187 }
1188 };
1189 let mut state = NavState::default();
1190 root.rebuild(&mut app, &mut state);
1191 let mut tcx = TextContext::new();
1192 root.layout_with_text(COMPACT, &mut tcx as &mut dyn Any);
1193
1194 show_search_view(
1195 &controller,
1196 || search_view(String::new(), |s: &mut NavState, q| s.query = q).hint("Search"),
1197 |state: &mut NavState, result: PopResult| {
1198 state.results.push(result.take::<i32>());
1199 },
1200 );
1201 root.rebuild(&mut app, &mut state);
1202 root.layout_with_text(COMPACT, &mut tcx as &mut dyn Any);
1203 for t in [0u64, 100, 250] {
1204 root.paint(&mut Recorder::default(), ft(t));
1205 }
1206
1207 assert!(
1208 controller.back_interest(),
1209 "a dismissable search view claims back interest"
1210 );
1211 controller.request_back();
1212 root.rebuild(&mut app, &mut state);
1213 assert_eq!(
1214 controller.depth(),
1215 2,
1216 "DismissAnimated: the page is still up while the exit ramps"
1217 );
1218
1219 let mut frames = 0u64;
1220 loop {
1221 root.rebuild(&mut app, &mut state);
1222 root.layout_with_text(COMPACT, &mut tcx as &mut dyn Any);
1223 let outcome = root.paint(&mut Recorder::default(), ft(300 + frames * 100));
1224 frames += 1;
1225 assert!(frames < 30, "the staged exit settles in bounded frames");
1226 if !outcome.needs_frame {
1227 break;
1228 }
1229 }
1230 root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
1231 assert_eq!(state.results, vec![None], "the back press dismissed it");
1232 assert_eq!(controller.depth(), 1);
1233 }
1234
1235 #[test]
1236 fn the_full_screen_host_panel_covers_the_whole_area_once_settled() {
1237 let view: SearchViewView<App> = search_view(String::new(), |s: &mut App, q| s.query = q)
1238 .hint("Search")
1239 .suggestions(leaf_any_app(100.0, 100.0))
1240 .full_screen();
1241 let mut counter = 0u64;
1242 let mut w = View::<App>::build(&view, &mut BuildCtx::new(&mut counter));
1243 let mut tcx = TextContext::new();
1244 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1245 let size = w.layout(&mut lctx, &BoxConstraints::tight(COMPACT));
1246 assert_eq!(size, COMPACT, "the host fills its own area");
1247 assert_eq!(
1248 w.progress(),
1249 0.0,
1250 "the slide entrance starts off its own (top) edge"
1251 );
1252
1253 for t in [0u64, 800] {
1256 let mut pctx = PaintCtx::for_test(Point::ZERO, COMPACT, ft(t));
1257 w.paint(&mut pctx, &mut Recorder::default());
1258 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1259 w.layout(&mut lctx, &BoxConstraints::tight(COMPACT));
1260 }
1261
1262 assert_eq!(w.progress(), 1.0);
1263 assert_eq!(
1264 w.panel_rect(),
1265 Rect::from_origin_size(Point::ZERO, COMPACT),
1266 "the settled full-screen panel is the whole area — no scrim shows"
1267 );
1268 assert_eq!(
1269 full_screen_config().corners,
1270 OverlayCorners::None,
1271 "a panel meeting all four screen edges rounds nothing"
1272 );
1273 }
1274
1275 #[test]
1276 fn a_non_dismissable_full_screen_view_refuses_the_back_press() {
1277 let view: SearchViewView<()> = search_view(String::new(), |_: &mut (), _| {})
1278 .dismissable(false)
1279 .full_screen();
1280 assert!(!OverlayModalContent::modal_dismissable(&view));
1281 }
1282
1283 #[test]
1286 fn the_docked_panel_is_placed_over_the_anchors_own_rect() {
1287 let anchor = OverlayAnchor::new();
1288 let bar = Rect::new(24.0, 24.0, 744.0, 80.0);
1289 anchor.set(bar);
1290
1291 let placement = docked_placement(&anchor);
1292 assert_eq!(placement.side, OverlaySide::Bottom);
1293 assert_eq!(placement.align, OverlayAlign::Start);
1294 assert_eq!(
1295 placement.offset,
1296 -bar.height(),
1297 "offset back up by the anchor's height so the panel covers it"
1298 );
1299 assert!(!placement.flip, "a covering panel never flips sides");
1300
1301 let placed = crate::overlay::place_anchored(
1302 bar,
1303 Size::new(720.0, 533.0),
1304 Rect::from_origin_size(Point::ZERO, EXPANDED),
1305 placement,
1306 );
1307 assert_eq!(
1308 placed.origin(),
1309 bar.origin(),
1310 "the panel's top-left is the anchor's"
1311 );
1312 }
1313
1314 #[test]
1315 fn a_docked_view_lays_out_and_paints_through_its_anchored_host() {
1316 let anchor = OverlayAnchor::new();
1317 anchor.set(Rect::new(24.0, 24.0, 744.0, 80.0));
1318 let view: DockedSearchViewView<App> =
1319 search_view(String::new(), |s: &mut App, q| s.query = q)
1320 .hint("Search")
1321 .suggestions(leaf_any_app(100.0, 100.0))
1322 .docked(&anchor)
1323 .open(true)
1324 .on_dismiss(|s: &mut App| s.open = false);
1325
1326 let mut counter = 0u64;
1327 let mut w = View::<App>::build(&view, &mut BuildCtx::new(&mut counter));
1328 let mut tcx = TextContext::new();
1329 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1330 let size = w.layout(&mut lctx, &BoxConstraints::tight(EXPANDED));
1331 assert_eq!(size, EXPANDED, "the host fills its area");
1332 assert_eq!(
1333 w.content_rect().origin(),
1334 Point::new(24.0, 24.0),
1335 "the placed panel covers the anchor"
1336 );
1337 }
1338
1339 #[test]
1340 fn a_docked_views_on_dismiss_also_serves_its_header_back_affordance() {
1341 let anchor = OverlayAnchor::new();
1342 let bar = Rect::new(24.0, 24.0, 744.0, 80.0);
1343 anchor.set(bar);
1344 let view: DockedSearchViewView<App> =
1345 search_view(String::new(), |s: &mut App, q| s.query = q)
1346 .suggestions(leaf_any_app(100.0, 100.0))
1347 .on_dismiss(|s: &mut App| s.open = false)
1348 .docked(&anchor);
1349
1350 let mut counter = 0u64;
1351 let mut w = View::<App>::build(&view, &mut BuildCtx::new(&mut counter));
1352 let mut tcx = TextContext::new();
1353 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1354 w.layout(&mut lctx, &BoxConstraints::tight(EXPANDED));
1355 let mut pctx = PaintCtx::for_test(Point::ZERO, EXPANDED, ft(0));
1356 w.paint(&mut pctx, &mut Recorder::default());
1357
1358 let (hpad, _) = SearchViewMode::Docked.header_padding();
1361 let cx = bar.x0 + hpad + ACTION_SLOT / 2.0;
1362 let cy = bar.y0 + SearchViewMode::Docked.header_height() / 2.0;
1363
1364 let mut state = App {
1365 open: true,
1366 ..App::default()
1367 };
1368 for phase in [PointerPhase::Down, PointerPhase::Up] {
1369 let state_any: &mut dyn Any = &mut state;
1370 let mut ctx = EventCtx::new(state_any, Point::ZERO, EXPANDED);
1371 w.event(&mut ctx, &ev(phase, cx, cy));
1372 }
1373 assert!(
1374 !state.open,
1375 "one `on_dismiss` closure covers the light dismiss and the back \
1376 affordance both"
1377 );
1378 }
1379
1380 #[test]
1381 fn a_press_outside_the_docked_panel_dismisses_it() {
1382 let anchor = OverlayAnchor::new();
1383 anchor.set(Rect::new(24.0, 24.0, 744.0, 80.0));
1384 let view: DockedSearchViewView<App> =
1385 search_view(String::new(), |s: &mut App, q| s.query = q)
1386 .suggestions(leaf_any_app(100.0, 100.0))
1387 .on_dismiss(|s: &mut App| s.open = false)
1388 .docked(&anchor)
1389 .open(true);
1390
1391 let mut counter = 0u64;
1392 let mut w = View::<App>::build(&view, &mut BuildCtx::new(&mut counter));
1393 let mut tcx = TextContext::new();
1394 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1395 w.layout(&mut lctx, &BoxConstraints::tight(EXPANDED));
1396 let mut pctx = PaintCtx::for_test(Point::ZERO, EXPANDED, ft(0));
1397 w.paint(&mut pctx, &mut Recorder::default());
1398
1399 let mut state = App {
1400 open: true,
1401 ..App::default()
1402 };
1403 let state_any: &mut dyn Any = &mut state;
1404 let mut ctx = EventCtx::new(state_any, Point::ZERO, EXPANDED);
1405 w.event(&mut ctx, &ev(PointerPhase::Down, 8.0, 780.0));
1407 assert!(!state.open, "a light dismiss clears the app's own flag");
1408 }
1409
1410 #[test]
1411 fn the_header_back_affordance_fires_on_back() {
1412 let view: SearchViewContentView<App> = search_view(String::new(), |_: &mut App, _| {})
1413 .on_back(|s: &mut App| s.open = false)
1414 .into_content(SearchViewMode::FullScreen, None);
1415 let mut w = build_content(&view);
1416 layout(&mut w, COMPACT);
1417
1418 let mut state = App {
1419 open: true,
1420 ..App::default()
1421 };
1422 let c = centre(&w.leading);
1423 dispatch(
1424 &mut w,
1425 &mut state,
1426 COMPACT,
1427 &ev(PointerPhase::Down, c.x, c.y),
1428 );
1429 dispatch(&mut w, &mut state, COMPACT, &ev(PointerPhase::Up, c.x, c.y));
1430 assert!(!state.open, "the back affordance fired on_back");
1431 }
1432
1433 #[test]
1436 fn a_docked_view_carries_its_own_labelled_non_modal_dialog_node() {
1437 fn logic(_s: &mut ()) -> SearchViewContentView<()> {
1438 let anchor = OverlayAnchor::new();
1439 anchor.set(Rect::new(0.0, 0.0, 400.0, 56.0));
1440 search_view(String::new(), |_: &mut (), _| {})
1441 .hint("Search recipes")
1442 .into_content(SearchViewMode::Docked, Some(anchor))
1443 }
1444 let mut root: RenderRoot<(), SearchViewContentView<()>> = RenderRoot::new();
1445 let mut state = ();
1446 root.rebuild(&mut logic, &mut state);
1447 let mut tcx = TextContext::new();
1448 root.layout_with_text(EXPANDED, &mut tcx as &mut dyn Any);
1449
1450 let update = root.semantics();
1451 let (_, node) = update
1452 .nodes
1453 .iter()
1454 .find(|(_, n)| n.role() == Role::Dialog)
1455 .expect("the docked content contributes the container node itself");
1456 assert_eq!(node.label(), Some("Search recipes"));
1457 assert!(
1458 !node.is_modal(),
1459 "the page under a docked panel stays interactive"
1460 );
1461 }
1462
1463 #[test]
1464 fn a_full_screen_view_leaves_the_container_node_to_its_host() {
1465 fn logic(_s: &mut ()) -> SearchViewContentView<()> {
1466 search_view(String::new(), |_: &mut (), _| {})
1467 .hint("Search recipes")
1468 .into_content(SearchViewMode::FullScreen, None)
1469 }
1470 let mut root: RenderRoot<(), SearchViewContentView<()>> = RenderRoot::new();
1471 let mut state = ();
1472 root.rebuild(&mut logic, &mut state);
1473 let mut tcx = TextContext::new();
1474 root.layout_with_text(COMPACT, &mut tcx as &mut dyn Any);
1475
1476 let update = root.semantics();
1477 assert!(
1478 !update.nodes.iter().any(|(_, n)| n.role() == Role::Dialog),
1479 "a second Dialog node would nest inside the modal host's own"
1480 );
1481 }
1482
1483 fn leaf_any_app(w: f64, h: f64) -> AnyView<App> {
1486 core_any::<App, _>(FixedLeaf {
1487 size: Size::new(w, h),
1488 })
1489 }
1490 struct FixedLeaf {
1491 size: Size,
1492 }
1493 struct FixedLeafW {
1494 size: Size,
1495 }
1496 impl View<App> for FixedLeaf {
1497 type Element = FixedLeafW;
1498 fn build(&self, _c: &mut BuildCtx<'_>) -> FixedLeafW {
1499 FixedLeafW { size: self.size }
1500 }
1501 fn rebuild(&self, _p: &Self, _e: &mut FixedLeafW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1502 ChangeFlags::NONE
1503 }
1504 }
1505 impl Widget for FixedLeafW {
1506 fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1507 bc.constrain(self.size)
1508 }
1509 fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
1510 }
1511
1512 struct BgPage {
1513 size: Size,
1514 }
1515 struct BgPageW {
1516 size: Size,
1517 }
1518 impl View<NavState> for BgPage {
1519 type Element = BgPageW;
1520 fn build(&self, _c: &mut BuildCtx<'_>) -> BgPageW {
1521 BgPageW { size: self.size }
1522 }
1523 fn rebuild(&self, _p: &Self, _e: &mut BgPageW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1524 ChangeFlags::NONE
1525 }
1526 }
1527 impl Widget for BgPageW {
1528 fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1529 bc.constrain(self.size)
1530 }
1531 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1532 scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
1533 }
1534 }
1535}