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