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