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