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