Skip to main content

frust_material/search/
view.rs

1// Ported from `material_3_expressive` v1.0.8's search view
2// (`lib/components/search/m3e_search_anchor.dart`,
3// `components/m3e_search_view.dart`, `components/m3e_search_view_build.dart`,
4// `components/m3e_search_view_route.dart`,
5// `styles/m3e_search_view_theme.dart`) — MIT, © 2026 Paa Developments; see
6// [`super`]'s header for the family's full source list and porting decisions.
7
8//! The M3 Expressive **search view**: an edit-field header over a
9//! caller-supplied suggestion slot, presented full-screen on compact widths and
10//! as an anchored panel on expanded ones.
11//!
12//! # One content, two presentations
13//!
14//! [`search_view`] returns a *builder* ([`SearchView`]) that terminates in one
15//! of two hosts — see [`super`]'s presentation split for why the choice cannot
16//! live inside a widget's `layout`:
17//!
18//! - [`SearchView::full_screen`] wraps [`crate::overlay::modal`]'s host as a
19//!   full-area panel; [`show_search_view`] pushes it as a transparent navigator
20//!   page, so it carries the host's staged exit ramp, its
21//!   [`frust::BackPolicy::DismissAnimated`] back seam, and its `Escape`
22//!   handling wholesale.
23//! - [`SearchView::docked`] wraps [`crate::overlay::anchored_overlay`], placed
24//!   over the bar's own rect, light-dismissing on a press outside it. The app
25//!   keeps it mounted and toggles [`DockedSearchViewView::open`] — the
26//!   kept-mounted pattern that host's exit ramp needs.
27//!
28//! Both build the same [`SearchViewContentWidget`], which reads its mode for
29//! the header band and (docked only) paints the panel chrome the anchored host
30//! deliberately has none of.
31//!
32//! # Header
33//!
34//! `[back][field][clear]`, with the **field spanning the whole header band**:
35//! the framework's baseline editable paints its own opaque background, so
36//! letting it span the band makes that fill read as one uniform field band
37//! above the divider instead of a patch behind the glyphs (see [`super`]'s
38//! *Fidelity decisions*). The back and clear affordances are laid out — and
39//! painted — *over* it, each centred in its own [`super::ACTION_SLOT`] (48dp)
40//! touch target, and the field's own left text inset clears the back slot.
41//!
42//! The back affordance fires [`SearchView::on_back`] **directly**, the same
43//! shape [`crate::dialog`]'s header close affordance takes: the host's staged
44//! dismiss seam (the back-press generation cell) is installed by
45//! [`crate::overlay::show_overlay_modal`] and is not reachable from panel
46//! content, so a header dismiss pops immediately while the system-back, Escape
47//! and scrim paths still ramp out. [`show_search_view`] wires `on_back` to
48//! `controller.pop()` for you; a docked view wires it to the same flag its
49//! `on_dismiss` clears.
50//!
51//! # Query round trip
52//!
53//! The view is controlled end to end: it renders `query`, reports every edit
54//! through `on_query_changed`, and the app's next rebuild feeds both the new
55//! query *and* the suggestions it produced back down. Nothing is cached here —
56//! the reference's own debounced `suggestionsBuilder` (a `Timer(Duration.zero)`
57//! coalescing controller notifications, `m3e_search_view.dart`'s
58//! `_scheduleSuggestions`) has no analogue in a synchronous rebuild model,
59//! where the suggestion list is simply whatever the app hands down this frame.
60
61use 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
85/// The back affordance's accessible name — `M3ESearchConstants.backButtonTooltip`.
86const BACK_LABEL: &str = "Back";
87
88/// The clear affordance's accessible name —
89/// `M3ESearchConstants.clearButtonTooltip`.
90const CLEAR_LABEL: &str = "Clear";
91
92/// A view-held, typed text callback.
93type OnText<State> = Rc<dyn Fn(&mut State, String)>;
94
95/// A view-held, typed state callback.
96type OnState<State> = Rc<dyn Fn(&mut State)>;
97
98/// The M3 full-screen search-view chrome: a full-area `surfaceContainerHigh`
99/// panel with square corners and no shadow (a panel filling the window has no
100/// edge to lift off), sliding down from the top edge.
101///
102/// The scrim the host paints under it is fully occluded by the panel — the
103/// reference's route is `barrierColor: transparent` for the same reason.
104///
105/// The entrance is [`OverlayEntrance::Slide`], the pairing
106/// [`OverlayModalConfig::edge`] itself ships: an edge panel's origin is a
107/// function of the ramp's progress, and only `Slide` asks for the per-frame
108/// **relayout** that moves it — a fade-only entrance would composite the panel
109/// at its still-off-screen progress-`0` geometry. Its 500ms
110/// ([`crate::tokens::MaterialMotion::LONG_2`]) is the host's own timing, close
111/// to the reference's 600ms `M3ESearchConstants.openViewDuration`.
112fn 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
121/// The docked panel's placement: pinned over the anchor's own rect (the
122/// reference's `_rectTween.end` topLeft *is* `anchorRect.topLeft`), never
123/// flipped, shifted back on-screen when it overflows — which is the
124/// reference's own `_updateTweens` fit-up step.
125///
126/// Expressed as a `Bottom` placement offset back up by the anchor's height,
127/// since [`crate::overlay::place_anchored`] positions relative to an anchor
128/// *edge*. The anchor cell is written by the trigger's own paint, so the
129/// height read here is last frame's — which is exact by the time a view opens,
130/// since the bar it opens from has necessarily painted first.
131fn 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
139/// A search view's content, before a presentation is chosen. See the [module
140/// docs](self).
141pub 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
154/// Build a controlled M3 search view showing `query` and reporting every edit
155/// through `on_query_changed(state, new_query)`.
156///
157/// Terminate it with [`SearchView::full_screen`] or [`SearchView::docked`] —
158/// or hand it straight to [`show_search_view`], which applies the full-screen
159/// presentation and wires the back affordance for you:
160///
161/// ```ignore
162/// show_search_view(
163///     &state.nav,
164///     move || {
165///         search_view(query.clone(), |s: &mut App, q| s.query = q)
166///             .hint("Search recipes")
167///             .suggestions(suggestion_list())
168///     },
169///     |_state: &mut App, _result: PopResult| {},
170/// );
171/// ```
172pub 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    /// Set the field's placeholder (`M3ESearchAnchor.viewHintText`), which also
192    /// labels the view's accessibility node.
193    pub fn hint(mut self, hint: impl Into<String>) -> Self {
194        self.hint = Some(hint.into());
195        self
196    }
197
198    /// Replace the header's default back affordance
199    /// (`M3ESearchAnchor.viewLeading`). The slot is never empty.
200    pub fn leading(mut self, view: impl View<State>) -> Self {
201        self.leading = Some(AnyView::new(view));
202        self
203    }
204
205    /// Append a trailing header action. Setting any replaces the built-in clear
206    /// button — `M3ESearchAnchor.viewTrailing`'s own
207    /// `widget.viewTrailing ?? [clear]` precedence.
208    pub fn trailing(mut self, view: impl View<State>) -> Self {
209        self.trailing.push(AnyView::new(view));
210        self
211    }
212
213    /// Set the body slot: the suggestion list this view shows under its header
214    /// (`M3ESearchAnchor.suggestionsBuilder`'s output). A view with none shows
215    /// an empty body.
216    pub fn suggestions<V: View<State>>(mut self, view: V) -> Self {
217        self.suggestions = Some(any(view));
218        self
219    }
220
221    /// Set the Enter handler (`M3ESearchAnchor.viewOnSubmitted`).
222    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    /// Set the header back affordance's handler. [`show_search_view`] wires
228    /// this to `controller.pop()`; a docked view falls back to its own
229    /// [`on_dismiss`](Self::on_dismiss) when this is unset, since a docked
230    /// back press *is* a dismissal. See the [module docs](self)' header note
231    /// for why this dismissal is not staged.
232    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    /// Whether the user can dismiss a full-screen view at all — its scrim tap,
238    /// `Escape` and the Android back press (default `true`; see
239    /// [`mod@crate::overlay::modal`]). No effect on a docked view, whose
240    /// lifetime is the app's own `open` flag.
241    pub fn dismissable(mut self, dismissable: bool) -> Self {
242        self.dismissable = dismissable;
243        self
244    }
245
246    /// Set the dismiss callback.
247    ///
248    /// - **Docked**: the host's light dismiss (a press outside the panel, or
249    ///   `Escape` while it holds focus) *and*, unless
250    ///   [`on_back`](Self::on_back) overrides it, the header's back affordance
251    ///   — wire it to the flag [`DockedSearchViewView::open`] reads.
252    /// - **Full-screen**: the **unstaged** dismissal, delivered with
253    ///   `&mut State` during the event pass. [`show_search_view`] wires the
254    ///   staged (navigator-pop) path instead, so set this only for a
255    ///   `Stack`-mounted view with no navigator underneath it.
256    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    /// Finish as the **full-screen** presentation: [`crate::overlay::modal`]'s
262    /// host filling the whole area. [`show_search_view`] is the navigator push
263    /// most callers want.
264    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    /// Finish as the **docked** presentation: an
280    /// [`crate::overlay::anchored_overlay`] placed over `anchor`'s own rect
281    /// (the bar's — wrap it in [`crate::overlay::overlay_anchor`]).
282    ///
283    /// Keep the host mounted and toggle [`DockedSearchViewView::open`] so its
284    /// exit ramp can run (`docs/LIMITATIONS.md`'s
285    /// `shadcn-anchored-exit-needs-kept-mounted`).
286    ///
287    /// [`on_dismiss`](Self::on_dismiss) serves both exits here — the light
288    /// dismiss and the header's back affordance — so one closure closes the
289    /// view however the user asks.
290    ///
291    /// ```ignore
292    /// search_view(state.query.clone(), |s: &mut App, q| s.query = q)
293    ///     .suggestions(suggestion_list())
294    ///     .on_dismiss(|s: &mut App| s.search_open = false)
295    ///     .docked(&state.search_anchor)
296    ///     .open(state.search_open)
297    /// ```
298    pub fn docked(mut self, anchor: &OverlayAnchor) -> DockedSearchViewView<State> {
299        let placement = docked_placement(anchor);
300        let on_dismiss = self.on_dismiss.clone();
301        // A docked back press *is* a dismissal, so one closure covers both
302        // unless the caller set an explicit `on_back`.
303        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    /// Erase this builder into the shared content view for `mode`.
317    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
337/// The shared header/divider/suggestions content both presentations mount.
338struct SearchViewContentView<State: 'static> {
339    mode: SearchViewMode,
340    /// Docked only: the width source (`clamp(anchor.width, …)`).
341    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    /// The header's live editable: the framework's baseline field with its own
354    /// border and focus ring suppressed, spanning the header band, its glyphs
355    /// inset clear of the back slot.
356    // erasure: keep feeds a ChildPod: build_child/rebuild_child/teardown_child take &AnyView
357    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    /// The header's default leading view: the M3 `arrow_back` affordance.
377    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    /// The leading view this pass mounts: the caller's, or `default`.
393    fn leading_ref<'a>(&'a self, default: &'a AnyView<State>) -> &'a AnyView<State> {
394        self.leading.as_ref().unwrap_or(default)
395    }
396
397    /// The built-in trailing actions: one clear button while the query is
398    /// non-empty and the caller set no trailing of its own.
399    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    /// Every trailing view this pass mounts: the caller's own, or `generated`.
413    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    /// The body slot's stand-in for a view with no suggestions set: an empty
422    /// text run, which fills the tight body box and paints nothing. (Not a
423    /// zero-thickness divider — a divider fills whatever box it is given.)
424    fn body_view(&self) -> AnyView<State> {
425        any::<State, _>(frust::text(String::new()))
426    }
427
428    /// The header/body separator.
429    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
540/// The retained widget behind both search-view presentations: the header band,
541/// the divider, and the suggestion slot — plus, docked, the panel chrome the
542/// anchored host has none of. See the [module docs](self).
543pub struct SearchViewContentWidget {
544    mode: SearchViewMode,
545    anchor: Option<OverlayAnchor>,
546    /// The hint, kept for the docked presentation's own accessibility node
547    /// (full-screen, the modal host carries the label instead).
548    label: Option<String>,
549    field: ChildPod,
550    leading: ChildPod,
551    trailing: Vec<ChildPod>,
552    divider: ChildPod,
553    body: ChildPod,
554}
555
556impl SearchViewContentWidget {
557    /// The presentation this content is laid out for.
558    pub fn mode(&self) -> SearchViewMode {
559        self.mode
560    }
561
562    /// The header band's height, in logical px — 72dp full-screen, 56dp docked.
563    pub fn header_height(&self) -> f64 {
564        self.mode.header_height()
565    }
566
567    /// The panel size this content resolves for `area`: the whole area when
568    /// full-screen, else the reference's own
569    /// `clamp(anchorWidth, minWidth, maxWidth)` ×
570    /// `clamp(area.height × 2⁄3, minHeight, maxHeight)`.
571    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        // `.max(..).min(area)` rather than `clamp`: a viewport narrower/shorter
577        // than the M3 minimum must win, and `clamp` would panic on the
578        // inverted bounds that implies.
579        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        // The field spans the whole band (see the module docs' Header note);
598        // its own text inset clears the leading slot.
599        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        // Docked: the anchored host paints no chrome at all, so the panel's
643        // fill, elevation and corners are this widget's own. Full-screen: the
644        // modal host already painted (and clipped to) its panel.
645        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        // The header affordances sit *over* the band-spanning field, so they
691        // get the pass first; the field takes what is left of the band, and
692        // the body the rest.
693        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            // The modal host contributes the labelled `Role::Dialog` container
717            // this content hangs under; a second one would nest.
718            children(ctx);
719            return;
720        }
721        // Docked, nothing above contributes a node — the anchored host reports
722        // none of its own — so this content carries the container. It is
723        // deliberately **not** flagged modal: the page underneath a docked
724        // panel stays interactive.
725        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
740/// A declarative full-screen M3 search view — [`crate::overlay::modal`]'s host,
741/// pre-configured. Build one with [`SearchView::full_screen`]; push it with
742/// [`show_search_view`].
743pub 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
777/// A declarative docked M3 search view — [`crate::overlay::anchored_overlay`]'s
778/// host, pre-configured and placed over its anchor. Build one with
779/// [`SearchView::docked`].
780pub struct DockedSearchViewView<State: 'static>(AnchoredOverlayView<State>);
781
782impl<State: 'static> DockedSearchViewView<State> {
783    /// Tell the kept-mounted host whether it is open (default `true`). Keep the
784    /// host mounted and toggle this so its exit ramp can run — see
785    /// [`SearchView::docked`].
786    pub fn open(mut self, open: bool) -> Self {
787        self.0 = self.0.open(open);
788        self
789    }
790
791    /// Replace the host's light-dismiss callback (a press outside the panel, or
792    /// `Escape` once it holds focus) after the fact.
793    ///
794    /// [`SearchView::on_dismiss`] already wires this *and* the header's back
795    /// affordance; reach for this only to give the light dismiss its own
796    /// behaviour, since it leaves the back affordance on the earlier closure.
797    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
824/// Push `build`'s search view as a **full-screen** transparent navigator page
825/// and register `on_result` for the value it pops with — the
826/// [`crate::overlay::modal`] host's shared
827/// [`show_overlay_modal`](crate::overlay::show_overlay_modal) push.
828///
829/// The scrim tap, `Escape` and an Android back press are wired to
830/// `controller.pop()` for you, staged behind the host's exit ramp; the header's
831/// own back affordance is wired to the same pop, **unstaged** (see the [module
832/// docs](self)' header note).
833pub 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    /// A compact (phone-shaped) window, and an expanded (desktop) one.
869    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    /// The centre of a laid-out pod, in the content widget's own space.
963    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    // ---- Mode branching, both constraint regimes ----
970
971    #[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        // A 720dp-wide bar 56dp tall, 24dp down the window.
992        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        // 200dp tall: 2/3 is 133, below the 240 minimum, which itself exceeds
1027        // the area — the area wins rather than overflowing.
1028        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    // ---- Header composition ----
1084
1085    #[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    // ---- Query round trip ----
1124
1125    #[test]
1126    fn typing_reports_through_on_query_changed_and_rebuilds_the_suggestions() {
1127        /// One suggestion row per query character — so the assertion below
1128        /// proves the *round trip*, not just the report.
1129        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        // Focus the field (a press in the band, clear of the back slot), then
1143        // type: the baseline editable takes the keystrokes and reports them.
1144        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        // The app's own rebuild feeds the new query — and the suggestions it
1154        // produced from it — back down.
1155        root.rebuild(&mut logic, &mut state);
1156        root.layout_with_text(COMPACT, &mut tcx as &mut dyn Any);
1157    }
1158
1159    /// A character keystroke, the shape the baseline editable inserts on.
1160    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    // ---- Full-screen presentation: staged exit through the navigator ----
1169
1170    #[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        // Settle the ramp: one paint seeds it, one well past its duration ends
1255        // it, each followed by the relayout the slide asks for.
1256        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    // ---- Docked presentation: placement over the anchor ----
1285
1286    #[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        // The back affordance's own 48dp slot: one 8dp docked header inset in
1360        // from the panel's leading edge, vertically centred in the 56dp band.
1361        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        // The bottom-left corner, well clear of the placed panel.
1407        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    // ---- Semantics ----
1435
1436    #[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    // ---- Fixtures ----
1485
1486    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}