frust_material/search/mod.rs
1// Ported from `material_3_expressive` v1.0.8's search family (MIT, © 2026 Paa
2// Developments; `tmp/material_3_expressive/lib/components/search/` —
3// `m3e_search_bar.dart`, `m3e_search_anchor.dart`,
4// `components/m3e_search_bar_build.dart`, `components/m3e_search_bar_input.dart`,
5// `components/m3e_search_view.dart`, `components/m3e_search_view_build.dart`,
6// `components/m3e_search_view_route.dart`, `controllers/m3e_search_controller.dart`,
7// `res/m3e_search_constants.dart`, `styles/m3e_search_bar_theme.dart`,
8// `styles/m3e_search_view_theme.dart`, retrieved 2026-08-20).
9// Upstream: <https://github.com/paadevelopments/material_3_expressive>
10//
11// Porting decisions (each also documented, with its reference site, in the
12// module docs below): the presentation split is resolved by **window width**
13// rather than by the reference's `TargetPlatform` switch; the bar is
14// display-only (the reference's own `M3ESearchAnchor.bar` shape) so its M3
15// container role survives; the view's header field is the framework's baseline
16// editable, which carries its own opaque fill; the reference's transparent
17// default divider is replaced by a visible one; and the anchor's fade-out is
18// dropped because the docked panel covers the anchor rect outright.
19
20//! The M3 Expressive **search** family: the search bar (a pill-shaped field
21//! affordance) and the search view it opens (a full-screen route on compact
22//! widths, an anchored panel on expanded ones).
23//!
24//! - [`mod@bar`] — [`search_bar`], the pill: leading icon, hint-or-query text,
25//! trailing actions, a press-driven expand spring, and a tap that opens the
26//! view.
27//! - [`mod@view`] — [`search_view`], the view's shared content (edit-field
28//! header with a back affordance, divider, caller-supplied suggestion slot)
29//! plus the two presentations it terminates in:
30//! [`SearchView::full_screen`] (a modal-host page) and
31//! [`SearchView::docked`] (an anchored panel over the bar).
32//!
33//! # The presentation split, and the rule that picks it
34//!
35//! The reference picks its presentation from the **platform**, not from the
36//! window: `_M3ESearchAnchorState._showFullScreenView()`
37//! (`m3e_search_anchor.dart`) is
38//!
39//! ```text
40//! widget.isFullScreen ?? switch (M3ETheme.platformOf(context)) {
41//! TargetPlatform.iOS || TargetPlatform.android || TargetPlatform.fuchsia => true,
42//! TargetPlatform.macOS || TargetPlatform.linux || TargetPlatform.windows => false,
43//! }
44//! ```
45//!
46//! — i.e. full-screen on mobile, docked on desktop. This port substitutes M3's
47//! own **window-size-class** breakpoint: below
48//! [`SEARCH_VIEW_COMPACT_MAX_WIDTH`] (600dp, the compact class's upper bound)
49//! the view is full-screen, at or above it the view is docked
50//! ([`SearchViewMode::for_width`]). Two reasons, in order:
51//!
52//! 1. A design-system plugin has no `TargetPlatform` to switch on —
53//! [`frust::Theme`]'s `design_language` names the *catalog*, not the host OS,
54//! and a catalog that guessed the OS from it would report "Material ⇒
55//! Android" on a Material-themed desktop app.
56//! 2. The width rule reproduces the reference's outcome on real hardware
57//! anyway (a phone is compact, a desktop window is not) **and** additionally
58//! tracks a resized desktop window and a foldable, which the platform switch
59//! cannot. The reference itself already re-closes an open docked view when
60//! the screen size changes (`didChangeDependencies`), which is that same
61//! concern reached from the other side.
62//!
63//! The rule is a plain function over a width so an app can apply it wherever
64//! it learns one — typically `use_context::<`[`frust::WindowMetrics`]`>()` in
65//! `Component::build`, whose `size.width` is exactly the logical width this
66//! breakpoint is expressed in. Both presentations stay reachable explicitly, so
67//! an app that wants one unconditionally (the reference's `isFullScreen`
68//! override) just calls that constructor.
69//!
70//! # Why the split is two constructors rather than one adaptive widget
71//!
72//! The two presentations are not two layouts of one widget: the full-screen
73//! view is a **navigator route** (pushed through
74//! [`crate::overlay::show_overlay_modal`], so it carries a
75//! [`frust::BackPolicy`] and a staged exit ramp), while the docked view is a
76//! **mounted panel** (an [`crate::overlay::anchored_overlay`] host the app
77//! keeps mounted and toggles with `.open(bool)`). Only an app can push a route,
78//! so the choice is necessarily made where the push is — not inside a widget's
79//! `layout`. The width rule above is therefore a function the app applies, and
80//! each presentation is its own view type.
81//!
82//! # Geometry
83//!
84//! Both presentations share [`mod@view`]'s content widget; the mode selects the
85//! header band and the panel chrome:
86//!
87//! | | Full-screen | Docked |
88//! |---|---|---|
89//! | Panel | the whole area | [`place_anchored`](crate::overlay::place_anchored) over the bar's own rect |
90//! | Width | the area's | `clamp(anchor.width, `[`SEARCH_VIEW_MIN_WIDTH`]`, area.width)` |
91//! | Height | the area's | `clamp(area.height × 2⁄3, `[`SEARCH_VIEW_MIN_HEIGHT`]`, area.height)` |
92//! | Header | [`FULL_SCREEN_HEADER_HEIGHT`] (72dp), 16/8dp inset | [`SEARCH_BAR_MIN_HEIGHT`] (56dp), 8dp inset |
93//! | Corners | square | [`frust::Theme`]`.shape.extra_large` (28dp) |
94//! | Chrome owner | the modal host | the content itself (the anchored host paints none) |
95//!
96//! The docked width/height rule is `M3ESearchViewRoute._updateTweens`'
97//! (`m3e_search_view_route.dart`) verbatim — `clampDouble(anchorRect.width,
98//! minWidth, maxWidth)` and `clampDouble(screenSize.height * 2 / 3, minHeight,
99//! maxHeight)`, with the reference's own shift-back-on-screen step supplied by
100//! [`crate::overlay::place_anchored`]'s `clamp`.
101//!
102//! # Fidelity decisions
103//!
104//! - **The bar is display-only.** The reference's `M3ESearchBar` embeds a live
105//! `EditableText`, but the shape every anchor uses —
106//! `M3ESearchAnchor.bar`/`_M3ESearchAnchorBarState` — passes `readOnly: true`
107//! with a `canRequestFocus: false` focus node, because *the view's* field
108//! owns editing. This port ships that shape only: the framework's baseline
109//! editable paints its own opaque `surface` background with no seam to
110//! suppress it, so a bar that embedded one would lose the
111//! `surfaceContainerHigh` pill that is the component's whole identity — see
112//! [`crate::text_field`]'s *Container fill* note for the same seam limit
113//! reached from the other side. Editing lives in the view, exactly as the
114//! reference's anchor bar intends.
115//! - **The view's header field band paints the baseline editable's own
116//! `surface` fill**, not the view container's role, for that same reason: the
117//! header's editable spans the full header band so the fill reads as one
118//! uniform field band above the divider rather than as a patch behind the
119//! glyphs.
120//! - **The container role is `surfaceContainerHigh` in *both* modes.** The
121//! reference paints a full-screen view in `scheme.surface`
122//! (`M3ESearchViewTheme.fullScreenBackgroundColor`) and a docked one in
123//! `scheme.surfaceContainerHigh`; M3's published search-view container role
124//! is `surfaceContainerHigh` for both, which is what this port uses.
125//! - **The divider is visible by default.** `_resolveViewStyles`
126//! (`m3e_search_view_build.dart`) resolves `widget.dividerColor ??
127//! Colors.transparent`, so the reference's own default divider is invisible
128//! unless a caller passes a color; this port always draws
129//! [`crate::divider`]'s `outlineVariant` hairline between the header and the
130//! suggestions, which is what M3 itself shows.
131//! - **The anchor does not fade.** `_M3ESearchAnchorState.build` wraps the
132//! trigger in an `AnimatedOpacity` that fades it to `0` while the view is
133//! open; the docked panel here covers the anchor's own rect outright (its
134//! top-left *is* the anchor's, per the table above) and is opaque, so there
135//! is nothing to fade out from under it.
136//! - **The open transition is the host's ramp, not a rect tween.** The
137//! reference animates a `RectTween` from the anchor's rect to the view's over
138//! 600ms `easeInOutCubicEmphasized`, with four staged interval fades layered
139//! on top (`M3ESearchConstants.view*FadeOnInterval`). Neither overlay host
140//! exposes a per-child geometry tween, so each mode takes its host's own
141//! entrance instead: the full-screen page slides down from the top edge
142//! ([`crate::overlay::OverlayEntrance::Slide`], the only entrance an
143//! edge-pinned panel's progress-driven geometry can take — see
144//! [`mod@view`]'s `full_screen_config`), the docked panel fades and scales
145//! toward the anchor ([`crate::overlay::anchored_overlay`]'s ramp). The bar's
146//! own focus-expand spring *is* ported ([`mod@bar`]).
147//!
148//! # IME: no programmatic focus
149//!
150//! The reference opens its view with `autoFocus: true` on the header field, so
151//! the keyboard is up before the user touches anything. The framework's
152//! baseline editable exposes **no programmatic-focus seam** — `TextInputView`
153//! has no `autofocus`/`request_focus` builder, and `EventCtx::request_focus` is
154//! reachable only from a widget's own event pass, which never runs for a field
155//! nobody has touched yet. A user therefore taps the view's field once before
156//! the keyboard appears. This is the same framework gap
157//! [`mod@crate::overlay::modal`] records for `Escape` ("there is no
158//! auto-focus-on-appear hook in the framework"), and it is not patched around
159//! here.
160//!
161//! # Symbol surface
162//!
163//! Everything below, plus [`mod@bar`]'s and [`mod@view`]'s types and
164//! constructors, is flat re-exported at the crate root like every other
165//! component here. [`bar::EXPAND_REST`]/[`bar::EXPAND_ACTIVE`] are the one
166//! carve-out and stay namespaced (`frust_material::search::bar::EXPAND_REST`):
167//! they are one component's motion tuning rather than catalog surface — the
168//! same carve-out [`crate::overlay`]'s token accessors take.
169
170use frust::authoring::text::LineHeight;
171
172pub mod bar;
173pub mod view;
174
175pub use bar::{SearchBarView, SearchBarWidget, search_bar};
176pub use view::{
177 DockedSearchViewView, SearchView, SearchViewContentWidget, SearchViewView, search_view,
178 show_search_view,
179};
180
181/// The search bar's minimum height, in logical px — `M3ESearchBarTheme`'s own
182/// `minHeight` default (56dp), and the M3 search-bar spec height. Doubles as
183/// the **docked** view's header-band height, the same way the reference falls
184/// back to `theme.searchBarTheme.minHeight` for a docked header
185/// (`m3e_search_view_build.dart`'s `headerBlockHeight`).
186pub const SEARCH_BAR_MIN_HEIGHT: f64 = 56.0;
187
188/// The search bar's minimum width, in logical px — `M3ESearchBarTheme`'s own
189/// `minWidth` default (360dp).
190///
191/// The reference applies it through a `ConstrainedBox(minWidth: 360)`, which
192/// *overflows* a narrower parent; this port clamps it down to whatever width it
193/// is offered instead (the same narrower-viewport clamp
194/// [`crate::side_sheet`]'s fixed width takes).
195pub const SEARCH_BAR_MIN_WIDTH: f64 = 360.0;
196
197/// The docked search view's minimum width, in logical px —
198/// `M3ESearchViewTheme`'s own `minWidth` default (360dp).
199pub const SEARCH_VIEW_MIN_WIDTH: f64 = 360.0;
200
201/// The docked search view's minimum height, in logical px —
202/// `M3ESearchViewTheme`'s own `minHeight` default (240dp).
203pub const SEARCH_VIEW_MIN_HEIGHT: f64 = 240.0;
204
205/// The fraction of the window height a docked search view takes before its
206/// [`SEARCH_VIEW_MIN_HEIGHT`] floor applies — `M3ESearchViewRoute._updateTweens`'
207/// own `screenSize.height * 2 / 3`.
208pub const SEARCH_VIEW_HEIGHT_FRACTION: f64 = 2.0 / 3.0;
209
210/// The full-screen view's header-band height, in logical px —
211/// `M3ESearchConstants.fullScreenBarHeight` / `M3ESearchViewTheme.headerHeight`
212/// (both 72dp).
213pub const FULL_SCREEN_HEADER_HEIGHT: f64 = 72.0;
214
215/// The exclusive upper bound of M3's **compact** window-size class, in logical
216/// px: a window narrower than this presents the search view full-screen, one at
217/// least this wide presents it docked. See the [module docs](self)' presentation
218/// split for why this replaces the reference's `TargetPlatform` switch.
219pub const SEARCH_VIEW_COMPACT_MAX_WIDTH: f64 = 600.0;
220
221/// Body-large glyph size, in logical px — the type role the reference's bar and
222/// view header both read (`M3ESearchBarTheme.textStyle`/`hintStyle` and
223/// `M3ESearchViewTheme.headerTextStyle`/`headerHintStyle` are all
224/// `type.bodyLarge`).
225///
226/// Hardcoded rather than read from a live [`frust::Theme`]'s type scale for the
227/// reason [`crate::side_sheet`]'s title metrics are: `Text` resolves a *color*
228/// role, and an opt-in family role, after `View::build`, but never a size.
229pub(crate) const BODY_LARGE_SIZE: f32 = 16.0;
230
231/// Body-large line height, in logical px (M3 `bodyLarge`, 16/24).
232pub(crate) const BODY_LARGE_LINE_HEIGHT: LineHeight = LineHeight::Absolute(24.0);
233
234/// A slot wide enough for one M3 48dp touch target — what the reference's
235/// `_wrapActionSlot` (`m3e_search_bar_build.dart`) reserves per leading/trailing
236/// action, and the width both the bar and the view header lay their action
237/// slots out in.
238pub(crate) const ACTION_SLOT: f64 = 48.0;
239
240/// The bar's and the docked header's horizontal inset —
241/// `M3ESearchBarTheme.horizontalPadding` / `M3ESearchViewTheme.barHorizontalPadding`
242/// (both 8dp).
243pub(crate) const BAR_HORIZONTAL_PADDING: f64 = 8.0;
244
245/// The opacity a disabled bar composites at —
246/// `M3ESearchConstants.disabledOpacity` (0.38).
247pub(crate) const DISABLED_OPACITY: f32 = 0.38;
248
249/// Which presentation a search view takes. See the [module docs](self)'
250/// presentation split.
251#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
252pub enum SearchViewMode {
253 /// A full-screen navigator route — the compact-width presentation, and the
254 /// reference's mobile one.
255 #[default]
256 FullScreen,
257 /// An anchored panel over the bar — the expanded-width presentation, and
258 /// the reference's desktop one.
259 Docked,
260}
261
262impl SearchViewMode {
263 /// The presentation for a window `width` in logical px: [`Self::FullScreen`]
264 /// below [`SEARCH_VIEW_COMPACT_MAX_WIDTH`], [`Self::Docked`] at or above it.
265 ///
266 /// ```
267 /// use frust_material::{SearchViewMode, SEARCH_VIEW_COMPACT_MAX_WIDTH};
268 ///
269 /// assert_eq!(SearchViewMode::for_width(392.0), SearchViewMode::FullScreen);
270 /// assert_eq!(SearchViewMode::for_width(1280.0), SearchViewMode::Docked);
271 /// // The boundary itself is already expanded.
272 /// assert_eq!(
273 /// SearchViewMode::for_width(SEARCH_VIEW_COMPACT_MAX_WIDTH),
274 /// SearchViewMode::Docked,
275 /// );
276 /// ```
277 pub fn for_width(width: f64) -> Self {
278 if width < SEARCH_VIEW_COMPACT_MAX_WIDTH {
279 SearchViewMode::FullScreen
280 } else {
281 SearchViewMode::Docked
282 }
283 }
284
285 /// Whether this is the full-screen presentation.
286 pub fn is_full_screen(self) -> bool {
287 matches!(self, SearchViewMode::FullScreen)
288 }
289
290 /// This mode's header-band height, in logical px: 72dp full-screen
291 /// (`M3ESearchConstants.fullScreenBarHeight`), 56dp docked (the reference's
292 /// `theme.searchBarTheme.minHeight` fallback).
293 pub(crate) fn header_height(self) -> f64 {
294 if self.is_full_screen() {
295 FULL_SCREEN_HEADER_HEIGHT
296 } else {
297 SEARCH_BAR_MIN_HEIGHT
298 }
299 }
300
301 /// This mode's header inset as `(horizontal, vertical)`, in logical px —
302 /// `M3ESearchViewTheme.fullScreenHeaderPadding` (16/8) full-screen,
303 /// `barPadding` (8/0) docked.
304 pub(crate) fn header_padding(self) -> (f64, f64) {
305 if self.is_full_screen() {
306 (16.0, 8.0)
307 } else {
308 (BAR_HORIZONTAL_PADDING, 0.0)
309 }
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn the_breakpoint_splits_compact_from_expanded() {
319 // A phone, a small tablet in portrait, a desktop window.
320 assert_eq!(SearchViewMode::for_width(360.0), SearchViewMode::FullScreen);
321 assert_eq!(SearchViewMode::for_width(599.9), SearchViewMode::FullScreen);
322 assert_eq!(SearchViewMode::for_width(600.0), SearchViewMode::Docked);
323 assert_eq!(SearchViewMode::for_width(1440.0), SearchViewMode::Docked);
324 }
325
326 #[test]
327 fn a_degenerate_width_still_answers_full_screen() {
328 // A zero/negative width is the unbounded-constraint degenerate case
329 // (`finite_or_zero`); the compact answer is the safe one — it needs no
330 // anchor rect to place against.
331 assert_eq!(SearchViewMode::for_width(0.0), SearchViewMode::FullScreen);
332 assert_eq!(SearchViewMode::for_width(-1.0), SearchViewMode::FullScreen);
333 }
334
335 #[test]
336 fn each_mode_carries_its_own_header_band() {
337 assert_eq!(
338 SearchViewMode::FullScreen.header_height(),
339 FULL_SCREEN_HEADER_HEIGHT
340 );
341 assert_eq!(
342 SearchViewMode::Docked.header_height(),
343 SEARCH_BAR_MIN_HEIGHT
344 );
345 assert_eq!(SearchViewMode::FullScreen.header_padding(), (16.0, 8.0));
346 assert_eq!(
347 SearchViewMode::Docked.header_padding(),
348 (BAR_HORIZONTAL_PADDING, 0.0)
349 );
350 }
351
352 #[test]
353 fn the_ported_metrics_match_the_reference_theme_defaults() {
354 // `M3ESearchBarTheme` / `M3ESearchViewTheme` / `M3ESearchConstants`.
355 assert_eq!(SEARCH_BAR_MIN_HEIGHT, 56.0);
356 assert_eq!(SEARCH_BAR_MIN_WIDTH, 360.0);
357 assert_eq!(SEARCH_VIEW_MIN_WIDTH, 360.0);
358 assert_eq!(SEARCH_VIEW_MIN_HEIGHT, 240.0);
359 assert_eq!(FULL_SCREEN_HEADER_HEIGHT, 72.0);
360 assert_eq!(BAR_HORIZONTAL_PADDING, 8.0);
361 assert_eq!(DISABLED_OPACITY, 0.38);
362 assert!((SEARCH_VIEW_HEIGHT_FRACTION - 2.0 / 3.0).abs() < f64::EPSILON);
363 }
364}