Skip to main content

gpui_kit_material/components/
text_field.rs

1//! M3 text fields: filled and outlined.
2//!
3//! Reference: <https://m3.material.io/components/text-fields/specs> and the
4//! Material 3 Design Kit "Text fields" page. Editing, selection, clipboard,
5//! IME, the context menu and text accessibility belong to Kit's
6//! `gpui_kit::component::input::Input` (single line) and `Textarea` (multi
7//! line), rendered without their own chrome (`appearance(false)`). This module
8//! owns the Material container, active indicator or outline (with the notch
9//! the floating label opens), the label's resting ↔ floating motion, icons,
10//! prefix/suffix, supporting text and the character counter.
11//!
12//! The label floats on the fast spatial token and its color, the indicator
13//! weight and the notch follow focus on the fast effects token, so both
14//! retarget from wherever they are when focus changes again and honor reduced
15//! motion through [`crate::motion`].
16use super::InteractionPreview;
17use crate::{
18    color::ColorRole,
19    motion::{self, MotionToken},
20    theme::{blend, color},
21    tokens::{
22        shape::RADIUS_EXTRA_SMALL,
23        state,
24        typography::{TypeRole, rem},
25    },
26};
27use gpui::{prelude::*, *};
28use gpui_base::input::{InputBaseState, InputModeKind, InputState, TextareaState};
29use gpui_kit::component::{
30    Sizable as _, Size,
31    input::{Input, Textarea},
32};
33
34/// Container height of a single-line field.
35pub const TEXT_FIELD_HEIGHT: f32 = 56.;
36/// The default specimen width of the design kit.
37pub const TEXT_FIELD_WIDTH: f32 = 210.;
38/// Horizontal content padding without an icon on that side.
39const PADDING: f32 = 16.;
40/// Horizontal padding beside a leading or trailing icon.
41const ICON_PADDING: f32 = 12.;
42const ICON_SIZE: f32 = 24.;
43/// Gap between an icon and the text.
44const ICON_GAP: f32 = 16.;
45/// The floating label's top inside a filled container.
46const FILLED_FLOAT_TOP: f32 = 8.;
47/// The input line's top in a filled field with a label.
48const FILLED_INPUT_TOP: f32 = 24.;
49/// Space the outline notch keeps on each side of the floating label.
50const NOTCH_PADDING: f32 = 4.;
51/// The focused indicator and outline weight (the design kit's 3dp stroke).
52const FOCUS_INDICATOR: f32 = 3.;
53
54/// md3.io's text field densities. The design kit draws one 56dp field;
55/// compact is density −2 (48dp, the M3 density scale's 4dp steps, as the
56/// baseline menu's compact items), with the filled label and input moved up
57/// 4dp each so the floating label keeps its 4dp gap to the input.
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
59pub enum TextFieldDensity {
60    #[default]
61    Default,
62    Compact,
63}
64
65impl TextFieldDensity {
66    pub const ALL: [Self; 2] = [Self::Default, Self::Compact];
67    /// The single-line container height in dp.
68    pub const fn height(self) -> f32 {
69        match self {
70            Self::Default => TEXT_FIELD_HEIGHT,
71            Self::Compact => TEXT_FIELD_HEIGHT - 8.,
72        }
73    }
74    /// How far the filled label and input move up from their 56dp places.
75    const fn lift(self) -> f32 {
76        (TEXT_FIELD_HEIGHT - self.height()) / 2.
77    }
78}
79
80/// The two M3 text field styles.
81#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
82pub enum TextFieldVariant {
83    #[default]
84    Filled,
85    Outlined,
86}
87
88impl TextFieldVariant {
89    pub const ALL: [Self; 2] = [Self::Filled, Self::Outlined];
90    pub const fn name(self) -> &'static str {
91        match self {
92            Self::Filled => "Filled",
93            Self::Outlined => "Outlined",
94        }
95    }
96}
97
98enum FieldInput {
99    Single(Entity<InputState>),
100    Multi(Entity<TextareaState>),
101    /// A specimen's fixed value, painted where the editor paints its text.
102    Static(SharedString),
103}
104
105/// An M3 text field over a Kit input state.
106#[derive(gpui::IntoElement)]
107pub struct TextField {
108    id: ElementId,
109    input: FieldInput,
110    variant: TextFieldVariant,
111    density: TextFieldDensity,
112    label: Option<SharedString>,
113    placeholder: Option<SharedString>,
114    leading: Option<AnyElement>,
115    trailing: Option<AnyElement>,
116    prefix: Option<SharedString>,
117    suffix: Option<SharedString>,
118    supporting: Option<SharedString>,
119    max_length: Option<usize>,
120    error: bool,
121    disabled: bool,
122    float_label: bool,
123    preview: Option<InteractionPreview>,
124    style: StyleRefinement,
125}
126
127impl TextField {
128    /// A single-line field editing `state`.
129    pub fn new(id: impl Into<ElementId>, state: &Entity<InputState>) -> Self {
130        Self::with_input(id, FieldInput::Single(state.clone()))
131    }
132
133    /// A multi-line field editing `state`; it grows with its rows.
134    pub fn multi_line(id: impl Into<ElementId>, state: &Entity<TextareaState>) -> Self {
135        Self::with_input(id, FieldInput::Multi(state.clone()))
136    }
137
138    /// A non-editable specimen showing `value` (empty for none), for boards
139    /// that render many fields in forced states without an editor each.
140    pub fn specimen(id: impl Into<ElementId>, value: impl Into<SharedString>) -> Self {
141        Self::with_input(id, FieldInput::Static(value.into()))
142    }
143
144    fn with_input(id: impl Into<ElementId>, input: FieldInput) -> Self {
145        Self {
146            id: id.into(),
147            input,
148            variant: TextFieldVariant::Filled,
149            density: TextFieldDensity::Default,
150            label: None,
151            placeholder: None,
152            leading: None,
153            trailing: None,
154            prefix: None,
155            suffix: None,
156            supporting: None,
157            max_length: None,
158            error: false,
159            disabled: false,
160            float_label: false,
161            preview: None,
162            style: StyleRefinement::default(),
163        }
164    }
165
166    pub fn variant(mut self, variant: TextFieldVariant) -> Self {
167        self.variant = variant;
168        self
169    }
170    /// The container density (default 56dp; compact 48dp).
171    pub fn density(mut self, density: TextFieldDensity) -> Self {
172        self.density = density;
173        self
174    }
175    pub fn outlined(self) -> Self {
176        self.variant(TextFieldVariant::Outlined)
177    }
178    /// The label: inside the field at rest, floating above the text while
179    /// the field is focused or populated. It is also the accessible name.
180    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
181        self.label = Some(label.into());
182        self
183    }
184    /// Hint text shown in an empty field while its label floats (or when it
185    /// has no label).
186    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
187        self.placeholder = Some(placeholder.into());
188        self
189    }
190    /// Keep the label floating even when empty and unfocused, so the
191    /// placeholder shows at rest (the design kit's "Placeholder text").
192    pub fn float_label(mut self, float: bool) -> Self {
193        self.float_label = float;
194        self
195    }
196    /// A 24dp leading icon; it inherits the field's icon color.
197    pub fn leading_icon(mut self, icon: impl IntoElement) -> Self {
198        self.leading = Some(icon.into_any_element());
199        self
200    }
201    /// A 24dp trailing icon or icon button; painted in the error color while
202    /// the field is in error.
203    pub fn trailing_icon(mut self, icon: impl IntoElement) -> Self {
204        self.trailing = Some(icon.into_any_element());
205        self
206    }
207    /// Text before the input (e.g. a currency sign), shown with the input.
208    pub fn prefix(mut self, prefix: impl Into<SharedString>) -> Self {
209        self.prefix = Some(prefix.into());
210        self
211    }
212    /// Text after the input (e.g. a unit), shown with the input.
213    pub fn suffix(mut self, suffix: impl Into<SharedString>) -> Self {
214        self.suffix = Some(suffix.into());
215        self
216    }
217    /// Supporting text below the field; with [`Self::error`] it is the
218    /// error message.
219    pub fn supporting_text(mut self, text: impl Into<SharedString>) -> Self {
220        self.supporting = Some(text.into());
221        self
222    }
223    /// Show a `count/max` character counter at the end of the supporting row.
224    pub fn max_length(mut self, max: usize) -> Self {
225        self.max_length = Some(max);
226        self
227    }
228    pub fn error(mut self, error: bool) -> Self {
229        self.error = error;
230        self
231    }
232    pub fn disabled(mut self, disabled: bool) -> Self {
233        self.disabled = disabled;
234        self
235    }
236    /// Render a static specimen in one forced interaction state (Enabled,
237    /// Hovered, Focused or Disabled; Pressed paints as Focused, which is
238    /// what pressing a field produces). See [`InteractionPreview`].
239    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
240        self.preview = Some(state);
241        self
242    }
243}
244
245impl Styled for TextField {
246    fn style(&mut self) -> &mut StyleRefinement {
247        &mut self.style
248    }
249}
250
251/// Which visual state the field paints.
252#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
253pub(crate) struct FieldState {
254    pub focused: bool,
255    pub hovered: bool,
256    pub error: bool,
257    pub disabled: bool,
258}
259
260/// The roles a field paints in one state.
261#[derive(Clone, Copy, Debug, PartialEq)]
262pub(crate) struct FieldPaint {
263    pub container: Rgba,
264    /// Filled: the active indicator. Outlined: the outline.
265    pub indicator: Rgba,
266    pub indicator_width: f32,
267    pub label: Rgba,
268    pub input: Rgba,
269    pub placeholder: Rgba,
270    pub leading: Rgba,
271    pub trailing: Rgba,
272    pub supporting: Rgba,
273    pub state_layer: Rgba,
274    /// The text caret (`md.comp.*-text-field.caret.color`, error in error).
275    pub caret: Rgba,
276}
277
278fn alpha(mut c: Rgba, a: f32) -> Rgba {
279    c.a *= a;
280    c
281}
282
283/// M3 text field colors (`md.comp.{filled,outlined}-text-field.*`).
284pub(crate) fn field_paint(variant: TextFieldVariant, s: FieldState) -> FieldPaint {
285    use ColorRole as C;
286    let filled = variant == TextFieldVariant::Filled;
287    let on_surface = color(C::ON_SURFACE);
288    let variant_ink = color(C::ON_SURFACE_VARIANT);
289    if s.disabled {
290        let content = state::disabled(on_surface);
291        return FieldPaint {
292            container: if filled {
293                alpha(on_surface, 0.04)
294            } else {
295                alpha(on_surface, 0.)
296            },
297            indicator: if filled {
298                content
299            } else {
300                alpha(on_surface, state::DISABLED_CONTAINER)
301            },
302            indicator_width: 1.,
303            label: content,
304            input: content,
305            placeholder: content,
306            leading: content,
307            trailing: content,
308            supporting: content,
309            state_layer: alpha(on_surface, 0.),
310            caret: color(C::PRIMARY),
311        };
312    }
313    let (rest, hover) = if filled {
314        (variant_ink, on_surface)
315    } else {
316        (color(C::OUTLINE), on_surface)
317    };
318    let indicator = match (s.error, s.focused, s.hovered) {
319        (true, false, true) => color(C::ON_ERROR_CONTAINER),
320        (true, _, _) => color(C::ERROR),
321        (false, true, _) => color(C::PRIMARY),
322        (false, false, true) => hover,
323        _ => rest,
324    };
325    let label = match (s.error, s.focused, s.hovered) {
326        (true, false, true) => color(C::ON_ERROR_CONTAINER),
327        (true, _, _) => color(C::ERROR),
328        (false, true, _) => color(C::PRIMARY),
329        (false, false, true) if !filled => on_surface,
330        _ => variant_ink,
331    };
332    FieldPaint {
333        container: if filled {
334            color(C::SURFACE_CONTAINER_HIGHEST)
335        } else {
336            alpha(on_surface, 0.)
337        },
338        indicator,
339        indicator_width: if s.focused { FOCUS_INDICATOR } else { 1. },
340        label,
341        input: on_surface,
342        placeholder: variant_ink,
343        leading: variant_ink,
344        trailing: match (s.error, s.hovered && !s.focused) {
345            (true, true) => color(C::ON_ERROR_CONTAINER),
346            (true, false) => color(C::ERROR),
347            _ => variant_ink,
348        },
349        supporting: if s.error {
350            color(C::ERROR)
351        } else {
352            variant_ink
353        },
354        state_layer: if filled && s.hovered {
355            alpha(on_surface, state::HOVER)
356        } else {
357            alpha(on_surface, 0.)
358        },
359        caret: if s.error {
360            color(C::ERROR)
361        } else {
362            color(C::PRIMARY)
363        },
364    }
365}
366
367/// Label geometry in design px: `(left, top, font size, line height)` for a
368/// float progress `t` (0 resting, 1 floating).
369#[cfg(test)]
370pub(crate) fn label_geometry(
371    variant: TextFieldVariant,
372    leading: bool,
373    t: f32,
374) -> (f32, f32, f32, f32) {
375    density_label_geometry(variant, TextFieldDensity::Default, leading, t)
376}
377
378/// The label geometry (see `label_geometry`) in a field of `density`.
379pub(crate) fn density_label_geometry(
380    variant: TextFieldVariant,
381    density: TextFieldDensity,
382    leading: bool,
383    t: f32,
384) -> (f32, f32, f32, f32) {
385    let rest_left = if leading {
386        ICON_PADDING + ICON_SIZE + ICON_GAP
387    } else {
388        PADDING
389    };
390    let (float_left, float_top) = match variant {
391        TextFieldVariant::Filled => (rest_left, FILLED_FLOAT_TOP - density.lift()),
392        // Centered on the top outline, at the content's leading edge.
393        TextFieldVariant::Outlined => (PADDING, -8.),
394    };
395    let rest_top = (density.height() - 24.) / 2.;
396    let large = TypeRole::BodyLarge.scale();
397    let small = TypeRole::BodySmall.scale();
398    let lerp = |a: f32, b: f32| a + (b - a) * t;
399    (
400        lerp(rest_left, float_left),
401        lerp(rest_top, float_top),
402        lerp(large.font_size.0, small.font_size.0) * 14.,
403        lerp(large.line_height.0, small.line_height.0) * 14.,
404    )
405}
406
407/// The outline's notch `(start, width)` in design px for a label `label_px`
408/// wide at the floating size, opened by `t`.
409pub(crate) fn notch(label_px: f32, t: f32) -> (f32, f32) {
410    let t = t.clamp(0., 1.);
411    let full = label_px + 2. * NOTCH_PADDING;
412    let center = PADDING - NOTCH_PADDING + full / 2.;
413    let width = full * t;
414    (center - width / 2., width)
415}
416
417/// Apply an M3 type role's family, size, line height and weight.
418pub(crate) fn type_style<E: Styled>(element: E, role: TypeRole) -> E {
419    let scale = role.scale();
420    element
421        .font_family(scale.font_family)
422        .text_size(scale.font_size)
423        .line_height(scale.line_height)
424        .font_weight(FontWeight(scale.font_weight as f32))
425}
426
427fn read_value<M: InputModeKind>(state: &Entity<InputBaseState<M>>, cx: &App) -> SharedString {
428    state.read(cx).value()
429}
430
431fn focus_handle<M: InputModeKind>(state: &Entity<InputBaseState<M>>, cx: &App) -> FocusHandle {
432    state.read(cx).focus_handle(cx)
433}
434
435/// The width of `text` at the floating label's size, in design px.
436fn label_width(text: &SharedString, window: &Window) -> f32 {
437    let scale = TypeRole::BodySmall.scale();
438    let mut style = window.text_style();
439    style.font_family = scale.font_family.into();
440    style.font_weight = FontWeight(scale.font_weight as f32);
441    let rem = window.rem_size();
442    let size = rem * scale.font_size.0;
443    let run = style.to_run(text.len());
444    let line = window
445        .text_system()
446        .shape_line(text.clone(), size, &[run], None);
447    // Back to design px at the base rem, so it composes with `rem()`.
448    line.width.as_f32() / rem.as_f32() * 14.
449}
450
451impl RenderOnce for TextField {
452    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
453        let id = self.id.clone();
454        let (value, focus) = match &self.input {
455            FieldInput::Single(state) => (read_value(state, cx), Some(focus_handle(state, cx))),
456            FieldInput::Multi(state) => (read_value(state, cx), Some(focus_handle(state, cx))),
457            FieldInput::Static(value) => (value.clone(), None),
458        };
459        let multi_line = matches!(self.input, FieldInput::Multi(_));
460        let preview = self.preview;
461        let disabled = self.disabled || preview.is_some_and(InteractionPreview::is_disabled);
462        let focused = match preview {
463            Some(p) => p.is_focused() || p.is_pressed(),
464            None => !disabled && focus.as_ref().is_some_and(|f| f.is_focused(window)),
465        };
466        let hovered_preview = preview.is_some_and(InteractionPreview::is_hovered);
467        let populated = !value.is_empty();
468        let has_label = self.label.is_some();
469        let floating = focused || populated || self.float_label || !has_label;
470        let variant = self.variant;
471        let filled = variant == TextFieldVariant::Filled;
472        let leading = self.leading.is_some();
473        let density = self.density;
474        let height = density.height();
475
476        // Motion: float on the fast spatial token, focus emphasis on fast effects.
477        let float_t = motion::token_value(
478            (id.clone(), "label-float"),
479            if floating { 1. } else { 0. },
480            MotionToken::FastSpatial,
481            window,
482            cx,
483        );
484        let focus_t = motion::token_value(
485            (id.clone(), "focus"),
486            if focused { 1. } else { 0. },
487            MotionToken::FastEffects,
488            window,
489            cx,
490        )
491        .clamp(0., 1.);
492
493        let base = FieldState {
494            focused: false,
495            hovered: hovered_preview,
496            error: self.error,
497            disabled,
498        };
499        let rest = field_paint(variant, base);
500        let active = field_paint(
501            variant,
502            FieldState {
503                focused: true,
504                ..base
505            },
506        );
507        let mix = |a: Rgba, b: Rgba| blend(a, b, focus_t);
508        let indicator = mix(rest.indicator, active.indicator);
509        // The outline's weight steps with focus; its color crossfades.
510        let outline_width = if focused {
511            active.indicator_width
512        } else {
513            rest.indicator_width
514        };
515        let label_color = mix(rest.label, active.label);
516        let hover_paint = field_paint(
517            variant,
518            FieldState {
519                hovered: true,
520                ..base
521            },
522        );
523        let live_hover = preview.is_none() && !disabled && !focused;
524        // Group hover resolves to the nearest painting ancestor of this name,
525        // so a static name stays scoped to this field without formatting one
526        // per frame.
527        let group = SharedString::new_static("material-text-field");
528
529        let mut container = div()
530            .id(id.clone())
531            .group(group.clone())
532            .relative()
533            .flex()
534            .w_full()
535            .min_h(rem(height))
536            .when(!multi_line, |this| this.h(rem(height)))
537            .when(multi_line, |this| this.items_start())
538            .when(!multi_line, |this| this.items_center())
539            .map(|this| match variant {
540                TextFieldVariant::Filled => this
541                    .rounded_t(rem(RADIUS_EXTRA_SMALL.as_f32()))
542                    .bg(rest.container),
543                TextFieldVariant::Outlined => this.rounded(rem(RADIUS_EXTRA_SMALL.as_f32())),
544            })
545            .when(!disabled, |this| this.cursor_text());
546        if let Some(focus) = focus.clone().filter(|_| !disabled && preview.is_none()) {
547            container = container.on_mouse_down(MouseButton::Left, move |_, window, cx| {
548                focus.focus(window, cx);
549            });
550        }
551
552        // The filled hover state layer.
553        if filled {
554            container = container.child(
555                div()
556                    .absolute()
557                    .inset_0()
558                    .rounded_t(rem(RADIUS_EXTRA_SMALL.as_f32()))
559                    .bg(rest.state_layer)
560                    .when(live_hover, |this| {
561                        let layer = hover_paint.state_layer;
562                        this.group_hover(group.clone(), move |s| s.bg(layer))
563                    }),
564            );
565            // The resting 1dp indicator, and the 3dp focus indicator fading
566            // in over it (material-web's two-layer active indicator).
567            container = container
568                .child(
569                    div()
570                        .debug_selector(|| "text-field-indicator".into())
571                        .absolute()
572                        .left_0()
573                        .right_0()
574                        .bottom_0()
575                        .h(px(rest.indicator_width))
576                        .bg(rest.indicator)
577                        .when(live_hover, |this| {
578                            let ink = hover_paint.indicator;
579                            this.group_hover(group.clone(), move |s| s.bg(ink))
580                        }),
581                )
582                .when(focus_t > 0., |this| {
583                    this.child(
584                        div()
585                            .debug_selector(|| "text-field-focus-indicator".into())
586                            .absolute()
587                            .left_0()
588                            .right_0()
589                            .bottom_0()
590                            .h(px(active.indicator_width))
591                            .bg(active.indicator)
592                            .opacity(focus_t),
593                    )
594                });
595        } else {
596            let label_px = self
597                .label
598                .as_ref()
599                .map(|label| label_width(label, window))
600                .unwrap_or(0.);
601            let (start, width) = if has_label {
602                notch(label_px, float_t)
603            } else {
604                (0., 0.)
605            };
606            container = container.children(outline(
607                start,
608                width,
609                indicator,
610                outline_width,
611                live_hover.then_some((group.clone(), hover_paint.indicator)),
612            ));
613        }
614
615        let pad_left = if leading { ICON_PADDING } else { PADDING };
616        let pad_right = if self.trailing.is_some() {
617            ICON_PADDING
618        } else {
619            PADDING
620        };
621        let input_top = if filled && has_label {
622            FILLED_INPUT_TOP - density.lift()
623        } else {
624            (height - 24.) / 2.
625        };
626        let input_bottom = height - 24. - input_top;
627        let show_content = floating || !has_label;
628        let show_placeholder = !populated && show_content;
629
630        let icon_slot = |icon: AnyElement, ink: Rgba| {
631            div()
632                .flex()
633                .flex_shrink_0()
634                .items_center()
635                .justify_center()
636                .size(rem(ICON_SIZE))
637                .when(multi_line, |this| this.mt(rem((height - 24.) / 2.)))
638                .text_color(ink)
639                .child(icon)
640        };
641        let affix = |text: SharedString| {
642            type_style(div().flex_shrink_0(), TypeRole::BodyLarge)
643                .text_color(rest.placeholder)
644                .child(text)
645        };
646        // Kit's input paints the theme caret (primary through the theme
647        // bridge); the field's own caret follows its error state.
648        let caret = Some(Hsla::from(active.caret));
649        match &self.input {
650            FieldInput::Single(state) => state.update(cx, |s, _| s.set_caret_color(caret)),
651            FieldInput::Multi(state) => state.update(cx, |s, _| s.set_caret_color(caret)),
652            FieldInput::Static(_) => {}
653        }
654        let editor: AnyElement = match &self.input {
655            FieldInput::Single(state) => Input::new(state)
656                .appearance(false)
657                .with_size(Size::XSmall)
658                .disabled(disabled)
659                .when_some(self.label.clone(), |this, label| this.aria_label(label))
660                .p_0()
661                .h(rem(24.))
662                .text_size(TypeRole::BodyLarge.scale().font_size)
663                .line_height(TypeRole::BodyLarge.scale().line_height)
664                .into_any_element(),
665            FieldInput::Multi(state) => Textarea::new(state)
666                .appearance(false)
667                .with_size(Size::XSmall)
668                .disabled(disabled)
669                .when_some(self.label.clone(), |this, label| this.aria_label(label))
670                .p_0()
671                // Kit insets multi-line text by the XSmall input padding.
672                .mx(px(-4.))
673                .text_size(TypeRole::BodyLarge.scale().font_size)
674                .line_height(TypeRole::BodyLarge.scale().line_height)
675                .into_any_element(),
676            FieldInput::Static(value) => div()
677                .flex_1()
678                .min_w_0()
679                .overflow_hidden()
680                .whitespace_nowrap()
681                .child(value.clone())
682                .into_any_element(),
683        };
684        let placeholder = self.placeholder.clone().filter(|_| show_placeholder);
685        let input_row = div()
686            .relative()
687            .flex()
688            .flex_1()
689            .min_w_0()
690            .when(!multi_line, |this| this.items_center().h(rem(24.)))
691            .when(multi_line, |this| this.items_start())
692            .gap(rem(2.))
693            .map(|this| type_style(this, TypeRole::BodyLarge))
694            .text_color(rest.input)
695            .children(self.prefix.clone().filter(|_| show_content).map(affix))
696            .child(
697                div()
698                    .relative()
699                    .flex_1()
700                    .min_w_0()
701                    // Kit paints the editor's text in its own foreground, so
702                    // a disabled editor fades as a whole: on-surface at 38%.
703                    .child(
704                        div()
705                            .when(disabled, |this| {
706                                this.text_color(color(ColorRole::ON_SURFACE))
707                                    .opacity(state::DISABLED_CONTENT)
708                            })
709                            .child(editor),
710                    )
711                    .when_some(placeholder, |this, placeholder| {
712                        this.child(
713                            div()
714                                .absolute()
715                                .top_0()
716                                .left_0()
717                                .right_0()
718                                .overflow_hidden()
719                                .whitespace_nowrap()
720                                .text_color(rest.placeholder)
721                                .child(placeholder),
722                        )
723                    }),
724            )
725            .children(self.suffix.clone().filter(|_| show_content).map(affix));
726
727        container = container.child(
728            div()
729                .relative()
730                .flex()
731                .w_full()
732                .when(!multi_line, |this| this.items_center().h_full())
733                .when(multi_line, |this| this.items_start())
734                .pl(rem(pad_left))
735                .pr(rem(pad_right))
736                .gap(rem(ICON_GAP))
737                .children(self.leading.map(|icon| icon_slot(icon, rest.leading)))
738                .child(
739                    div()
740                        .flex()
741                        .flex_1()
742                        .min_w_0()
743                        .pt(rem(input_top))
744                        .pb(rem(input_bottom))
745                        .child(input_row),
746                )
747                .children(self.trailing.map(|icon| {
748                    icon_slot(icon, mix(rest.trailing, active.trailing)).when(live_hover, |this| {
749                        let ink = hover_paint.trailing;
750                        this.group_hover(group.clone(), move |s| s.text_color(ink))
751                    })
752                })),
753        );
754
755        if let Some(label) = self.label.clone() {
756            let (left, top, size, line) =
757                density_label_geometry(variant, density, leading, float_t);
758            container = container.child(
759                div()
760                    .debug_selector(|| "text-field-label".into())
761                    .absolute()
762                    .left(rem(left))
763                    .top(rem(top))
764                    .max_w(relative(1.))
765                    .whitespace_nowrap()
766                    .overflow_hidden()
767                    .font_family(TypeRole::BodyLarge.scale().font_family)
768                    .text_size(rem(size))
769                    .line_height(rem(line))
770                    .text_color(label_color)
771                    // An error field's label hovers to on-error-container,
772                    // as its hovered preview paints.
773                    .when(live_hover, |this| {
774                        let ink = hover_paint.label;
775                        this.group_hover(group.clone(), move |s| s.text_color(ink))
776                    })
777                    .child(label),
778            );
779        }
780
781        let counter = self
782            .max_length
783            .map(|max| SharedString::from(format!("{}/{max}", value.chars().count())));
784        let supporting_row = (self.supporting.is_some() || counter.is_some()).then(|| {
785            type_style(div(), TypeRole::BodySmall)
786                .flex()
787                .justify_between()
788                .gap(rem(16.))
789                .px(rem(PADDING))
790                .pt(rem(4.))
791                .text_color(rest.supporting)
792                .child(
793                    div()
794                        .debug_selector(|| "text-field-supporting".into())
795                        .min_w_0()
796                        .flex_1()
797                        .children(self.supporting.clone()),
798                )
799                .children(counter.map(|counter| div().flex_shrink_0().child(counter)))
800        });
801
802        let mut outer = div()
803            .flex()
804            .flex_col()
805            .flex_shrink_0()
806            .w(rem(TEXT_FIELD_WIDTH))
807            .child(container)
808            .children(supporting_row);
809        outer.style().refine(&self.style);
810        outer
811    }
812}
813
814/// The outline as three borders that leave the notch open: the leading part
815/// up to the notch, the trailing part after it, and the bottom edge under it.
816fn outline(
817    start: f32,
818    width: f32,
819    ink: Rgba,
820    weight: f32,
821    hover: Option<(SharedString, Rgba)>,
822) -> Vec<AnyElement> {
823    let radius = rem(RADIUS_EXTRA_SMALL.as_f32());
824    let weight = px(weight);
825    let outline_hover = hover.clone();
826    let hovering = move |el: Div| match outline_hover.clone() {
827        Some((group, hover)) => el.group_hover(group, move |s| s.border_color(hover)),
828        None => el,
829    };
830    let fill_hover = {
831        let hover = hover.clone();
832        move |el: Div| match hover.clone() {
833            Some((group, hover)) => el.group_hover(group, move |s| s.bg(hover)),
834            None => el,
835        }
836    };
837    let start_piece = div()
838        .debug_selector(|| "text-field-outline-start".into())
839        .absolute()
840        .top_0()
841        .bottom_0()
842        .left_0()
843        .border_l(weight)
844        .border_t(weight)
845        .border_b(weight)
846        .border_color(ink);
847    if width <= 0. {
848        // A whole outline: one rounded border.
849        return vec![
850            hovering(start_piece.right_0().border_r(weight).rounded(radius)).into_any_element(),
851        ];
852    }
853    vec![
854        hovering(start_piece.w(rem(start)).rounded_l(radius)).into_any_element(),
855        hovering(
856            div()
857                .debug_selector(|| "text-field-outline-end".into())
858                .absolute()
859                .top_0()
860                .bottom_0()
861                .right_0()
862                .left(rem(start + width))
863                .border_r(weight)
864                .border_t(weight)
865                .border_b(weight)
866                .rounded_r(radius)
867                .border_color(ink),
868        )
869        .into_any_element(),
870        fill_hover(
871            div()
872                .absolute()
873                .bottom_0()
874                .left(rem(start))
875                .w(rem(width))
876                .h(weight)
877                .bg(ink),
878        )
879        .into_any_element(),
880    ]
881}
882
883#[cfg(test)]
884#[path = "text_field_tests.rs"]
885mod tests;