Skip to main content

frust_material/
text_field.rs

1//! The M3 Expressive **text field**: filled and outlined variants, a floating
2//! label, leading/trailing icon slots, supporting text and an error state,
3//! painted as decoration *around* the framework's own [`frust::text_input`].
4//!
5//! Ported from `material_3_expressive` v1.0.8 (MIT, © 2026 Paa Developments)'s
6//! `lib/components/text_fields/` — `m3e_text_fields.dart` (`M3ETextField`),
7//! `styles/m3e_text_field_theme.dart` (`M3ETextFieldTheme`'s default metrics)
8//! and `enums/m3e_text_field_variant.dart`. Upstream:
9//! <https://github.com/paadevelopments/material_3_expressive>. Porting
10//! decisions that depart from the Dart source are called out inline below
11//! (disabled treatment, the container fill, the label crossfade).
12//!
13//! # Wrapping the baseline, not forking it
14//!
15//! The editable is the framework's own field: this widget holds a
16//! [`ChildPod`] carrying a [`frust::TextInputView`] and paints Material's
17//! decoration around it. Every editing concern — the text editor, focus/IME
18//! publication, the caret blink, selection, the controlled-value reconcile —
19//! stays the baseline's, reached through its public builder seams only:
20//!
21//! - `border_width(0.0)` + `focus_ring_width(0.0)` — the baseline draws its
22//!   frame as a border-colored rounded rect with the background inset by the
23//!   border width, so a zero width on both leaves only the background fill.
24//!   That fill is opaque, which is why every piece of Material chrome here is
25//!   painted **after** the child rather than under it.
26//! - `corner_radius(RADIUS)` — the extra-small (4dp) M3 corner, baked as a
27//!   constant because the seam is a view-time builder value and no `Theme` is
28//!   threaded into `View::build`.
29//! - `padding(x, y)` — `x` clears the leading icon slot, `y` reserves the
30//!   floated label's band (`TEXT_TOP_WITH_LABEL`) so the label never
31//!   overlaps the edited text. A field with no label passes `0.0`, which
32//!   leaves the baseline's own vertical centering in charge.
33//!
34//! # Focus observation: the paint-pass focus path
35//!
36//! The wrapper never asks the editable whether it is focused, and holds no
37//! focus bookkeeping of its own. It reads [`PaintCtx::has_focus`] — the pod's
38//! recorded focus **path**, seeded by `ChildPod::paint_child` from the render
39//! root's own focus mirror. Its contract, and the limits that follow:
40//!
41//! - **It reports path membership, not identity**: `true` means "this widget
42//!   or a descendant of it holds focus". That is exactly what this widget
43//!   wants (its one focusable descendant is the wrapped editable), and it is
44//!   why no focus callback, `ImeState` inspection or `on_change` side channel
45//!   is needed. A caller who puts a *focusable* view in the
46//!   [`leading`](TextFieldView::leading)/[`trailing`](TextFieldView::trailing)
47//!   slot therefore makes the field read as focused while that slot holds
48//!   focus — the accent and the floated label follow the slot. Icon slots are
49//!   the intended content and are not focusable, so this is a documented
50//!   consequence, not a supported mode.
51//! - **It is a paint-pass read, so chrome converges on the first paint after
52//!   a focus change, never before it.** A focus change already dirties the
53//!   frame, so the lag is at most that one frame; there is no staleness
54//!   beyond it, and — unlike a widget-local `focused` flag — a
55//!   container-routed blur (a tap on a sibling, which never calls this
56//!   widget's `event`) is observed correctly, because the path is
57//!   authoritative and this widget keeps no flag that could disagree with it.
58//! - **A disabled field never reads as focused**, because the baseline
59//!   refuses focus while disabled; the `&& self.enabled` guard here only
60//!   makes that explicit for the frame on which a live field is disabled.
61//!
62//! # Container fill: a documented fidelity gap
63//!
64//! M3's filled variant fills its container with `surfaceContainerHighest`.
65//! This port **does not paint that wash**, and the container fill an app sees
66//! is the baseline's own background — the theme's `surface` role. The
67//! baseline resolves that background itself and exposes no seam to override
68//! or suppress it, so any wash painted here would be covered across the
69//! field's interior; painting it only in the gutters the child's rounded rect
70//! misses would leave a visible two-tone seam instead. The two variants
71//! therefore differ by their stroke — a bottom indicator versus a full
72//! outline — and not by their fill. This is the same class of gap
73//! `frust_shadcn::input` records for its own `dark:bg-input/30` wash.
74//!
75//! # Floating label
76//!
77//! The label floats when the field is focused **or** holds content
78//! (`floated = focused || !value.is_empty()`), animated over
79//! [`MaterialMotion::SHORT_3`] with [`MaterialMotion::STANDARD`] easing —
80//! the Dart source's `M3EMotion.short3`/`standard` pair.
81//!
82//! Both label states are shaped as their own retained runs — body-large at
83//! rest, body-small floated — and the transition crossfades between them
84//! while their shared vertical center travels from the field's middle to the
85//! floated band. The Dart source instead interpolates one `TextStyle`
86//! (`AnimatedDefaultTextStyle`); a crossfade of two pre-shaped runs is used
87//! here because a per-frame interpolated font size would re-shape the label
88//! on every frame of the transition, and text shaping happens in the layout
89//! pass, not the paint pass this animation advances in.
90//!
91//! A field *mounted* with content starts floated rather than ramping up on
92//! first appearance, through a short priming window
93//! (`TextFieldWidget::float_priming`) — the controller cannot be seeded at
94//! an endpoint, so it is driven to one unseen while the target is painted
95//! directly. `reduce_motion` collapses the transition entirely: the fraction
96//! is read straight off the target and no continuation frame is requested.
97//!
98//! # Known limitations
99//!
100//! - **Per-field text alignment.** The wrapped editable is always
101//!   start-aligned regardless of `TextStyle::align`
102//!   (`docs/WIDGETS_ARCHITECTURE.md`'s *Text Widget Alignment*: parley's
103//!   `PlainEditor` exposes no alignment hook). This widget cannot work around
104//!   it, and offers no alignment knob that would imply otherwise.
105//! - **A long value runs under the trailing slot.** The wrapped field spans
106//!   the whole decoration box so its opaque background is the container fill;
107//!   the baseline offers a left inset (`padding`) but no right one, so an
108//!   overflowing single-line value passes beneath a trailing icon.
109//! - **The label is not notched into the outline.** M3 proper cuts the
110//!   outlined variant's stroke where the floated label crosses it; the Dart
111//!   source floats the label *inside* the container for both variants and
112//!   strokes an unbroken border, which is what is ported here.
113//! - **The editable's glyphs use the baseline's default family**, not the
114//!   theme's Roboto Flex: `TextInputView::text_style` is all-or-nothing and
115//!   setting it would also freeze the glyph color, losing the baseline's
116//!   themed `on_surface` resolution (the same trade `frust_shadcn::input`
117//!   documents). The catalog-owned label and supporting runs *do* shape in
118//!   the theme's type scale.
119//! - **Single-line only.** The decoration box is a fixed
120//!   `DECORATION_HEIGHT`, so the Dart source's `maxLines` (and with it the
121//!   baseline's `multiline` mode) is not exposed; neither are its
122//!   `keyboardType`/`textInputAction`/`inputFormatters`/`onTapOutside` knobs.
123//! - **A `Down` on an interactive slot blurs the field.** That is the shared
124//!   container contract (`frust::authoring::route_event`'s blur-on-outside-tap:
125//!   a `Down` that does not re-establish focus on the child it hits clears
126//!   every focused sibling), not something this widget adds or can suppress —
127//!   another reason the slots are meant for plain icons.
128
129use std::rc::Rc;
130
131use frust::authoring::Role;
132use frust::authoring::text::{TextContext, TextLayout, TextStyle};
133use frust::authoring::{
134    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CursorIcon, EventCtx, EventResult,
135    InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
136};
137use frust::{AnimationController, Theme, text_input};
138use kurbo::{Point, RoundedRect, Shape, Size};
139use peniko::{Brush, Color};
140
141use super::interaction::{DISABLED_CONTAINER_OPACITY, DISABLED_CONTENT_OPACITY};
142use crate::tokens::{MaterialDimensions, MaterialMotion};
143
144/// Decoration-box height, in logical px (`M3ETextFieldTheme.minHeight`). The
145/// box is a fixed height here rather than a minimum: the wrapped field is
146/// single-line, so nothing can grow it.
147const DECORATION_HEIGHT: f64 = 56.0;
148/// Horizontal inset from the decoration box's edges to its content
149/// (`M3ETextFieldTheme.horizontalPadding`), in logical px.
150const HPAD: f64 = 16.0;
151/// Edge length of a leading/trailing icon slot, in logical px
152/// (`M3ETextFieldTheme.iconSize`). The slot is reserved at this size whatever
153/// the mounted view measures, so the editable's left inset is known at
154/// `View::build` time (where the baseline's `padding` seam is set).
155const ICON_SIZE: f64 = 24.0;
156/// Gap between an icon slot and the editable (`M3ETextFieldTheme.iconGap`), in
157/// logical px.
158const ICON_GAP: f64 = 12.0;
159/// Top inset of the floated label's band, in logical px
160/// (`M3ETextFieldTheme.labelFloatingTopPadding`).
161const LABEL_FLOAT_TOP: f64 = 8.0;
162/// Top inset of the editable's glyphs while a label is present, in logical px:
163/// [`LABEL_FLOAT_TOP`] plus the 16dp body-small line box the floated label
164/// occupies (m3.material.io — *Components → Text fields → Specs*, filled field
165/// with a populated label; retrieved 2026-08-19). Fed to the baseline's
166/// `padding` seam, which floors the field's own vertical centering at it.
167const TEXT_TOP_WITH_LABEL: f64 = 24.0;
168/// Gap between the decoration box and the supporting text
169/// (`M3ETextFieldTheme.supportingTextPadding.top`), in logical px.
170const SUPPORTING_GAP: f64 = 4.0;
171/// Corner radius of both variants, in logical px — M3's extra-small shape
172/// token (`M3EShapes.radiusExtraSmall`).
173const RADIUS: f64 = MaterialDimensions::RADIUS_EXTRA_SMALL;
174/// Indicator/outline thickness at rest, in logical px.
175const STROKE_REST: f64 = 1.0;
176/// Indicator/outline thickness while focused, in logical px.
177const STROKE_FOCUSED: f64 = 2.0;
178/// Flattening tolerance for the outlined variant's stroked rounded rect —
179/// matching `super::card`'s own outline tolerance.
180const STROKE_TOLERANCE: f64 = 0.1;
181/// Field width used when the incoming constraints are horizontally unbounded —
182/// the baseline field's own fallback, restated here because this widget
183/// resolves its width before handing the child tight constraints.
184const UNBOUNDED_WIDTH: f64 = 200.0;
185
186/// Unthemed fallback for the `primary` role (M3 baseline light `#6750A4`).
187const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
188/// Unthemed fallback for the `error` role (M3 baseline light `#B3261E`).
189const ERROR: Color = Color::from_rgb8(0xB3, 0x26, 0x1E);
190/// Unthemed fallback for the `on_surface` role (M3 baseline light `#1D1B20`).
191const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
192/// Unthemed fallback for the `on_surface_variant` role (M3 baseline light
193/// `#49454F`).
194const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
195/// Unthemed fallback for the `outline` role (M3 baseline light `#79747E`).
196const OUTLINE: Color = Color::from_rgb8(0x79, 0x74, 0x7E);
197
198/// Body-large size/line-height, in logical px — the resting label's type role
199/// when no theme is threaded (M3 `bodyLarge`, 16/24).
200const BODY_LARGE_SIZE: f32 = 16.0;
201/// Body-small size, in logical px — the floated label's and the supporting
202/// text's type role when no theme is threaded (M3 `bodySmall`, 12/16).
203const BODY_SMALL_SIZE: f32 = 12.0;
204
205/// The ink every catalog-owned run is *shaped* with. Never painted: each run
206/// is re-brushed with its resolved state color at paint time, and keeping the
207/// shaping color constant keeps the shape cache from missing on a recolor.
208const SHAPING_INK: Color = Color::BLACK;
209
210/// Which container treatment a field paints
211/// (`M3ETextFieldVariant`).
212#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
213pub enum TextFieldVariant {
214    /// A bottom indicator only, thickening from `STROKE_REST` to
215    /// `STROKE_FOCUSED` on focus. The M3 default.
216    #[default]
217    Filled,
218    /// A full outline at `RADIUS` (4dp), thickening on focus like the
219    /// indicator.
220    Outlined,
221}
222
223/// A view-held, typed text callback (erased by the wrapped baseline field on
224/// build).
225type OnText<State> = Rc<dyn Fn(&mut State, String)>;
226
227/// A declarative M3 text field. See the [module docs](self).
228pub struct TextFieldView<State: 'static> {
229    value: String,
230    label: Option<String>,
231    supporting_text: Option<String>,
232    error_text: Option<String>,
233    leading: Option<AnyView<State>>,
234    trailing: Option<AnyView<State>>,
235    variant: TextFieldVariant,
236    obscured: bool,
237    enabled: bool,
238    on_change: OnText<State>,
239    on_submit: Option<OnText<State>>,
240}
241
242/// Create a controlled M3 text field showing `value`, reporting each edit
243/// through `on_change(state, new_text)`.
244///
245/// Controlled exactly like the baseline field it wraps: the widget never owns
246/// the durable value, and an app that rejects or transforms the requested text
247/// in `on_change` sees its own value win on the next rebuild.
248pub fn text_field<State: 'static, F: Fn(&mut State, String) + 'static>(
249    value: impl Into<String>,
250    on_change: F,
251) -> TextFieldView<State> {
252    TextFieldView {
253        value: value.into(),
254        label: None,
255        supporting_text: None,
256        error_text: None,
257        leading: None,
258        trailing: None,
259        variant: TextFieldVariant::Filled,
260        obscured: false,
261        enabled: true,
262        on_change: Rc::new(on_change),
263        on_submit: None,
264    }
265}
266
267/// PascalCase alias for [`text_field`].
268#[allow(non_snake_case)]
269pub fn TextField<State: 'static, F: Fn(&mut State, String) + 'static>(
270    value: impl Into<String>,
271    on_change: F,
272) -> TextFieldView<State> {
273    text_field(value, on_change)
274}
275
276impl<State: 'static> TextFieldView<State> {
277    /// Set the floating label (see the [module docs](self)' *Floating label*).
278    pub fn label(mut self, label: impl Into<String>) -> Self {
279        self.label = Some(label.into());
280        self
281    }
282
283    /// Set the helper line below the field. An
284    /// [`error_text`](Self::error_text) always replaces it.
285    pub fn supporting_text(mut self, text: impl Into<String>) -> Self {
286        self.supporting_text = Some(text.into());
287        self
288    }
289
290    /// Put the field in its error state: the indicator/outline, the floated
291    /// label and the supporting line all recolor to `error`, and this text
292    /// replaces [`supporting_text`](Self::supporting_text).
293    pub fn error_text(mut self, text: impl Into<String>) -> Self {
294        self.error_text = Some(text.into());
295        self
296    }
297
298    /// Mount a leading view in a `ICON_SIZE` slot at the field's start edge.
299    /// The slot's content owns its own ink and size; the field only reserves
300    /// and positions the slot.
301    pub fn leading(mut self, view: impl View<State>) -> Self {
302        self.leading = Some(AnyView::new(view));
303        self
304    }
305
306    /// Mount a trailing view in a `ICON_SIZE` slot at the field's end edge —
307    /// see [`leading`](Self::leading).
308    pub fn trailing(mut self, view: impl View<State>) -> Self {
309        self.trailing = Some(AnyView::new(view));
310        self
311    }
312
313    /// Choose the container treatment (default [`TextFieldVariant::Filled`]).
314    pub fn variant(mut self, variant: TextFieldVariant) -> Self {
315        self.variant = variant;
316        self
317    }
318
319    /// Mask the rendered glyphs (the baseline field's `obscured` mode, which
320    /// also publishes the secret IME hint).
321    pub fn obscured(mut self, obscured: bool) -> Self {
322        self.obscured = obscured;
323        self
324    }
325
326    /// Set whether the field accepts input (default `true`). A disabled field
327    /// refuses focus and dims both the baseline's content and this widget's
328    /// chrome.
329    pub fn enabled(mut self, enabled: bool) -> Self {
330        self.enabled = enabled;
331        self
332    }
333
334    /// Set the Enter handler (the baseline field's `on_submit`).
335    pub fn on_submit<F: Fn(&mut State, String) + 'static>(mut self, on_submit: F) -> Self {
336        self.on_submit = Some(Rc::new(on_submit));
337        self
338    }
339
340    /// Whether an error text was set — the reference's `hasError`.
341    fn has_error(&self) -> bool {
342        self.error_text.is_some()
343    }
344
345    /// The supporting line actually shown: the error text outranks the helper.
346    fn supporting_line(&self) -> Option<&str> {
347        self.error_text
348            .as_deref()
349            .or(self.supporting_text.as_deref())
350    }
351
352    /// Left inset of the editable's glyphs (and of the label), in logical px.
353    fn text_left(&self) -> f64 {
354        if self.leading.is_some() {
355            HPAD + ICON_SIZE + ICON_GAP
356        } else {
357            HPAD
358        }
359    }
360
361    /// Top inset of the editable's glyphs, in logical px. `0.0` with no label,
362    /// which leaves the baseline's own vertical centering in charge.
363    fn text_top(&self) -> f64 {
364        if self.label.is_some() {
365            TEXT_TOP_WITH_LABEL
366        } else {
367            0.0
368        }
369    }
370
371    /// The presence-of-slots shape; a change forces a full child rebuild.
372    fn shape(&self) -> (bool, bool) {
373        (self.leading.is_some(), self.trailing.is_some())
374    }
375
376    /// The wrapped baseline field, with its own chrome suppressed (see the
377    /// [module docs](self)).
378    // erasure: keep feeds a ChildPod: build_child/rebuild_child/teardown_child take &AnyView
379    fn field_view(&self) -> AnyView<State> {
380        let on_change = self.on_change.clone();
381        let mut field = text_input(self.value.clone(), move |state: &mut State, text| {
382            on_change(state, text)
383        })
384        .enabled(self.enabled)
385        .obscured(self.obscured)
386        .padding(self.text_left(), self.text_top())
387        .border_width(0.0)
388        .focus_ring_width(0.0)
389        .corner_radius(RADIUS);
390        if let Some(on_submit) = self.on_submit.clone() {
391            field = field.on_submit(move |state: &mut State, text| on_submit(state, text));
392        }
393        any(field)
394    }
395}
396
397/// A cached, lazily-shaped text run whose ink is applied at paint time.
398///
399/// The catalog owns its label and supporting runs rather than nesting
400/// `frust::text` children because both need a paint-time ink (the float
401/// crossfade's alpha, the focus/error accent) that a baked-at-layout color
402/// could only follow a relayout behind.
403struct Run {
404    content: String,
405    layout: Option<TextLayout>,
406    shaped_style: Option<TextStyle>,
407}
408
409impl Run {
410    /// A run holding `content`, unshaped until the first [`layout`](Self::layout).
411    fn new(content: impl Into<String>) -> Self {
412        Self {
413            content: content.into(),
414            layout: None,
415            shaped_style: None,
416        }
417    }
418
419    /// Replace the text, dropping the cached layout only if it actually
420    /// changed.
421    fn set_content(&mut self, content: &str) {
422        if self.content != content {
423            self.content = content.to_string();
424            self.layout = None;
425        }
426    }
427
428    /// The text this run holds — the accessible name/description a
429    /// `semantics` pass reports, without a second copy of the string.
430    fn content(&self) -> &str {
431        &self.content
432    }
433
434    /// The measured size of the last shaped run (`ZERO` before the first
435    /// layout).
436    fn size(&self) -> Size {
437        self.layout.as_ref().map_or(Size::ZERO, |l| l.size())
438    }
439
440    /// Shape (or reuse) the run in `style` and return its measured size.
441    fn layout(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
442        if let Some(cached) = &self.layout
443            && self.shaped_style.as_ref() == Some(style)
444        {
445            return cached.size();
446        }
447        let text_ctx = ctx.text_context::<TextContext>();
448        let laid = text_ctx.layout(&self.content, style, None);
449        let size = laid.size();
450        self.layout = Some(laid);
451        self.shaped_style = Some(style.clone());
452        size
453    }
454
455    /// Paint the run at `origin` in `color`, overriding the [`SHAPING_INK`] it
456    /// was shaped with. A run that has never been laid out paints nothing.
457    fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
458        let Some(layout) = &self.layout else {
459            return;
460        };
461        for mut run in layout.to_scene_runs(origin) {
462            run.brush = Brush::Solid(color);
463            scene.draw_glyph_run(run);
464        }
465    }
466}
467
468/// The body-large type role the resting label shapes in. Themed:
469/// `Theme::type_scale.body_large`. Unthemed: [`BODY_LARGE_SIZE`] at the
470/// default family.
471fn body_large(theme: Option<&Theme>) -> TextStyle {
472    let mut style = match theme {
473        Some(theme) => theme.type_scale.body_large.clone(),
474        None => TextStyle::new(BODY_LARGE_SIZE, SHAPING_INK),
475    };
476    style.color = SHAPING_INK;
477    style
478}
479
480/// The body-small type role the floated label and the supporting line shape
481/// in — see [`body_large`].
482fn body_small(theme: Option<&Theme>) -> TextStyle {
483    let mut style = match theme {
484        Some(theme) => theme.type_scale.body_small.clone(),
485        None => TextStyle::new(BODY_SMALL_SIZE, SHAPING_INK),
486    };
487    style.color = SHAPING_INK;
488    style
489}
490
491/// The resolved decoration inks for one paint pass.
492#[derive(Clone, Copy, Debug, PartialEq)]
493struct Colors {
494    /// The bottom indicator (filled) or the outline (outlined).
495    stroke: Color,
496    /// The label in its floated state.
497    label_float: Color,
498    /// The label in its resting state.
499    label_rest: Color,
500    /// The supporting line below the field.
501    supporting: Color,
502}
503
504/// Resolve every decoration ink for one pass.
505///
506/// Enabled follows the Dart source: the accent is `error` in the error state
507/// and `primary` otherwise; the stroke takes the accent while focused or in
508/// error and the variant's idle role (`outline` / `onSurfaceVariant`)
509/// otherwise; a floated label takes the accent and a resting one
510/// `onSurfaceVariant`.
511///
512/// Disabled deliberately **departs** from the source, which dims only the
513/// accent (and so leaves a disabled field's idle outline at full strength).
514/// M3's disabled treatment is applied instead: container-class ink
515/// ([`DISABLED_CONTAINER_OPACITY`], 12%) for the stroke and content-class ink
516/// ([`DISABLED_CONTENT_OPACITY`], 38%) for the label and supporting line, both
517/// over `onSurface` — the same rule the wrapped baseline field dims its own
518/// glyphs by.
519fn resolve_colors(
520    theme: Option<&Theme>,
521    variant: TextFieldVariant,
522    enabled: bool,
523    has_error: bool,
524    focused: bool,
525) -> Colors {
526    let (primary, error, on_surface, on_surface_variant, outline) = match theme {
527        Some(theme) => {
528            let s = theme.scheme();
529            (
530                s.primary,
531                s.error,
532                s.on_surface,
533                s.on_surface_variant,
534                s.outline,
535            )
536        }
537        None => (PRIMARY, ERROR, ON_SURFACE, ON_SURFACE_VARIANT, OUTLINE),
538    };
539    if !enabled {
540        let container = on_surface.multiply_alpha(DISABLED_CONTAINER_OPACITY);
541        let content = on_surface.multiply_alpha(DISABLED_CONTENT_OPACITY);
542        return Colors {
543            stroke: container,
544            label_float: content,
545            label_rest: content,
546            supporting: content,
547        };
548    }
549    let accent = if has_error { error } else { primary };
550    let idle = match variant {
551        TextFieldVariant::Filled => on_surface_variant,
552        TextFieldVariant::Outlined => outline,
553    };
554    Colors {
555        stroke: if focused || has_error { accent } else { idle },
556        label_float: accent,
557        label_rest: on_surface_variant,
558        supporting: if has_error { error } else { on_surface_variant },
559    }
560}
561
562/// Which entry of [`TextFieldWidget::children`] each slot occupies. The
563/// wrapped field is always present; the icon slots are optional. Storing every
564/// pod in one `Vec` lets the widget route events through
565/// [`frust::authoring::route_event`] (topmost-first, so an interactive icon
566/// wins over the field beneath it) and recurse uniformly for semantics.
567#[derive(Clone, Copy)]
568struct Slots {
569    field: usize,
570    leading: Option<usize>,
571    trailing: Option<usize>,
572}
573
574/// The retained widget for a [`TextFieldView`].
575pub struct TextFieldWidget {
576    children: Vec<ChildPod>,
577    slots: Slots,
578    variant: TextFieldVariant,
579    enabled: bool,
580    has_error: bool,
581    /// Whether the app-confirmed value is empty — half of the float condition
582    /// (see the [module docs](self)).
583    value_empty: bool,
584    /// Left inset of the label runs, matching the editable's own.
585    text_left: f64,
586    /// The label at rest (body-large) and floated (body-small); both `None`
587    /// when the field carries no label.
588    label_rest: Option<Run>,
589    label_float: Option<Run>,
590    /// The supporting line actually shown (error text outranking helper).
591    supporting: Option<Run>,
592    /// Height the supporting line adds below the decoration box, gap included.
593    supporting_height: f64,
594    /// Drives the label's `0.0` (resting) .. `1.0` (floated) fraction.
595    float: AnimationController,
596    /// The float state the controller is currently driving toward.
597    float_target: bool,
598    /// The mount-priming window. [`AnimationController`] always starts at
599    /// `0.0` and its duration cannot be changed after construction, so a field
600    /// *built* with content would otherwise fade its label up over
601    /// [`MaterialMotion::SHORT_3`] on first appearance. While this is set the
602    /// painted fraction is read straight off the target and the controller is
603    /// left to catch up unseen; it clears the first time the controller
604    /// settles, from which point every transition is the controller's.
605    float_priming: bool,
606}
607
608/// Build the ordered pod window `[field, leading?, trailing?]` and its
609/// [`Slots`] index map. The field goes first because it is painted first
610/// (bottom of the z-order), which is the order `route_event` hit-tests in
611/// reverse.
612fn build_children<State: 'static>(
613    view: &TextFieldView<State>,
614    ctx: &mut BuildCtx<'_>,
615) -> (Vec<ChildPod>, Slots) {
616    let mut children = vec![frust::authoring::build_child(&view.field_view(), ctx)];
617    let field = children.len() - 1;
618    let leading = view.leading.as_ref().map(|v| {
619        children.push(frust::authoring::build_child(v, ctx));
620        children.len() - 1
621    });
622    let trailing = view.trailing.as_ref().map(|v| {
623        children.push(frust::authoring::build_child(v, ctx));
624        children.len() - 1
625    });
626    (
627        children,
628        Slots {
629            field,
630            leading,
631            trailing,
632        },
633    )
634}
635
636/// Tear down every pod through the view it was built from, per the [`Slots`]
637/// index map.
638fn teardown_children<State: 'static>(
639    view: &TextFieldView<State>,
640    slots: Slots,
641    children: &mut [ChildPod],
642    ctx: &mut BuildCtx<'_>,
643) {
644    for (index, pod) in children.iter_mut().enumerate() {
645        if index == slots.field {
646            frust::authoring::teardown_child(&view.field_view(), pod, ctx);
647        } else if Some(index) == slots.leading
648            && let Some(v) = view.leading.as_ref()
649        {
650            frust::authoring::teardown_child(v, pod, ctx);
651        } else if Some(index) == slots.trailing
652            && let Some(v) = view.trailing.as_ref()
653        {
654            frust::authoring::teardown_child(v, pod, ctx);
655        }
656    }
657}
658
659/// Reconcile an optional catalog-owned run against the text it should hold,
660/// reporting whether the widget needs a fresh layout.
661fn sync_run(slot: &mut Option<Run>, content: Option<&str>) -> bool {
662    match (slot.as_mut(), content) {
663        (Some(run), Some(text)) => {
664            if run.content() == text {
665                return false;
666            }
667            run.set_content(text);
668            true
669        }
670        (None, Some(text)) => {
671            *slot = Some(Run::new(text));
672            true
673        }
674        (Some(_), None) => {
675            *slot = None;
676            true
677        }
678        (None, None) => false,
679    }
680}
681
682fn inside(pos: Point, size: Size) -> bool {
683    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
684}
685
686impl<State: 'static> View<State> for TextFieldView<State> {
687    type Element = TextFieldWidget;
688
689    fn build(&self, ctx: &mut BuildCtx<'_>) -> TextFieldWidget {
690        let (children, slots) = build_children(self, ctx);
691        let float_target = !self.value.is_empty();
692        // The ramp started here is deliberately never *displayed* — it only
693        // drives the controller to the endpoint a field mounted with content
694        // already belongs at, which `float_priming` shows in the meantime.
695        let mut float =
696            AnimationController::new(MaterialMotion::SHORT_3).with_curve(MaterialMotion::STANDARD);
697        if float_target {
698            float.forward();
699        }
700        TextFieldWidget {
701            children,
702            slots,
703            variant: self.variant,
704            enabled: self.enabled,
705            has_error: self.has_error(),
706            value_empty: self.value.is_empty(),
707            text_left: self.text_left(),
708            label_rest: self.label.as_deref().map(Run::new),
709            label_float: self.label.as_deref().map(Run::new),
710            supporting: self.supporting_line().map(Run::new),
711            supporting_height: 0.0,
712            float,
713            float_target,
714            float_priming: true,
715        }
716    }
717
718    fn rebuild(
719        &self,
720        prev: &Self,
721        element: &mut TextFieldWidget,
722        ctx: &mut BuildCtx<'_>,
723    ) -> ChangeFlags {
724        let mut flags = ChangeFlags::NONE;
725
726        if self.shape() != prev.shape() {
727            // Slot presence changed: tear the whole pod set down (against the
728            // previous view, whose shape the live pods still match) and rebuild.
729            teardown_children(prev, element.slots, &mut element.children, ctx);
730            let (children, slots) = build_children(self, ctx);
731            element.children = children;
732            element.slots = slots;
733            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
734        } else {
735            flags |= frust::authoring::rebuild_child(
736                &prev.field_view(),
737                &self.field_view(),
738                &mut element.children[element.slots.field],
739                ctx,
740            );
741            if let (Some(pv), Some(nv), Some(idx)) = (
742                prev.leading.as_ref(),
743                self.leading.as_ref(),
744                element.slots.leading,
745            ) {
746                flags |= frust::authoring::rebuild_child(pv, nv, &mut element.children[idx], ctx);
747            }
748            if let (Some(pv), Some(nv), Some(idx)) = (
749                prev.trailing.as_ref(),
750                self.trailing.as_ref(),
751                element.slots.trailing,
752            ) {
753                flags |= frust::authoring::rebuild_child(pv, nv, &mut element.children[idx], ctx);
754            }
755        }
756
757        // Non-short-circuiting `|` on purpose: every run must reconcile, and
758        // any one of them changing needs the same relayout.
759        if sync_run(&mut element.label_rest, self.label.as_deref())
760            | sync_run(&mut element.label_float, self.label.as_deref())
761            | sync_run(&mut element.supporting, self.supporting_line())
762        {
763            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
764        }
765        if element.text_left != self.text_left() {
766            element.text_left = self.text_left();
767            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
768        }
769        if element.variant != self.variant {
770            element.variant = self.variant;
771            flags |= ChangeFlags::PAINT;
772        }
773        if element.enabled != self.enabled {
774            element.enabled = self.enabled;
775            flags |= ChangeFlags::PAINT;
776        }
777        if element.has_error != self.has_error() {
778            element.has_error = self.has_error();
779            flags |= ChangeFlags::PAINT;
780        }
781        if element.value_empty != self.value.is_empty() {
782            element.value_empty = self.value.is_empty();
783            // The float retargets in `paint`, the one pass that can also read
784            // the focus path the other half of the condition comes from.
785            flags |= ChangeFlags::PAINT;
786        }
787        flags
788    }
789
790    fn teardown(&self, element: &mut TextFieldWidget, ctx: &mut BuildCtx<'_>) {
791        teardown_children(self, element.slots, &mut element.children, ctx);
792    }
793}
794
795impl Widget for TextFieldWidget {
796    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
797        let width = if bc.max().width.is_finite() {
798            bc.max().width
799        } else {
800            UNBOUNDED_WIDTH
801        };
802
803        // Resolve the type roles into owned styles first: the theme borrow has
804        // to end before anything takes `ctx` mutably (shaping, child layout).
805        let (rest_style, small_style) = {
806            let theme = Theme::from_layout_ctx(ctx);
807            (body_large(theme), body_small(theme))
808        };
809        if let Some(run) = &mut self.label_rest {
810            run.layout(ctx, &rest_style);
811        }
812        if let Some(run) = &mut self.label_float {
813            run.layout(ctx, &small_style);
814        }
815        self.supporting_height = match &mut self.supporting {
816            Some(run) => SUPPORTING_GAP + run.layout(ctx, &small_style).height,
817            None => 0.0,
818        };
819
820        // Tight constraints are what make the wrapped field the container: its
821        // opaque background covers the whole decoration box, so no seam can
822        // show between it and anything this widget paints.
823        let field = self.slots.field;
824        self.children[field].layout_child(
825            ctx,
826            &BoxConstraints::tight(Size::new(width, DECORATION_HEIGHT)),
827        );
828        self.children[field].set_origin(Point::ORIGIN);
829
830        let slot_bc = BoxConstraints::new(Size::ZERO, Size::new(ICON_SIZE, ICON_SIZE));
831        if let Some(idx) = self.slots.leading {
832            let size = self.children[idx].layout_child(ctx, &slot_bc);
833            self.children[idx].set_origin(icon_origin(HPAD, size));
834        }
835        if let Some(idx) = self.slots.trailing {
836            let size = self.children[idx].layout_child(ctx, &slot_bc);
837            self.children[idx].set_origin(icon_origin(width - HPAD - ICON_SIZE, size));
838        }
839
840        bc.constrain(Size::new(width, DECORATION_HEIGHT + self.supporting_height))
841    }
842
843    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
844        // The pod's focus path is authoritative (a container-routed blur never
845        // reaches this widget's `event`) — see the module docs.
846        let focused = ctx.has_focus() && self.enabled;
847        let frame_time = ctx.frame_time();
848        let origin = ctx.origin();
849        let width = ctx.size().width;
850        // Theme reads happen up front and only owned values cross the child's
851        // paint, which borrows the `PaintCtx` mutably.
852        let (colors, reduce_motion) = {
853            let theme = Theme::from_paint_ctx(ctx);
854            (
855                resolve_colors(theme, self.variant, self.enabled, self.has_error, focused),
856                theme.is_some_and(|t| t.motion.reduce_motion),
857            )
858        };
859
860        let floated = focused || !self.value_empty;
861        if self.float_target != floated {
862            self.float_target = floated;
863            if floated {
864                self.float.forward();
865            } else {
866                self.float.reverse();
867            }
868        }
869        let float_t = if reduce_motion {
870            // Collapsed: the fraction is read straight off the target and no
871            // continuation frame is asked for. The controller is left alone, so
872            // it resumes (and converges) if `reduce_motion` is turned back off.
873            if floated { 1.0 } else { 0.0 }
874        } else {
875            if self.float.advance(frame_time) {
876                ctx.request_frame();
877            }
878            if self.float_priming {
879                // Still catching up to the mount-time endpoint: show the
880                // settled value, and hand the controller over once it agrees.
881                if !self.float.is_animating() {
882                    self.float_priming = false;
883                }
884                if floated { 1.0 } else { 0.0 }
885            } else {
886                self.float.value_clamped()
887            }
888        };
889
890        // The wrapped field first: its opaque background is this field's
891        // container fill, so every piece of chrome below is painted over it.
892        self.children[self.slots.field].paint_child(ctx, scene);
893        if let Some(idx) = self.slots.leading {
894            self.children[idx].paint_child(ctx, scene);
895        }
896        if let Some(idx) = self.slots.trailing {
897            self.children[idx].paint_child(ctx, scene);
898        }
899
900        let stroke_w = if focused { STROKE_FOCUSED } else { STROKE_REST };
901        match self.variant {
902            TextFieldVariant::Filled => scene.fill_rect(
903                Point::new(origin.x, origin.y + DECORATION_HEIGHT - stroke_w),
904                Size::new(width, stroke_w),
905                colors.stroke,
906            ),
907            TextFieldVariant::Outlined => {
908                // Inset by half the stroke so the line paints fully inside the
909                // decoration box (a stroke is centered on its path) — the same
910                // treatment `super::card`'s outlined variant uses.
911                let half = stroke_w / 2.0;
912                let rr = RoundedRect::new(
913                    half,
914                    half,
915                    width - half,
916                    DECORATION_HEIGHT - half,
917                    (RADIUS - half).max(0.0),
918                );
919                scene.stroke_path(
920                    origin,
921                    &rr.to_path(STROKE_TOLERANCE),
922                    stroke_w,
923                    &Brush::Solid(colors.stroke),
924                );
925            }
926        }
927
928        if let (Some(rest), Some(float)) = (&self.label_rest, &self.label_float) {
929            let x = origin.x + self.text_left;
930            let (rest_h, float_h) = (rest.size().height, float.size().height);
931            let rest_center = origin.y + DECORATION_HEIGHT / 2.0;
932            let float_center = origin.y + LABEL_FLOAT_TOP + float_h / 2.0;
933            let center = rest_center + (float_center - rest_center) * float_t;
934            if float_t < 1.0 {
935                rest.paint(
936                    Point::new(x, center - rest_h / 2.0),
937                    colors.label_rest.multiply_alpha(1.0 - float_t as f32),
938                    scene,
939                );
940            }
941            if float_t > 0.0 {
942                float.paint(
943                    Point::new(x, center - float_h / 2.0),
944                    colors.label_float.multiply_alpha(float_t as f32),
945                    scene,
946                );
947            }
948        }
949
950        if let Some(run) = &self.supporting {
951            run.paint(
952                Point::new(
953                    origin.x + HPAD,
954                    origin.y + DECORATION_HEIGHT + SUPPORTING_GAP,
955                ),
956                colors.supporting,
957                scene,
958            );
959        }
960    }
961
962    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
963        let result = frust::authoring::route_event(&mut self.children, ctx, event);
964        // The cursor is asked for from the uncaptured `Move` arm only, and
965        // after routing so this widget's shape wins over a slot's: the I-beam
966        // over an editable decoration box, `NotAllowed` when disabled.
967        if let InputEvent::Pointer(p) = event
968            && matches!(p.phase, PointerPhase::Move)
969            && inside(p.position, Size::new(ctx.size().width, DECORATION_HEIGHT))
970        {
971            ctx.set_cursor(if self.enabled {
972                CursorIcon::Text
973            } else {
974                CursorIcon::NotAllowed
975            });
976        }
977        result
978    }
979
980    fn semantics(&self, ctx: &mut SemanticsCtx) {
981        // A labelled group around the wrapped editable's own node: the label
982        // and the supporting/error line are this widget's text, not the
983        // baseline field's, so nothing else would report them.
984        ctx.push_container(
985            Role::Group,
986            |node| {
987                if let Some(label) = &self.label_rest {
988                    node.set_label(label.content());
989                }
990                if let Some(supporting) = &self.supporting {
991                    node.set_description(supporting.content());
992                }
993            },
994            |ctx| {
995                for pod in &self.children {
996                    pod.semantics_child(ctx);
997                }
998            },
999        );
1000    }
1001
1002    frust::authoring::visit_children!(children);
1003}
1004
1005/// The origin of an icon slot whose reserved `ICON_SIZE` box starts at `x`:
1006/// the mounted view is centered in the slot, and in the decoration box
1007/// vertically.
1008fn icon_origin(x: f64, measured: Size) -> Point {
1009    Point::new(
1010        x + ((ICON_SIZE - measured.width) / 2.0).max(0.0),
1011        ((DECORATION_HEIGHT - measured.height) / 2.0).max(0.0),
1012    )
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017    use super::*;
1018    use frust::authoring::scene::GlyphRun;
1019    use frust::authoring::{BezPath, Key, KeyEvent, Modifiers, PointerButton, PointerEvent, Rect};
1020    use frust::{Brightness, FrameTime};
1021    use frust_core::RenderRoot;
1022    use std::any::Any;
1023
1024    /// The window every harness lays out in.
1025    const WINDOW: Size = Size::new(320.0, 160.0);
1026    /// A point inside the decoration box, clear of both icon slots.
1027    const IN_FIELD: Point = Point::new(120.0, 28.0);
1028
1029    /// A recording `PaintScene` capturing everything these tests assert on: the
1030    /// rounded-rect fills (the wrapped field's background), the plain rects
1031    /// (the filled variant's indicator), the stroked paths (the outlined
1032    /// variant's outline) and the glyph runs (label + supporting text).
1033    #[derive(Default)]
1034    struct Recorder {
1035        rrects: Vec<(Point, Size, f64, Color)>,
1036        rects: Vec<(Point, Size, Color)>,
1037        strokes: Vec<(Rect, f64, Color)>,
1038        runs: Vec<(f32, Point, Color)>,
1039        /// Index into `runs` at which this widget's *own* text begins.
1040        ///
1041        /// The wrapped field's glyphs share the label's body-large size, so a
1042        /// size filter alone cannot tell them apart. Paint order can: the
1043        /// indicator/outline is the last rect-or-stroke of the pass and is
1044        /// painted between the wrapped field's own paint and the label, so
1045        /// every run recorded after the final rect/stroke is chrome.
1046        chrome_start: usize,
1047    }
1048
1049    impl PaintScene for Recorder {
1050        fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
1051            self.rects.push((origin, size, color));
1052            self.chrome_start = self.runs.len();
1053        }
1054        fn draw_text(&mut self, _origin: Point, _text: &str) {}
1055        fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
1056            self.rrects.push((origin, size, radius, color));
1057        }
1058        fn stroke_path(&mut self, origin: Point, path: &BezPath, width: f64, brush: &Brush) {
1059            let bbox = path.bounding_box() + origin.to_vec2();
1060            let color = match brush {
1061                Brush::Solid(c) => *c,
1062                _ => Color::TRANSPARENT,
1063            };
1064            self.strokes.push((bbox, width, color));
1065            self.chrome_start = self.runs.len();
1066        }
1067        fn draw_glyph_run(&mut self, run: GlyphRun) {
1068            let color = match run.brush {
1069                Brush::Solid(c) => c,
1070                _ => Color::TRANSPARENT,
1071            };
1072            let t = run.transform.translation();
1073            self.runs.push((run.font_size, Point::new(t.x, t.y), color));
1074        }
1075    }
1076
1077    impl Recorder {
1078        /// The indicator rect of a filled field — the last rect of the pass
1079        /// (the wrapped field's caret is a rect too, painted before it).
1080        fn indicator(&self) -> (Point, Size, Color) {
1081            *self
1082                .rects
1083                .last()
1084                .expect("a filled field paints an indicator")
1085        }
1086
1087        /// The outline stroke of an outlined field.
1088        fn outline(&self) -> (Rect, f64, Color) {
1089            *self
1090                .strokes
1091                .last()
1092                .expect("an outlined field strokes an outline")
1093        }
1094
1095        /// Every *chrome* glyph run recorded at `font_size`, in paint order —
1096        /// see [`Recorder::chrome_start`].
1097        fn runs_at(&self, font_size: f32) -> Vec<(Point, Color)> {
1098            self.runs[self.chrome_start..]
1099                .iter()
1100                .filter(|(size, _, _)| (*size - font_size).abs() < 1e-3)
1101                .map(|(_, origin, color)| (*origin, *color))
1102                .collect()
1103        }
1104    }
1105
1106    /// A field driven through a real `RenderRoot`, which is what makes the
1107    /// focus-path read (`PaintCtx::has_focus`) — and therefore every focused
1108    /// treatment — observable at all.
1109    struct Harness {
1110        root: RenderRoot<String, TextFieldView<String>>,
1111        state: String,
1112        tcx: TextContext,
1113        variant: TextFieldVariant,
1114        label: Option<String>,
1115        supporting: Option<String>,
1116        error: Option<String>,
1117        enabled: bool,
1118    }
1119
1120    impl Harness {
1121        fn new(variant: TextFieldVariant) -> Self {
1122            let mut h = Harness {
1123                root: RenderRoot::new(),
1124                state: String::new(),
1125                tcx: TextContext::new(),
1126                variant,
1127                label: Some("Email".to_string()),
1128                supporting: None,
1129                error: None,
1130                enabled: true,
1131            };
1132            h.pass();
1133            h
1134        }
1135
1136        fn theme(&mut self, brightness: Brightness) -> Theme {
1137            let theme = crate::baseline().with_brightness(brightness);
1138            self.root.set_theme(Box::new(theme.clone()));
1139            self.pass();
1140            theme
1141        }
1142
1143        fn pass(&mut self) {
1144            let (variant, enabled) = (self.variant, self.enabled);
1145            let (label, supporting, error) = (
1146                self.label.clone(),
1147                self.supporting.clone(),
1148                self.error.clone(),
1149            );
1150            let mut logic = move |state: &mut String| {
1151                let mut view = text_field::<String, _>(state.clone(), |s: &mut String, t| *s = t)
1152                    .variant(variant)
1153                    .enabled(enabled);
1154                if let Some(label) = &label {
1155                    view = view.label(label.clone());
1156                }
1157                if let Some(supporting) = &supporting {
1158                    view = view.supporting_text(supporting.clone());
1159                }
1160                if let Some(error) = &error {
1161                    view = view.error_text(error.clone());
1162                }
1163                view
1164            };
1165            self.root.rebuild(&mut logic, &mut self.state);
1166            self.root
1167                .layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
1168        }
1169
1170        fn paint(&mut self) -> Recorder {
1171            self.paint_at(FrameTime::ZERO)
1172        }
1173
1174        fn paint_at(&mut self, now: FrameTime) -> Recorder {
1175            let mut rec = Recorder::default();
1176            self.root.paint(&mut rec, now);
1177            rec
1178        }
1179
1180        fn pointer(&mut self, phase: PointerPhase, at: Point) {
1181            self.root.event(
1182                &mut self.state,
1183                &InputEvent::Pointer(PointerEvent {
1184                    phase,
1185                    position: at,
1186                    button: PointerButton::Primary,
1187                }),
1188            );
1189        }
1190
1191        /// Tap the field, which focuses the wrapped editable.
1192        fn focus(&mut self) {
1193            self.pointer(PointerPhase::Down, IN_FIELD);
1194            self.pointer(PointerPhase::Up, IN_FIELD);
1195        }
1196
1197        /// Blur by tapping below the decoration box (still inside the window,
1198        /// outside every pod).
1199        fn blur(&mut self) {
1200            self.pointer(PointerPhase::Down, Point::new(160.0, 140.0));
1201            self.pointer(PointerPhase::Up, Point::new(160.0, 140.0));
1202        }
1203
1204        fn key(&mut self, ch: char) {
1205            self.root.event(
1206                &mut self.state,
1207                &InputEvent::Key(KeyEvent {
1208                    key: Key::Character(ch.to_string()),
1209                    modifiers: Modifiers::default(),
1210                    repeat: false,
1211                }),
1212            );
1213        }
1214    }
1215
1216    fn secs(s: f64) -> FrameTime {
1217        FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
1218    }
1219
1220    fn build<S: 'static>(view: &TextFieldView<S>) -> TextFieldWidget {
1221        let mut counter = 0u64;
1222        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1223    }
1224
1225    fn layout_widget(w: &mut TextFieldWidget, width: f64) -> Size {
1226        let mut tcx = TextContext::new();
1227        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1228        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(width, 400.0)))
1229    }
1230
1231    // ---- geometry ---------------------------------------------------------
1232
1233    #[test]
1234    fn the_wrapped_field_covers_the_whole_decoration_box() {
1235        let view: TextFieldView<String> = text_field("", |_s: &mut String, _t| {}).label("Email");
1236        let mut w = build(&view);
1237        let size = layout_widget(&mut w, 300.0);
1238        assert_eq!(
1239            size,
1240            Size::new(300.0, DECORATION_HEIGHT),
1241            "no supporting line"
1242        );
1243        let field = &w.children[w.slots.field];
1244        assert_eq!(field.origin(), Point::ORIGIN);
1245        assert_eq!(
1246            field.size(),
1247            Size::new(300.0, DECORATION_HEIGHT),
1248            "tight constraints make the baseline's background the container fill"
1249        );
1250    }
1251
1252    #[test]
1253    fn a_supporting_line_grows_the_widget_below_the_decoration_box() {
1254        let view: TextFieldView<String> =
1255            text_field("", |_s: &mut String, _t| {}).supporting_text("Required");
1256        let mut w = build(&view);
1257        let size = layout_widget(&mut w, 300.0);
1258        assert!(size.height > DECORATION_HEIGHT);
1259        assert_eq!(size.height, DECORATION_HEIGHT + w.supporting_height);
1260        assert!(
1261            w.supporting_height > SUPPORTING_GAP,
1262            "gap plus a shaped line"
1263        );
1264        // The decoration box itself never grows: the wrapped field still spans
1265        // exactly it.
1266        assert_eq!(
1267            w.children[w.slots.field].size(),
1268            Size::new(300.0, DECORATION_HEIGHT)
1269        );
1270    }
1271
1272    #[test]
1273    fn an_unbounded_width_falls_back_to_the_baseline_default() {
1274        let view: TextFieldView<String> = text_field("", |_s: &mut String, _t| {});
1275        let mut w = build(&view);
1276        let mut tcx = TextContext::new();
1277        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1278        let size = w.layout(
1279            &mut lctx,
1280            &BoxConstraints::loose(Size::new(f64::INFINITY, 400.0)),
1281        );
1282        assert_eq!(size.width, UNBOUNDED_WIDTH);
1283    }
1284
1285    #[test]
1286    fn a_leading_slot_insets_the_text_and_both_slots_are_placed_and_centered() {
1287        let plain: TextFieldView<String> = text_field("", |_s: &mut String, _t| {});
1288        assert_eq!(plain.text_left(), HPAD);
1289        assert_eq!(plain.text_top(), 0.0, "no label leaves the field centered");
1290
1291        let view: TextFieldView<String> = text_field("", |_s: &mut String, _t| {})
1292            .label("Email")
1293            .leading(frust::SizedBox(Some(20.0), Some(20.0)))
1294            .trailing(frust::SizedBox(Some(20.0), Some(20.0)));
1295        assert_eq!(view.text_left(), HPAD + ICON_SIZE + ICON_GAP);
1296        assert_eq!(view.text_top(), TEXT_TOP_WITH_LABEL);
1297
1298        let mut w = build(&view);
1299        layout_widget(&mut w, 300.0);
1300        let leading = &w.children[w.slots.leading.expect("leading slot")];
1301        let trailing = &w.children[w.slots.trailing.expect("trailing slot")];
1302        assert_eq!(leading.origin(), Point::new(HPAD + 2.0, 18.0));
1303        assert_eq!(
1304            trailing.origin(),
1305            Point::new(300.0 - HPAD - ICON_SIZE + 2.0, 18.0)
1306        );
1307    }
1308
1309    // ---- variant chrome ---------------------------------------------------
1310
1311    #[test]
1312    fn a_filled_field_paints_an_indicator_and_an_outlined_one_an_outline() {
1313        let mut filled = Harness::new(TextFieldVariant::Filled);
1314        let theme = filled.theme(Brightness::Light);
1315        let rec = filled.paint();
1316        let (origin, size, color) = rec.indicator();
1317        assert_eq!(origin, Point::new(0.0, DECORATION_HEIGHT - STROKE_REST));
1318        assert_eq!(size, Size::new(WINDOW.width, STROKE_REST));
1319        assert_eq!(
1320            color,
1321            theme.scheme().on_surface_variant,
1322            "an idle filled indicator is onSurfaceVariant"
1323        );
1324        assert!(rec.strokes.is_empty(), "the filled variant strokes nothing");
1325        // The wrapped baseline field still paints the container fill.
1326        assert!(
1327            rec.rrects
1328                .iter()
1329                .any(|(_, _, _, c)| *c == theme.scheme().surface),
1330            "the wrapped field painted its background"
1331        );
1332
1333        let mut outlined = Harness::new(TextFieldVariant::Outlined);
1334        let theme = outlined.theme(Brightness::Light);
1335        let rec = outlined.paint();
1336        let (bbox, width, color) = rec.outline();
1337        assert_eq!(width, STROKE_REST);
1338        assert_eq!(
1339            color,
1340            theme.scheme().outline,
1341            "an idle outline is `outline`"
1342        );
1343        assert_eq!(bbox.width(), WINDOW.width - STROKE_REST);
1344        assert_eq!(bbox.height(), DECORATION_HEIGHT - STROKE_REST);
1345        assert!(
1346            rec.rects.is_empty(),
1347            "the outlined variant fills no indicator"
1348        );
1349    }
1350
1351    #[test]
1352    fn focus_thickens_and_accents_both_variants() {
1353        for variant in [TextFieldVariant::Filled, TextFieldVariant::Outlined] {
1354            let mut h = Harness::new(variant);
1355            let theme = h.theme(Brightness::Light);
1356            h.focus();
1357            let rec = h.paint();
1358            let (width, color) = match variant {
1359                TextFieldVariant::Filled => {
1360                    let (origin, size, color) = rec.indicator();
1361                    assert_eq!(origin.y, DECORATION_HEIGHT - STROKE_FOCUSED);
1362                    (size.height, color)
1363                }
1364                TextFieldVariant::Outlined => {
1365                    let (_, width, color) = rec.outline();
1366                    (width, color)
1367                }
1368            };
1369            assert_eq!(width, STROKE_FOCUSED, "{variant:?} thickens on focus");
1370            assert_eq!(
1371                color,
1372                theme.scheme().primary,
1373                "{variant:?} takes the accent"
1374            );
1375        }
1376    }
1377
1378    #[test]
1379    fn an_error_field_accents_the_stroke_even_unfocused() {
1380        for variant in [TextFieldVariant::Filled, TextFieldVariant::Outlined] {
1381            let mut h = Harness::new(variant);
1382            h.error = Some("Enter a valid address".to_string());
1383            let theme = h.theme(Brightness::Light);
1384            let rec = h.paint();
1385            let (width, color) = match variant {
1386                TextFieldVariant::Filled => {
1387                    let (_, size, color) = rec.indicator();
1388                    (size.height, color)
1389                }
1390                TextFieldVariant::Outlined => {
1391                    let (_, width, color) = rec.outline();
1392                    (width, color)
1393                }
1394            };
1395            assert_eq!(color, theme.scheme().error, "{variant:?} strokes `error`");
1396            assert_eq!(
1397                width, STROKE_REST,
1398                "error alone does not thicken the stroke"
1399            );
1400        }
1401    }
1402
1403    #[test]
1404    fn a_disabled_field_dims_container_and_content_and_refuses_focus() {
1405        for variant in [TextFieldVariant::Filled, TextFieldVariant::Outlined] {
1406            let mut h = Harness::new(variant);
1407            h.enabled = false;
1408            h.supporting = Some("Required".to_string());
1409            let theme = h.theme(Brightness::Light);
1410            let on_surface = theme.scheme().on_surface;
1411            let rec = h.paint();
1412            let stroke = match variant {
1413                TextFieldVariant::Filled => rec.indicator().2,
1414                TextFieldVariant::Outlined => rec.outline().2,
1415            };
1416            assert_eq!(
1417                stroke,
1418                on_surface.multiply_alpha(DISABLED_CONTAINER_OPACITY),
1419                "{variant:?} dims its container ink to 12%"
1420            );
1421            let content = on_surface.multiply_alpha(DISABLED_CONTENT_OPACITY);
1422            assert!(
1423                rec.runs.iter().any(|(_, _, c)| *c == content),
1424                "{variant:?} dims its label/supporting ink to 38%"
1425            );
1426
1427            // Inert: the wrapped field refuses focus, so nothing thickens.
1428            h.focus();
1429            let rec = h.paint();
1430            let width = match variant {
1431                TextFieldVariant::Filled => rec.indicator().1.height,
1432                TextFieldVariant::Outlined => rec.outline().1,
1433            };
1434            assert_eq!(width, STROKE_REST);
1435        }
1436    }
1437
1438    #[test]
1439    fn an_unthemed_pass_paints_the_fallback_roles() {
1440        let view: TextFieldView<String> = text_field("", |_s: &mut String, _t| {}).label("Email");
1441        let mut w = build(&view);
1442        let size = layout_widget(&mut w, 300.0);
1443        let mut rec = Recorder::default();
1444        let mut ctx = PaintCtx::new(Point::ORIGIN, size);
1445        w.paint(&mut ctx, &mut rec);
1446        assert_eq!(rec.indicator().2, ON_SURFACE_VARIANT);
1447        assert_eq!(
1448            rec.runs_at(BODY_LARGE_SIZE)[0].1,
1449            ON_SURFACE_VARIANT,
1450            "a resting label is onSurfaceVariant"
1451        );
1452    }
1453
1454    // ---- label float ------------------------------------------------------
1455
1456    /// The float condition's whole truth table: focus x content presence.
1457    #[test]
1458    fn the_label_floats_on_focus_or_content_and_rests_only_when_neither() {
1459        // 1. unfocused + empty -> resting.
1460        let mut h = Harness::new(TextFieldVariant::Filled);
1461        h.theme(Brightness::Light);
1462        let rec = h.paint();
1463        assert_eq!(rec.runs_at(BODY_SMALL_SIZE).len(), 0, "no floated run");
1464        assert_eq!(rec.runs_at(BODY_LARGE_SIZE).len(), 1, "the resting run");
1465
1466        // 2. focused + empty -> floated (settled after the transition).
1467        h.focus();
1468        h.paint_at(secs(0.0));
1469        let rec = h.paint_at(secs(1.0));
1470        assert_eq!(rec.runs_at(BODY_LARGE_SIZE).len(), 0);
1471        assert_eq!(rec.runs_at(BODY_SMALL_SIZE).len(), 1);
1472
1473        // 3. focused + content -> still floated.
1474        h.key('a');
1475        h.pass();
1476        assert_eq!(h.state, "a");
1477        let rec = h.paint_at(secs(2.0));
1478        assert_eq!(rec.runs_at(BODY_SMALL_SIZE).len(), 1);
1479
1480        // 4. unfocused + content -> floated.
1481        h.blur();
1482        let rec = h.paint_at(secs(3.0));
1483        assert_eq!(
1484            rec.runs_at(BODY_SMALL_SIZE).len(),
1485            1,
1486            "content alone keeps the label up"
1487        );
1488        assert_eq!(rec.runs_at(BODY_LARGE_SIZE).len(), 0);
1489    }
1490
1491    #[test]
1492    fn the_float_transition_crossfades_and_travels_upward() {
1493        let mut h = Harness::new(TextFieldVariant::Filled);
1494        h.theme(Brightness::Light);
1495        let resting = h.paint_at(secs(0.0)).runs_at(BODY_LARGE_SIZE)[0].0;
1496
1497        h.focus();
1498        // Seed the controller's clock, then sample mid-transition.
1499        h.paint_at(secs(10.0));
1500        let mid = h.paint_at(secs(10.0 + MaterialMotion::SHORT_3.as_secs_f64() / 2.0));
1501        let rest_run = mid.runs_at(BODY_LARGE_SIZE);
1502        let float_run = mid.runs_at(BODY_SMALL_SIZE);
1503        assert_eq!(rest_run.len(), 1, "both layers are live mid-crossfade");
1504        assert_eq!(float_run.len(), 1);
1505        let rest_alpha = rest_run[0].1.components[3];
1506        let float_alpha = float_run[0].1.components[3];
1507        assert!(rest_alpha > 0.0 && rest_alpha < 1.0, "{rest_alpha}");
1508        assert!(float_alpha > 0.0 && float_alpha < 1.0, "{float_alpha}");
1509        assert!(
1510            (rest_alpha + float_alpha - 1.0).abs() < 1e-3,
1511            "the crossfade is complementary"
1512        );
1513        assert!(
1514            rest_run[0].0.y < resting.y,
1515            "the label has started travelling"
1516        );
1517
1518        // Settled: only the floated layer, at the floated band.
1519        let done = h.paint_at(secs(11.0));
1520        let floated = done.runs_at(BODY_SMALL_SIZE);
1521        assert_eq!(floated.len(), 1);
1522        assert!(done.runs_at(BODY_LARGE_SIZE).is_empty());
1523        assert!(floated[0].0.y >= LABEL_FLOAT_TOP);
1524        assert!(floated[0].0.y < resting.y);
1525        assert_eq!(floated[0].1.components[3], 1.0, "fully opaque once settled");
1526    }
1527
1528    #[test]
1529    fn a_floated_label_takes_the_accent_and_a_resting_one_does_not() {
1530        let mut h = Harness::new(TextFieldVariant::Filled);
1531        let theme = h.theme(Brightness::Light);
1532        let resting = h.paint().runs_at(BODY_LARGE_SIZE)[0].1;
1533        assert_eq!(resting, theme.scheme().on_surface_variant);
1534
1535        h.focus();
1536        h.paint_at(secs(0.0));
1537        let floated = h.paint_at(secs(1.0)).runs_at(BODY_SMALL_SIZE)[0].1;
1538        assert_eq!(floated, theme.scheme().primary);
1539
1540        // In the error state the floated label takes `error` instead.
1541        h.error = Some("Bad".to_string());
1542        h.pass();
1543        let floated = h.paint_at(secs(2.0)).runs_at(BODY_SMALL_SIZE)[0].1;
1544        assert_eq!(floated, theme.scheme().error);
1545    }
1546
1547    #[test]
1548    fn reduce_motion_collapses_the_float_and_asks_for_no_frames() {
1549        let mut theme = crate::baseline().with_brightness(Brightness::Light);
1550        theme.motion.reduce_motion = true;
1551        let view: TextFieldView<String> = text_field("", |_s: &mut String, _t| {}).label("Email");
1552        let mut w = build(&view);
1553        let size = layout_widget(&mut w, 300.0);
1554
1555        // Not focused, empty: resting immediately.
1556        let mut rec = Recorder::default();
1557        let mut ctx = PaintCtx::new(Point::ORIGIN, size).with_theme(&theme as &dyn Any);
1558        w.paint(&mut ctx, &mut rec);
1559        assert_eq!(rec.runs_at(BODY_LARGE_SIZE).len(), 1);
1560        assert!(!ctx.needs_frame(), "a collapsed float asks for no frame");
1561
1562        // A value arrives: the floated layer is fully up on the very next
1563        // paint, with no transition frames in between.
1564        w.value_empty = false;
1565        let mut rec = Recorder::default();
1566        let mut ctx = PaintCtx::new(Point::ORIGIN, size).with_theme(&theme as &dyn Any);
1567        w.paint(&mut ctx, &mut rec);
1568        let floated = rec.runs_at(BODY_SMALL_SIZE);
1569        assert_eq!(floated.len(), 1);
1570        assert_eq!(floated[0].1.components[3], 1.0);
1571        assert!(rec.runs_at(BODY_LARGE_SIZE).is_empty());
1572        assert!(!ctx.needs_frame());
1573    }
1574
1575    #[test]
1576    fn a_field_built_with_content_starts_floated() {
1577        let view: TextFieldView<String> =
1578            text_field("already here", |_s: &mut String, _t| {}).label("Email");
1579        let mut w = build(&view);
1580        let size = layout_widget(&mut w, 300.0);
1581        let mut rec = Recorder::default();
1582        let mut ctx = PaintCtx::new(Point::ORIGIN, size);
1583        w.paint(&mut ctx, &mut rec);
1584        assert_eq!(rec.runs_at(BODY_SMALL_SIZE).len(), 1, "no fade-in on mount");
1585        assert!(rec.runs_at(BODY_LARGE_SIZE).is_empty());
1586    }
1587
1588    // ---- supporting text --------------------------------------------------
1589
1590    #[test]
1591    fn the_error_text_outranks_the_helper_text() {
1592        let helper: TextFieldView<String> =
1593            text_field("", |_s: &mut String, _t| {}).supporting_text("Helper");
1594        assert_eq!(helper.supporting_line(), Some("Helper"));
1595
1596        let both: TextFieldView<String> = text_field("", |_s: &mut String, _t| {})
1597            .supporting_text("Helper")
1598            .error_text("Wrong");
1599        assert_eq!(both.supporting_line(), Some("Wrong"), "error wins");
1600        assert!(both.has_error());
1601
1602        let neither: TextFieldView<String> = text_field("", |_s: &mut String, _t| {});
1603        assert_eq!(neither.supporting_line(), None);
1604
1605        // And the shown line is the error's, in the error ink.
1606        let mut h = Harness::new(TextFieldVariant::Filled);
1607        h.label = None;
1608        h.supporting = Some("Helper".to_string());
1609        let theme = h.theme(Brightness::Light);
1610        let helper_run = h.paint().runs_at(BODY_SMALL_SIZE);
1611        assert_eq!(helper_run.len(), 1);
1612        assert_eq!(helper_run[0].1, theme.scheme().on_surface_variant);
1613
1614        h.error = Some("Wrong".to_string());
1615        h.pass();
1616        let error_run = h.paint().runs_at(BODY_SMALL_SIZE);
1617        assert_eq!(error_run.len(), 1, "one line, not two");
1618        assert_eq!(error_run[0].1, theme.scheme().error);
1619        assert_eq!(
1620            error_run[0].0,
1621            Point::new(HPAD, DECORATION_HEIGHT + SUPPORTING_GAP),
1622            "placed below the decoration box"
1623        );
1624    }
1625
1626    // ---- behaviour --------------------------------------------------------
1627
1628    #[test]
1629    fn typing_round_trips_through_the_callback_and_never_self_mutates() {
1630        let mut h = Harness::new(TextFieldVariant::Filled);
1631        h.focus();
1632        h.key('h');
1633        h.key('i');
1634        assert_eq!(h.state, "hi", "each edit reported through on_change");
1635
1636        // The app rejecting an edit wins: the next rebuild feeds its own value
1637        // back down, which is the controlled contract the baseline implements.
1638        h.state = "frozen".to_string();
1639        h.pass();
1640        assert_eq!(h.state, "frozen");
1641    }
1642
1643    #[test]
1644    fn the_cursor_is_text_over_the_field_and_not_allowed_when_disabled() {
1645        let mut h = Harness::new(TextFieldVariant::Filled);
1646        h.supporting = Some("Required".to_string());
1647        h.pass();
1648        h.pointer(PointerPhase::Move, IN_FIELD);
1649        assert_eq!(h.root.cursor(), CursorIcon::Text);
1650        // Over the supporting line, outside the decoration box: nothing asked.
1651        h.pointer(
1652            PointerPhase::Move,
1653            Point::new(120.0, DECORATION_HEIGHT + 6.0),
1654        );
1655        assert_eq!(h.root.cursor(), CursorIcon::Default);
1656
1657        h.enabled = false;
1658        h.pass();
1659        h.pointer(PointerPhase::Move, IN_FIELD);
1660        assert_eq!(h.root.cursor(), CursorIcon::NotAllowed);
1661    }
1662
1663    #[test]
1664    fn visit_children_publishes_every_mounted_pod() {
1665        let bare: TextFieldView<String> = text_field("", |_s: &mut String, _t| {});
1666        let w = build(&bare);
1667        let mut seen = 0usize;
1668        Widget::visit_children(&w, &mut |_pod| seen += 1);
1669        assert_eq!(seen, 1, "the wrapped field alone");
1670
1671        let slotted: TextFieldView<String> = text_field("", |_s: &mut String, _t| {})
1672            .leading(frust::SizedBox(Some(20.0), Some(20.0)))
1673            .trailing(frust::SizedBox(Some(20.0), Some(20.0)));
1674        let w = build(&slotted);
1675        let mut seen = 0usize;
1676        Widget::visit_children(&w, &mut |_pod| seen += 1);
1677        assert_eq!(seen, 3);
1678    }
1679
1680    #[test]
1681    fn semantics_is_a_labelled_group_around_the_editables_own_node() {
1682        let mut logic = |state: &mut String| {
1683            text_field(state.clone(), |s: &mut String, t| *s = t)
1684                .label("Email")
1685                .error_text("Enter a valid address")
1686        };
1687        let mut root: RenderRoot<String, TextFieldView<String>> = RenderRoot::new();
1688        let mut state = String::new();
1689        root.rebuild(&mut logic, &mut state);
1690        let mut tcx = TextContext::new();
1691        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1692        let update = root.semantics();
1693        let group = update
1694            .nodes
1695            .iter()
1696            .find(|(_, n)| n.role() == Role::Group)
1697            .expect("a group node");
1698        assert_eq!(group.1.label(), Some("Email"));
1699        assert_eq!(group.1.description(), Some("Enter a valid address"));
1700        assert!(
1701            update
1702                .nodes
1703                .iter()
1704                .any(|(_, n)| n.role() == frust::authoring::Role::TextInput),
1705            "the wrapped editable's node reaches the tree through the chrome"
1706        );
1707    }
1708
1709    #[test]
1710    fn adding_a_slot_rebuilds_the_pod_set() {
1711        let mut root: RenderRoot<bool, TextFieldView<bool>> = RenderRoot::new();
1712        let mut state = false;
1713        let mut logic = |with_slot: &mut bool| {
1714            let view: TextFieldView<bool> = text_field("", |_s: &mut bool, _t| {});
1715            if *with_slot {
1716                view.leading(frust::SizedBox(Some(20.0), Some(20.0)))
1717            } else {
1718                view
1719            }
1720        };
1721        root.rebuild(&mut logic, &mut state);
1722        let mut tcx = TextContext::new();
1723        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1724        state = true;
1725        root.rebuild(&mut logic, &mut state);
1726        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1727        // The slot's arrival must move the editable's glyphs right; the field
1728        // view carries the new padding through the normal child rebuild.
1729        state = false;
1730        root.rebuild(&mut logic, &mut state);
1731        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1732    }
1733}