Skip to main content

frust_material/search/
bar.rs

1// Ported from `material_3_expressive` v1.0.8's `M3ESearchBar`
2// (`lib/components/search/m3e_search_bar.dart`,
3// `components/m3e_search_bar_build.dart`, `styles/m3e_search_bar_theme.dart`)
4// and the read-only anchor shape `_M3ESearchAnchorBarState`
5// (`m3e_search_anchor.dart`) — MIT, © 2026 Paa Developments; see [`super`]'s
6// header for the family's full source list and porting decisions.
7
8//! The M3 Expressive **search bar**: the pill-shaped affordance that opens a
9//! search view.
10//!
11//! # Geometry
12//!
13//! A stadium ([`frust::Theme`]`.shape.full`-equivalent) pill filled with
14//! `surfaceContainerHigh`, at least [`super::SEARCH_BAR_MIN_HEIGHT`] (56dp)
15//! tall, inset [`super::BAR_HORIZONTAL_PADDING`] (8dp) on both ends, laying out
16//! `[leading slot][hint-or-query][trailing slots]` — the reference's
17//! `_buildEditingRow` (`m3e_search_bar_build.dart`) with each action wrapped in
18//! its own [`super::ACTION_SLOT`] (48dp) touch target, exactly as
19//! `_wrapActionSlot` does.
20//!
21//! The bar fills whatever width it is offered, floored at
22//! [`super::SEARCH_BAR_MIN_WIDTH`] only when its constraints are unbounded (see
23//! that constant for the reference's overflowing `ConstrainedBox` and why this
24//! port clamps instead).
25//!
26//! # Display-only, by design
27//!
28//! The bar never edits: it shows `query`, or `hint` when the query is empty,
29//! and a press fires [`SearchBarView::on_tap`] — the caller opens the view,
30//! whose own field owns editing. That is the reference's own
31//! `M3ESearchAnchor.bar` shape (`readOnly: true`, `canRequestFocus: false`);
32//! see [`super`]'s *Fidelity decisions* for why the live-editable
33//! `M3ESearchBar` variant is not offered.
34//!
35//! [`search_bar`]'s `on_query_changed` is therefore not an editing callback but
36//! the controlled query's **write side**, and this widget's one caller of it is
37//! the built-in clear action: with no explicit
38//! [`trailing`](SearchBarView::trailing) set and a non-empty query, the bar
39//! grows a [`crate::icons::CLOSE`] icon button that reports the empty string —
40//! the reference's `_M3ESearchAnchorBarState._buildTrailing`, whose default
41//! trailing is exactly one clear button wired to `controller.clear`.
42//!
43//! # The expand spring
44//!
45//! `M3ESearchBarTheme` animates a horizontal inset ([`EXPAND_REST`], 8dp)
46//! down to half of it ([`EXPAND_ACTIVE`], 4dp) — i.e. the pill *widens* — with
47//! `M3EMotion.expressiveSpatialPress` (stiffness 380, damping ratio 0.55, the
48//! [`crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS`] token
49//! [`EXPAND_SPRING`] pins). The reference drives it from **focus**
50//! (`_handleFocusChange` → `_syncExpandPaddingController`); a display-only bar
51//! never takes focus, so this port drives the same spring from the bar's own
52//! **press** instead — the nearest live signal, and the one the reference's own
53//! spring is named for. The inset is applied in `layout` (it changes the pill's
54//! geometry, so hit-testing follows it), which is why a running spring asks for
55//! a relayout rather than a bare repaint. Gating that request solely on
56//! [`AnimationController::advance`]'s own return would miss its *landing*
57//! frame — `advance` reports `false` on the very pass that snaps the value
58//! onto its target — so `paint` also compares the inset before/after
59//! advancing and requests one more relayout whenever it differs.
60//!
61//! `Theme.motion.reduce_motion` snaps the inset to its target with no ramp —
62//! and, since the inset is a layout value, a snap that actually moves it
63//! requests the same one relayout a running spring does (the same
64//! before/after comparison catches both), so the pill (and its hit region)
65//! don't lag a frame behind the snap.
66//!
67//! # Interaction
68//!
69//! Fire-on-up-inside with a pointer capture, the M3 state layer
70//! (pressed 10% / hovered 8% — the reference's own `pressedOverlayOpacity` /
71//! `hoveredOverlayOpacity`), and the catalog's claim/latch/self-correct hover
72//! discipline (`docs/CODE_STANDARDS.md`'s Interaction Semantics; the
73//! [`crate::list_item`] reference impl). A disabled bar composites at
74//! [`super::DISABLED_OPACITY`] (0.38), claims no hover and arms nothing —
75//! the reference's `Opacity` + `IgnorePointer` pair.
76
77use std::rc::Rc;
78use std::time::Duration;
79
80use frust::authoring::text::TextOverflow;
81use frust::authoring::{
82    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedCallback, EventCtx,
83    EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, Role, SemanticsCtx,
84    ThemeTextColor, ThemeTextType, View, Widget, any, build_child, erase_callback, rebuild_child,
85    rebuild_children, route_event, route_event_single, teardown_child, visit_children,
86};
87use frust::{AnimationController, Color, FrameTime, SpringDesc, Theme, icon, text};
88use kurbo::{Point, Rect, Size};
89
90use super::{
91    ACTION_SLOT, BAR_HORIZONTAL_PADDING, BODY_LARGE_LINE_HEIGHT, BODY_LARGE_SIZE, DISABLED_OPACITY,
92    SEARCH_BAR_MIN_HEIGHT, SEARCH_BAR_MIN_WIDTH,
93};
94use crate::icon_button::icon_button;
95use crate::overlay::{finite_or_zero, reduce_motion};
96use crate::press::presses;
97use crate::state_layer::StateLayer;
98
99/// The pill's resting horizontal inset, in logical px —
100/// `M3ESearchBarTheme.restingExpandPadding` (8dp).
101pub const EXPAND_REST: f64 = 8.0;
102
103/// The pill's active (pressed) horizontal inset, in logical px — the
104/// reference's `_focusedExpandPadding`, which is
105/// `_restingExpandPadding(barTheme) / 2`.
106pub const EXPAND_ACTIVE: f64 = EXPAND_REST / 2.0;
107
108/// The expand spring: stiffness 380, damping ratio 0.55, mass 1 —
109/// `M3ESearchBarTheme.focusExpandSpring`'s `M3EMotion.expressiveSpatialPress`
110/// default, i.e. [`crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS`].
111///
112/// A named constant rather than a theme read for the reason every press spring
113/// in this crate is: a press starts in the event pass, and event-pass code
114/// never reads a theme (`docs/CODE_STANDARDS.md`'s Theming conventions).
115const EXPAND_SPRING: SpringDesc = SpringDesc {
116    mass: 1.0,
117    stiffness: 380.0,
118    damping_ratio: 0.55,
119};
120
121/// Nominal period seeding the expand [`AnimationController`]'s clock. The
122/// motion is spring-driven via `fling`, so this duration backs the
123/// controller's construction only and is not itself a timing (the same shape
124/// [`crate::button`]'s press morph uses).
125const EXPAND_PERIOD: Duration = Duration::from_millis(300);
126
127/// Launch velocity (progress-units/sec) handed to the expand spring's `fling`
128/// — the same modest kick every press spring in this catalog takes.
129const EXPAND_FLING_VELOCITY: f64 = 4.0;
130
131/// Dead band (logical px) a new inset target must exceed to start a new spring
132/// leg, so a re-resolving paint pass cannot restart the spring every frame
133/// ([`crate::button`]'s `RETARGET_TOLERANCE`).
134const RETARGET_TOLERANCE: f64 = 0.1;
135
136/// Unthemed-fallback `surfaceContainerHigh` — the pill's fill
137/// (`M3ESearchBarTheme.backgroundColor`), M3 baseline light.
138const FALLBACK_CONTAINER_HIGH: Color = Color::from_rgb8(0xEC, 0xE6, 0xF0);
139
140/// Unthemed-fallback `onSurface` — the state layer's ink, M3 baseline light.
141const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
142
143/// A view-held, typed text callback (erased on build).
144type OnText<State> = Rc<dyn Fn(&mut State, String)>;
145
146/// A view-held, typed state callback (erased on build).
147type OnState<State> = Rc<dyn Fn(&mut State)>;
148
149/// The pill's horizontal-inset spring. See the [module docs](self)' *expand
150/// spring*.
151struct ExpandMotion {
152    from: f64,
153    to: f64,
154    anim: AnimationController,
155}
156
157impl ExpandMotion {
158    /// A motion resting at [`EXPAND_REST`] with nothing running.
159    fn new() -> Self {
160        Self {
161            from: EXPAND_REST,
162            to: EXPAND_REST,
163            anim: AnimationController::new(EXPAND_PERIOD),
164        }
165    }
166
167    /// Aim the inset at `target`, returning whether a new spring leg started.
168    /// A target inside [`RETARGET_TOLERANCE`] of the live one is ignored.
169    fn retarget(&mut self, target: f64) -> bool {
170        if (self.to - target).abs() <= RETARGET_TOLERANCE {
171            return false;
172        }
173        self.from = self.value();
174        self.to = target;
175        self.anim = AnimationController::new(EXPAND_PERIOD);
176        self.anim.fling(EXPAND_FLING_VELOCITY, EXPAND_SPRING);
177        true
178    }
179
180    /// Pin the inset to its target with no motion (`reduce_motion`, and the
181    /// reference's own `snapToTarget`).
182    fn snap(&mut self) {
183        self.from = self.to;
184        self.anim = AnimationController::new(EXPAND_PERIOD);
185    }
186
187    /// Advance the spring to `now`, reporting whether it is still running.
188    fn advance(&mut self, now: FrameTime) -> bool {
189        self.anim.advance(now)
190    }
191
192    /// The inset this frame lays out with — never negative, since an
193    /// under-damped overshoot toward the smaller target can otherwise drive it
194    /// past zero (the reference's own `pad > 0 ? pad : 0.0` guard).
195    fn value(&self) -> f64 {
196        let t = self.anim.value();
197        let t = if t.is_finite() { t } else { 1.0 };
198        (self.from + (self.to - self.from) * t).max(0.0)
199    }
200}
201
202/// A declarative M3 search bar. See the [module docs](self).
203pub struct SearchBarView<State: 'static> {
204    query: String,
205    on_query_changed: OnText<State>,
206    hint: Option<String>,
207    leading: Option<AnyView<State>>,
208    trailing: Vec<AnyView<State>>,
209    on_tap: Option<OnState<State>>,
210    enabled: bool,
211}
212
213/// Build a controlled M3 search bar showing `query`.
214///
215/// `on_query_changed(state, new_query)` is the controlled value's write side;
216/// this widget itself calls it only from the built-in clear action (see the
217/// [module docs](self)), and the search view it opens calls it for every
218/// keystroke. Wire [`SearchBarView::on_tap`] to open that view.
219///
220/// ```ignore
221/// search_bar(state.query.clone(), |s: &mut App, q| s.query = q)
222///     .hint("Search recipes")
223///     .on_tap(|s: &mut App| s.search_open = true)
224/// ```
225pub fn search_bar<State: 'static, F: Fn(&mut State, String) + 'static>(
226    query: impl Into<String>,
227    on_query_changed: F,
228) -> SearchBarView<State> {
229    SearchBarView {
230        query: query.into(),
231        on_query_changed: Rc::new(on_query_changed),
232        hint: None,
233        leading: None,
234        trailing: Vec::new(),
235        on_tap: None,
236        enabled: true,
237    }
238}
239
240impl<State: 'static> SearchBarView<State> {
241    /// Set the placeholder shown while `query` is empty
242    /// (`M3ESearchBar.hintText`). It also labels the bar's accessibility node.
243    pub fn hint(mut self, hint: impl Into<String>) -> Self {
244        self.hint = Some(hint.into());
245        self
246    }
247
248    /// Replace the leading slot's default [`crate::icons::SEARCH`] icon.
249    ///
250    /// The slot itself is never empty — the reference's anchor bar passes
251    /// `barLeading ?? Icon(M3EIcons.search)`, so its
252    /// `noLeadingHintExtraPadding` (the extra start inset for a leadingless
253    /// bar) has no reachable case here and is not ported.
254    pub fn leading(mut self, view: impl View<State>) -> Self {
255        self.leading = Some(AnyView::new(view));
256        self
257    }
258
259    /// Append a trailing action (an icon button, an avatar). Setting any
260    /// trailing action replaces the built-in clear button —
261    /// `_M3ESearchAnchorBarState._buildTrailing`'s own
262    /// `widget.barTrailing ?? [clear]` precedence.
263    pub fn trailing(mut self, view: impl View<State>) -> Self {
264        self.trailing.push(AnyView::new(view));
265        self
266    }
267
268    /// Set the press handler — where a caller opens the search view
269    /// (`M3ESearchBar.onTap`, which the reference's anchor wires to
270    /// `controller.openView()`).
271    pub fn on_tap<F: Fn(&mut State) + 'static>(mut self, on_tap: F) -> Self {
272        self.on_tap = Some(Rc::new(on_tap));
273        self
274    }
275
276    /// Set whether the bar responds to input (default `true`). A disabled bar
277    /// composites at [`super::DISABLED_OPACITY`] and arms nothing.
278    pub fn enabled(mut self, enabled: bool) -> Self {
279        self.enabled = enabled;
280        self
281    }
282
283    /// The leading slot's default view (the M3 `search` glyph). Rebuilt per
284    /// pass rather than stored, since [`AnyView`] is opaque — the
285    /// [`crate::side_sheet`] precedent for a generated child view.
286    fn default_leading(&self) -> AnyView<State> {
287        any::<State, _>(icon(crate::icons::SEARCH))
288    }
289
290    /// The leading view this pass mounts: the caller's, or `default`.
291    fn leading_ref<'a>(&'a self, default: &'a AnyView<State>) -> &'a AnyView<State> {
292        self.leading.as_ref().unwrap_or(default)
293    }
294
295    /// The built-in trailing actions: one clear button while the query is
296    /// non-empty and the caller set no trailing of its own, else nothing.
297    fn generated_trailing(&self) -> Vec<AnyView<State>> {
298        if !self.trailing.is_empty() || self.query.is_empty() {
299            return Vec::new();
300        }
301        let on_query_changed = self.on_query_changed.clone();
302        vec![any::<State, _>(
303            icon_button(any::<State, _>(icon(crate::icons::CLOSE)), move |state| {
304                on_query_changed(state, String::new());
305            })
306            .semantic_label(CLEAR_LABEL),
307        )]
308    }
309
310    /// Every trailing view this pass mounts, in order: the caller's own, or
311    /// `generated` — `_buildTrailing`'s `widget.barTrailing ?? [clear]`.
312    fn trailing_refs<'a>(&'a self, generated: &'a [AnyView<State>]) -> Vec<&'a AnyView<State>> {
313        if self.trailing.is_empty() {
314            generated.iter().collect()
315        } else {
316            self.trailing.iter().collect()
317        }
318    }
319
320    /// The label run: the query, or the hint while it is empty — `bodyLarge`
321    /// metrics, its family following the live theme's `bodyLarge` role.
322    fn label_view(&self) -> AnyView<State> {
323        let (content, role) = if self.query.is_empty() {
324            (
325                self.hint.clone().unwrap_or_default(),
326                ThemeTextColor::OnSurfaceVariant,
327            )
328        } else {
329            (self.query.clone(), ThemeTextColor::OnSurface)
330        };
331        any::<State, _>(
332            text(content)
333                .size(BODY_LARGE_SIZE)
334                .line_height(BODY_LARGE_LINE_HEIGHT)
335                .themed_role(role)
336                .themed_family(ThemeTextType::BodyLarge)
337                .max_lines(1)
338                .overflow(TextOverflow::Ellipsis),
339        )
340    }
341}
342
343/// The clear action's accessible name — `M3ESearchConstants.clearButtonTooltip`.
344const CLEAR_LABEL: &str = "Clear";
345
346impl<State: 'static> View<State> for SearchBarView<State> {
347    type Element = SearchBarWidget;
348
349    fn build(&self, ctx: &mut BuildCtx<'_>) -> SearchBarWidget {
350        let default_leading = self.default_leading();
351        let generated = self.generated_trailing();
352        SearchBarWidget {
353            leading: build_child(self.leading_ref(&default_leading), ctx),
354            label: build_child(&self.label_view(), ctx),
355            trailing: self
356                .trailing_refs(&generated)
357                .into_iter()
358                .map(|v| build_child(v, ctx))
359                .collect(),
360            on_tap: self.on_tap.as_ref().map(erase_callback),
361            enabled: self.enabled,
362            hint: self.hint.clone(),
363            state: StateLayer::new(),
364            captured: false,
365            expand: ExpandMotion::new(),
366            pill: Rect::ZERO,
367        }
368    }
369
370    fn rebuild(
371        &self,
372        prev: &Self,
373        element: &mut SearchBarWidget,
374        ctx: &mut BuildCtx<'_>,
375    ) -> ChangeFlags {
376        let prev_default_leading = prev.default_leading();
377        let next_default_leading = self.default_leading();
378        let mut flags = rebuild_child(
379            prev.leading_ref(&prev_default_leading),
380            self.leading_ref(&next_default_leading),
381            &mut element.leading,
382            ctx,
383        );
384        flags |= rebuild_child(
385            &prev.label_view(),
386            &self.label_view(),
387            &mut element.label,
388            ctx,
389        );
390
391        let prev_generated = prev.generated_trailing();
392        let next_generated = self.generated_trailing();
393        let prev_refs = prev.trailing_refs(&prev_generated);
394        let next_refs = self.trailing_refs(&next_generated);
395        flags |= rebuild_children(
396            &prev_refs,
397            &next_refs,
398            &mut element.trailing,
399            ctx,
400            |view: &&AnyView<State>| *view,
401            |_| None,
402        );
403
404        element.on_tap = self.on_tap.as_ref().map(erase_callback);
405        if element.enabled != self.enabled {
406            element.enabled = self.enabled;
407            if !self.enabled {
408                element.state.set_pressed(false);
409                element.state.set_hovered(false);
410                element.captured = false;
411            }
412            flags |= ChangeFlags::PAINT;
413        }
414        if element.hint != self.hint {
415            element.hint = self.hint.clone();
416        }
417        flags
418    }
419
420    fn teardown(&self, element: &mut SearchBarWidget, ctx: &mut BuildCtx<'_>) {
421        let default_leading = self.default_leading();
422        teardown_child(
423            self.leading_ref(&default_leading),
424            &mut element.leading,
425            ctx,
426        );
427        teardown_child(&self.label_view(), &mut element.label, ctx);
428        let generated = self.generated_trailing();
429        for (view, pod) in self
430            .trailing_refs(&generated)
431            .into_iter()
432            .zip(element.trailing.iter_mut())
433        {
434            teardown_child(view, pod, ctx);
435        }
436    }
437}
438
439/// The retained widget for a [`SearchBarView`]. See the [module docs](self).
440pub struct SearchBarWidget {
441    leading: ChildPod,
442    label: ChildPod,
443    trailing: Vec<ChildPod>,
444    on_tap: Option<ErasedCallback>,
445    enabled: bool,
446    /// The hint, kept for the accessibility node's label.
447    hint: Option<String>,
448    state: StateLayer,
449    captured: bool,
450    expand: ExpandMotion,
451    /// The pill's rect in this widget's own space — the hit region, and what
452    /// the fill and state layer paint.
453    pill: Rect,
454}
455
456impl SearchBarWidget {
457    /// The pill's rect in this widget's own coordinate space: the widget's box
458    /// inset by the live [expand](self) spring value on both ends.
459    pub fn pill_rect(&self) -> Rect {
460        self.pill
461    }
462
463    /// The horizontal inset the expand spring is currently at, in logical px.
464    pub fn expand_inset(&self) -> f64 {
465        self.expand.value()
466    }
467}
468
469impl Widget for SearchBarWidget {
470    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
471        let avail = finite_or_zero(bc.max().width);
472        let width = if avail > 0.0 {
473            avail.max(bc.min().width)
474        } else {
475            SEARCH_BAR_MIN_WIDTH
476        };
477        // The 56dp spec height, floored by the incoming minimum and capped by a
478        // finite maximum — so the pill's own rect never disagrees with the size
479        // this layout reports.
480        let height = SEARCH_BAR_MIN_HEIGHT.max(bc.min().height);
481        let height = if bc.max().height.is_finite() {
482            height.min(bc.max().height)
483        } else {
484            height
485        };
486
487        let inset = self.expand.value();
488        // A spring overshoot may not eat the pill: the inset can never take
489        // more than the box itself.
490        let inset = inset.min(width / 2.0);
491        self.pill = Rect::new(inset, 0.0, width - inset, height);
492
493        let content_x0 = self.pill.x0 + BAR_HORIZONTAL_PADDING;
494        let content_x1 = self.pill.x1 - BAR_HORIZONTAL_PADDING;
495        let slot_bc = BoxConstraints::loose(Size::new(ACTION_SLOT, height));
496
497        let leading_size = self.leading.layout_child(ctx, &slot_bc);
498        self.leading.set_origin(Point::new(
499            content_x0 + (ACTION_SLOT - leading_size.width) / 2.0,
500            (height - leading_size.height) / 2.0,
501        ));
502
503        // Trailing actions pack from the end edge inward, each centred in its
504        // own 48dp slot.
505        let mut trailing_x = content_x1;
506        for pod in self.trailing.iter_mut().rev() {
507            let size = pod.layout_child(ctx, &slot_bc);
508            trailing_x -= ACTION_SLOT;
509            pod.set_origin(Point::new(
510                trailing_x + (ACTION_SLOT - size.width) / 2.0,
511                (height - size.height) / 2.0,
512            ));
513        }
514
515        let label_x0 = content_x0 + ACTION_SLOT;
516        let label_w = (trailing_x - label_x0).max(0.0);
517        let label_size = self
518            .label
519            .layout_child(ctx, &BoxConstraints::loose(Size::new(label_w, height)));
520        self.label
521            .set_origin(Point::new(label_x0, (height - label_size.height) / 2.0));
522
523        Size::new(width, height)
524    }
525
526    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
527        // Resolve every themed value up front: the theme borrows `ctx`, and the
528        // motion arms below need it mutably.
529        let (reduce, fill, ink) = {
530            let theme = Theme::from_paint_ctx(ctx);
531            (
532                reduce_motion(theme),
533                theme.map_or(FALLBACK_CONTAINER_HIGH, |t| {
534                    t.scheme().surface_container_high
535                }),
536                theme.map_or(FALLBACK_ON_SURFACE, |t| t.scheme().on_surface),
537            )
538        };
539        // The inset is a *layout* value, so a running spring asks for a
540        // relayout, not a bare repaint — and a reduce-motion snap that
541        // actually moves the value does too, since `layout` already ran this
542        // frame against the pre-snap inset and would otherwise keep painting
543        // (and hit-testing) the resting inset indefinitely. Gating the
544        // animating arm solely on `advance`'s return would miss its
545        // *landing* frame the same way: `AnimationController::advance`
546        // reports `false` on the very pass that snaps the value onto its
547        // target, so without the before/after comparison below the last
548        // inset `layout` ever saw is a hair short of rest.
549        let before = self.expand.value();
550        if reduce {
551            self.expand.snap();
552        } else if self.expand.advance(ctx.frame_time()) {
553            ctx.request_layout();
554            ctx.request_frame();
555        }
556        if self.expand.value() != before {
557            ctx.request_layout();
558        }
559
560        // The pod's hover path is authoritative: correct the latched flag from
561        // it, whatever the event pass last recorded
562        // (`docs/CODE_STANDARDS.md`'s Interaction Semantics).
563        let hovered = ctx.is_hovered() && self.enabled;
564        self.state.set_hovered(hovered);
565
566        let origin = ctx.origin();
567        let pill_origin = Point::new(origin.x + self.pill.x0, origin.y + self.pill.y0);
568        let pill = Rect::from_origin_size(pill_origin, self.pill.size());
569        let radius = pill.height() / 2.0;
570
571        // A disabled bar composites whole at 38% (`Opacity` upstream), so its
572        // chrome and its slot content dim together.
573        let dimmed = !self.enabled;
574        if dimmed {
575            scene.push_layer(pill.origin(), pill.size(), DISABLED_OPACITY);
576        }
577        scene.fill_rounded_rect(pill.origin(), pill.size(), radius, fill);
578        self.state.paint(ctx, scene, pill, radius, ink);
579        self.leading.paint_child(ctx, scene);
580        self.label.paint_child(ctx, scene);
581        for pod in &mut self.trailing {
582            pod.paint_child(ctx, scene);
583        }
584        if dimmed {
585            scene.pop_layer();
586        }
587    }
588
589    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
590        // A broadcast reaches every child unconditionally and is never consumed.
591        if event.is_broadcast() {
592            self.leading.event_child(ctx, event);
593            self.label.event_child(ctx, event);
594            for pod in &mut self.trailing {
595                pod.event_child(ctx, event);
596            }
597            return EventResult::Ignored;
598        }
599        if !self.enabled {
600            // Disabled: the reference's `IgnorePointer` — nothing is armed and
601            // no slot is reachable.
602            return EventResult::Ignored;
603        }
604        // Content first, the container-claims-after-routing rule: the trailing
605        // clear action must win the press it lands on. A captured bar keeps the
606        // pass to itself so a drag cannot reach a slot.
607        if !self.captured {
608            if route_event_single(&mut self.leading, ctx, event) == EventResult::Handled {
609                return EventResult::Handled;
610            }
611            if route_event(&mut self.trailing, ctx, event) == EventResult::Handled {
612                return EventResult::Handled;
613            }
614        }
615
616        let InputEvent::Pointer(p) = event else {
617            return EventResult::Ignored;
618        };
619        match p.phase {
620            PointerPhase::Down => {
621                if !presses(p) || !self.pill.contains(p.position) {
622                    return EventResult::Ignored;
623                }
624                self.state.set_pressed(true);
625                self.captured = true;
626                self.expand.retarget(EXPAND_ACTIVE);
627                ctx.capture_pointer();
628                ctx.request_redraw();
629                EventResult::Handled
630            }
631            PointerPhase::Move => {
632                if !self.captured {
633                    // The hover pass: claim on every qualifying move (a claim
634                    // covers only its own pass) and gate the redraw on the
635                    // latch's changed-return.
636                    let over = self.pill.contains(p.position);
637                    if over {
638                        ctx.claim_hover();
639                    }
640                    if self.state.set_hovered(over) {
641                        ctx.request_redraw();
642                    }
643                    return EventResult::Ignored;
644                }
645                if self.state.set_pressed(self.pill.contains(p.position)) {
646                    ctx.request_redraw();
647                }
648                EventResult::Handled
649            }
650            PointerPhase::Up => {
651                if !self.captured {
652                    return EventResult::Ignored;
653                }
654                let inside = self.pill.contains(p.position);
655                self.state.set_pressed(false);
656                self.captured = false;
657                self.expand.retarget(EXPAND_REST);
658                if inside && let Some(on_tap) = self.on_tap.as_mut() {
659                    on_tap(ctx);
660                }
661                ctx.request_redraw();
662                EventResult::Handled
663            }
664            PointerPhase::Cancel => {
665                if !self.captured {
666                    return EventResult::Ignored;
667                }
668                // A `Cancel` arm clears flags only — never app state.
669                self.state.set_pressed(false);
670                self.captured = false;
671                self.expand.retarget(EXPAND_REST);
672                ctx.request_redraw();
673                EventResult::Handled
674            }
675        }
676    }
677
678    fn semantics(&self, ctx: &mut SemanticsCtx) {
679        let label = self.hint.clone();
680        ctx.push_container(
681            Role::SearchInput,
682            |node| {
683                if let Some(label) = &label {
684                    node.set_label(label.as_str());
685                }
686            },
687            |ctx| {
688                self.leading.semantics_child(ctx);
689                self.label.semantics_child(ctx);
690                for pod in &self.trailing {
691                    pod.semantics_child(ctx);
692                }
693            },
694        );
695    }
696
697    visit_children!(leading, label, trailing);
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703    use frust::authoring::text::TextContext;
704    use frust::authoring::{PointerButton, PointerEvent, any as core_any};
705    use frust_core::RenderRoot;
706    use std::any::Any;
707
708    /// The window every layout test uses.
709    const WINDOW: Size = Size::new(400.0, 200.0);
710
711    #[derive(Default)]
712    struct App {
713        query: String,
714        taps: u32,
715    }
716
717    #[derive(Default)]
718    struct Recorder {
719        rrects: Vec<(Point, Size, f64, Color)>,
720        layers: Vec<f32>,
721    }
722
723    impl PaintScene for Recorder {
724        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
725        fn draw_text(&mut self, _o: Point, _t: &str) {}
726        fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
727            self.rrects.push((origin, size, radius, color));
728        }
729        fn push_layer(&mut self, _o: Point, _s: Size, alpha: f32) {
730            self.layers.push(alpha);
731        }
732        fn pop_layer(&mut self) {}
733    }
734
735    fn build<S: 'static>(view: &SearchBarView<S>) -> SearchBarWidget {
736        let mut counter = 0u64;
737        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
738    }
739
740    fn layout(w: &mut SearchBarWidget, bc: &BoxConstraints) -> Size {
741        let mut tcx = TextContext::new();
742        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
743        w.layout(&mut lctx, bc)
744    }
745
746    fn paint(w: &mut SearchBarWidget) -> Recorder {
747        let mut rec = Recorder::default();
748        let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::ZERO);
749        w.paint(&mut pctx, &mut rec);
750        rec
751    }
752
753    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
754        InputEvent::Pointer(PointerEvent {
755            phase,
756            position: Point::new(x, y),
757            button: PointerButton::Primary,
758        })
759    }
760
761    fn dispatch(w: &mut SearchBarWidget, state: &mut App, event: &InputEvent) -> EventResult {
762        let state_any: &mut dyn Any = state;
763        let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
764        w.event(&mut ctx, event)
765    }
766
767    fn bar(query: &str) -> SearchBarView<App> {
768        search_bar(query.to_string(), |s: &mut App, q| s.query = q)
769    }
770
771    // ---- Geometry ----
772
773    #[test]
774    fn the_bar_is_a_56dp_pill_inset_by_the_resting_expand_padding() {
775        let view = bar("").hint("Search");
776        let mut w = build(&view);
777        let size = layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
778
779        assert_eq!(size, Size::new(400.0, SEARCH_BAR_MIN_HEIGHT));
780        let pill = w.pill_rect();
781        assert_eq!(pill.x0, EXPAND_REST);
782        assert_eq!(pill.x1, 400.0 - EXPAND_REST);
783        assert_eq!(pill.height(), SEARCH_BAR_MIN_HEIGHT);
784    }
785
786    #[test]
787    fn a_loose_box_still_floors_the_height_at_the_spec_56dp() {
788        let view = bar("");
789        let mut w = build(&view);
790        let size = layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 500.0)));
791        assert_eq!(size.height, SEARCH_BAR_MIN_HEIGHT);
792    }
793
794    #[test]
795    fn an_unbounded_width_falls_back_to_the_reference_min_width() {
796        let view = bar("");
797        let mut w = build(&view);
798        let size = layout(
799            &mut w,
800            &BoxConstraints::loose(Size::new(f64::INFINITY, 500.0)),
801        );
802        assert_eq!(size.width, SEARCH_BAR_MIN_WIDTH);
803    }
804
805    #[test]
806    fn a_narrow_parent_clamps_rather_than_overflowing() {
807        let view = bar("");
808        let mut w = build(&view);
809        let size = layout(&mut w, &BoxConstraints::tight(Size::new(300.0, 56.0)));
810        assert_eq!(
811            size.width, 300.0,
812            "the reference's ConstrainedBox would overflow; this port clamps"
813        );
814    }
815
816    #[test]
817    fn the_leading_slot_sits_in_a_48dp_target_at_the_pill_start() {
818        let view = bar("");
819        let mut w = build(&view);
820        layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
821        // 24dp icon centred in a 48dp slot that starts one 8dp inset in.
822        let expected_x = EXPAND_REST + BAR_HORIZONTAL_PADDING + (ACTION_SLOT - 24.0) / 2.0;
823        assert_eq!(w.leading.origin().x, expected_x);
824    }
825
826    #[test]
827    fn a_non_empty_query_grows_the_built_in_clear_action_at_the_end_edge() {
828        let empty = bar("");
829        let w = build(&empty);
830        assert!(
831            w.trailing.is_empty(),
832            "an empty query carries no clear action"
833        );
834
835        let filled = bar("pasta");
836        let mut w = build(&filled);
837        assert_eq!(w.trailing.len(), 1, "a non-empty query grows one");
838        layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
839        let slot_x = w.trailing[0].origin().x;
840        assert!(
841            slot_x > 400.0 - EXPAND_REST - BAR_HORIZONTAL_PADDING - ACTION_SLOT - 1.0,
842            "the clear action packs against the end edge"
843        );
844    }
845
846    #[test]
847    fn an_explicit_trailing_action_replaces_the_built_in_clear() {
848        let view = bar("pasta").trailing(leaf_any_app(24.0, 24.0));
849        let w = build(&view);
850        assert_eq!(w.trailing.len(), 1);
851        // The built-in clear would have been mounted too if it were additive;
852        // the reference's `barTrailing ?? [clear]` is a replacement.
853    }
854
855    // ---- Paint ----
856
857    #[test]
858    fn the_pill_paints_the_surface_container_high_role() {
859        let theme = crate::baseline();
860        let view = bar("").hint("Search");
861        let mut w = build(&view);
862        layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
863
864        let mut rec = Recorder::default();
865        let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::ZERO).with_theme(&theme);
866        w.paint(&mut pctx, &mut rec);
867
868        let (origin, size, radius, color) = rec.rrects[0];
869        assert_eq!(origin, Point::new(EXPAND_REST, 0.0));
870        assert_eq!(size, Size::new(400.0 - 2.0 * EXPAND_REST, 56.0));
871        assert_eq!(radius, 28.0, "a stadium pill rounds to half its height");
872        assert_eq!(color, theme.scheme().surface_container_high);
873    }
874
875    #[test]
876    fn an_unthemed_pill_paints_the_m3_baseline_light_container() {
877        let light = crate::baseline().with_brightness(frust::Brightness::Light);
878        let view = bar("");
879        let mut w = build(&view);
880        layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
881        let rec = paint(&mut w);
882        assert_eq!(rec.rrects[0].3, light.scheme().surface_container_high);
883    }
884
885    #[test]
886    fn a_disabled_bar_composites_at_the_reference_disabled_opacity() {
887        let view = bar("").enabled(false);
888        let mut w = build(&view);
889        layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
890        let rec = paint(&mut w);
891        assert_eq!(rec.layers, vec![DISABLED_OPACITY]);
892    }
893
894    // ---- Interaction ----
895
896    #[test]
897    fn a_press_and_release_inside_the_pill_fires_on_tap() {
898        let view = bar("").on_tap(|s: &mut App| s.taps += 1);
899        let mut w = build(&view);
900        layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
901
902        let mut state = App::default();
903        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, 28.0));
904        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 200.0, 28.0));
905        assert_eq!(state.taps, 1);
906    }
907
908    #[test]
909    fn a_release_outside_the_pill_fires_nothing() {
910        let view = bar("").on_tap(|s: &mut App| s.taps += 1);
911        let mut w = build(&view);
912        layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
913
914        let mut state = App::default();
915        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, 28.0));
916        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 200.0, 400.0));
917        assert_eq!(state.taps, 0);
918    }
919
920    #[test]
921    fn a_secondary_press_never_arms_the_bar() {
922        let view = bar("").on_tap(|s: &mut App| s.taps += 1);
923        let mut w = build(&view);
924        layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
925
926        let mut state = App::default();
927        let down = InputEvent::Pointer(PointerEvent {
928            phase: PointerPhase::Down,
929            position: Point::new(200.0, 28.0),
930            button: PointerButton::Secondary,
931        });
932        assert_eq!(
933            dispatch(&mut w, &mut state, &down),
934            EventResult::Ignored,
935            "a context gesture is not an activation"
936        );
937        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 200.0, 28.0));
938        assert_eq!(state.taps, 0);
939    }
940
941    #[test]
942    fn a_disabled_bar_arms_nothing() {
943        let view = bar("").enabled(false).on_tap(|s: &mut App| s.taps += 1);
944        let mut w = build(&view);
945        layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
946
947        let mut state = App::default();
948        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, 28.0));
949        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 200.0, 28.0));
950        assert_eq!(state.taps, 0);
951    }
952
953    #[test]
954    fn a_cancel_clears_the_press_without_firing() {
955        let view = bar("").on_tap(|s: &mut App| s.taps += 1);
956        let mut w = build(&view);
957        layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
958
959        let mut state = App::default();
960        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, 28.0));
961        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 200.0, 28.0));
962        assert_eq!(state.taps, 0);
963        assert!(!w.captured);
964    }
965
966    #[test]
967    fn the_clear_action_reports_an_empty_query_through_on_query_changed() {
968        fn logic(state: &mut App) -> SearchBarView<App> {
969            search_bar(state.query.clone(), |s: &mut App, q| s.query = q).hint("Search")
970        }
971        let mut root: RenderRoot<App, SearchBarView<App>> = RenderRoot::new();
972        let mut state = App {
973            query: "pasta".into(),
974            taps: 0,
975        };
976        root.rebuild(&mut logic, &mut state);
977        let mut tcx = TextContext::new();
978        root.layout_with_text(Size::new(400.0, 56.0), &mut tcx as &mut dyn Any);
979
980        // The clear button's own 48dp slot sits against the end edge.
981        let x = 400.0 - EXPAND_REST - BAR_HORIZONTAL_PADDING - ACTION_SLOT / 2.0;
982        root.event(&mut state, &ev(PointerPhase::Down, x, 28.0));
983        root.event(&mut state, &ev(PointerPhase::Up, x, 28.0));
984        assert_eq!(state.query, "", "the built-in clear empties the query");
985    }
986
987    // ---- Expand spring ----
988
989    #[test]
990    fn the_expand_spring_matches_the_reference_focus_expand_spring() {
991        // `M3ESearchBarTheme.focusExpandSpring` defaults to
992        // `M3EMotion.expressiveSpatialPress` (380 / 0.55).
993        let token = crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS;
994        assert_eq!(EXPAND_SPRING.stiffness, token.stiffness);
995        assert_eq!(EXPAND_SPRING.damping_ratio, token.damping_ratio);
996        assert_eq!(EXPAND_SPRING.mass, 1.0);
997        assert_eq!(
998            EXPAND_ACTIVE,
999            EXPAND_REST / 2.0,
1000            "the reference's _focusedExpandPadding is half the resting inset"
1001        );
1002    }
1003
1004    #[test]
1005    fn a_press_springs_the_inset_toward_the_active_value_and_back() {
1006        let view = bar("").on_tap(|s: &mut App| s.taps += 1);
1007        let mut w = build(&view);
1008        layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
1009        assert_eq!(w.expand_inset(), EXPAND_REST);
1010
1011        let mut state = App::default();
1012        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, 28.0));
1013        // Advance the spring past its settling time.
1014        let mut rec = Recorder::default();
1015        let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::ZERO);
1016        w.paint(&mut pctx, &mut rec);
1017        let mut pctx =
1018            PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::from_nanos(3_000_000_000));
1019        w.paint(&mut pctx, &mut rec);
1020        assert!(
1021            (w.expand_inset() - EXPAND_ACTIVE).abs() < 0.5,
1022            "a held press settles at the active inset, got {}",
1023            w.expand_inset()
1024        );
1025
1026        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 200.0, 28.0));
1027        // The release starts a fresh leg: one paint seeds its clock, the next
1028        // (well past the spring's settling time) reads it settled.
1029        for t in [3_500_000_000u64, 7_000_000_000] {
1030            let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::from_nanos(t));
1031            w.paint(&mut pctx, &mut rec);
1032        }
1033        assert!(
1034            (w.expand_inset() - EXPAND_REST).abs() < 0.5,
1035            "the release springs back to the resting inset, got {}",
1036            w.expand_inset()
1037        );
1038    }
1039
1040    #[test]
1041    fn paint_requests_layout_through_the_press_settle_frame() {
1042        // The landing-frame relayout guard: `AnimationController::advance`
1043        // reports `false` on the very pass that snaps the value onto its
1044        // target, so gating solely on it would leave the last inset (and
1045        // hit-tested pill) `layout` ever saw a hair short of rest. This
1046        // drives a shell-honest frame loop — `layout` runs only on a frame
1047        // the *previous* paint actually asked for one — so a silently-skipped
1048        // landing frame leaves the pill short of the target instead of landing
1049        // on it exactly. See docs/REVIEW_FOCUS.md's layout-skip section.
1050        let view = bar("").on_tap(|s: &mut App| s.taps += 1);
1051        let mut w = build(&view);
1052        layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
1053        assert_eq!(w.pill.x0, EXPAND_REST);
1054
1055        let mut state = App::default();
1056        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, 28.0));
1057
1058        let mut needs_layout = true;
1059        let mut last_x0 = w.pill.x0;
1060        let mut t = 0u64;
1061        let mut frames = 0u32;
1062        loop {
1063            if !needs_layout {
1064                break;
1065            }
1066            layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
1067            last_x0 = w.pill.x0;
1068            let mut rec = Recorder::default();
1069            let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::from_nanos(t));
1070            w.paint(&mut pctx, &mut rec);
1071            needs_layout = pctx.needs_layout();
1072            frames += 1;
1073            assert!(
1074                frames < 600,
1075                "the press spring should settle well inside 600 frames"
1076            );
1077            t += 16_000_000;
1078        }
1079        assert!(frames > 1, "the press spring spans more than one frame");
1080        assert!(
1081            (last_x0 - EXPAND_ACTIVE).abs() < 1e-9,
1082            "the last layout a shell-honest driver runs lands exactly on the active inset, got {last_x0}"
1083        );
1084    }
1085
1086    #[test]
1087    fn reduce_motion_snaps_the_inset_with_no_ramp() {
1088        let theme = crate::baseline();
1089        let mut theme = theme;
1090        theme.motion.reduce_motion = true;
1091        let view = bar("").on_tap(|s: &mut App| s.taps += 1);
1092        let mut w = build(&view);
1093        layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
1094        assert_eq!(w.pill.x0, EXPAND_REST, "starts at the resting inset");
1095
1096        let mut state = App::default();
1097        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 200.0, 28.0));
1098        let mut rec = Recorder::default();
1099        let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::ZERO).with_theme(&theme);
1100        w.paint(&mut pctx, &mut rec);
1101        assert_eq!(w.expand_inset(), EXPAND_ACTIVE);
1102        assert!(
1103            pctx.needs_layout(),
1104            "the snap moved a layout-relevant value (the pill inset), so it must request a relayout"
1105        );
1106
1107        // Regression guard: the bug snapped `self.expand`'s live value but
1108        // requested no relayout, so `self.pill` — the *painted* rect and hit
1109        // region, computed only in `layout` — kept the resting inset
1110        // indefinitely even though `expand_inset()` above already read the
1111        // focused one. Run the relayout a real shell would run on seeing
1112        // `needs_layout` and check the pill itself reaches it.
1113        layout(&mut w, &BoxConstraints::tight(Size::new(400.0, 56.0)));
1114        assert_eq!(
1115            w.pill.x0, EXPAND_ACTIVE,
1116            "the painted/hit-tested pill reaches the snapped inset after the requested relayout"
1117        );
1118    }
1119
1120    // ---- Semantics ----
1121
1122    #[test]
1123    fn semantics_is_a_search_input_labelled_by_the_hint() {
1124        fn logic(_s: &mut App) -> SearchBarView<App> {
1125            search_bar("", |_: &mut App, _| {}).hint("Search recipes")
1126        }
1127        let mut root: RenderRoot<App, SearchBarView<App>> = RenderRoot::new();
1128        let mut state = App::default();
1129        root.rebuild(&mut logic, &mut state);
1130        let mut tcx = TextContext::new();
1131        root.layout_with_text(Size::new(400.0, 56.0), &mut tcx as &mut dyn Any);
1132
1133        let update = root.semantics();
1134        let (_, node) = update
1135            .nodes
1136            .iter()
1137            .find(|(_, n)| n.role() == Role::SearchInput)
1138            .expect("a Role::SearchInput node is contributed");
1139        assert_eq!(node.label(), Some("Search recipes"));
1140    }
1141
1142    /// An `App`-stated fixed leaf, for the explicit-trailing test (the shared
1143    /// `leaf_any` fixture is `AnyView<()>`).
1144    fn leaf_any_app(w: f64, h: f64) -> AnyView<App> {
1145        core_any::<App, _>(FixedLeaf {
1146            size: Size::new(w, h),
1147        })
1148    }
1149    struct FixedLeaf {
1150        size: Size,
1151    }
1152    struct FixedLeafW {
1153        size: Size,
1154    }
1155    impl View<App> for FixedLeaf {
1156        type Element = FixedLeafW;
1157        fn build(&self, _c: &mut BuildCtx<'_>) -> FixedLeafW {
1158            FixedLeafW { size: self.size }
1159        }
1160        fn rebuild(&self, _p: &Self, _e: &mut FixedLeafW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1161            ChangeFlags::NONE
1162        }
1163    }
1164    impl Widget for FixedLeafW {
1165        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1166            bc.constrain(self.size)
1167        }
1168        fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
1169    }
1170}