Skip to main content

repose_material/material3/
text_field.rs

1#![allow(non_snake_case)]
2
3use std::cell::{Cell, RefCell};
4use std::rc::Rc;
5
6use repose_core::*;
7use repose_ui::{
8    BasicTextField, Box, Column, Row, Text, TextFieldState, TextStyle,
9    ViewExt, ZStack,
10    anim::animate_f32,
11    textfield::{TextMeasureConfig, measure_text},
12    TextFieldConfig as BasicTextFieldConfig,
13};
14
15use super::*;
16
17/// Color slots for text fields -> matches Compose Material3 `TextFieldColors`.
18/// All 42 color fields (focused/unfocused/disabled/error variants of each slot).
19#[allow(dead_code)]
20#[derive(Clone, Debug)]
21pub struct TextFieldColors {
22    pub focused_text_color: Color,
23    pub unfocused_text_color: Color,
24    pub disabled_text_color: Color,
25    pub error_text_color: Color,
26    pub focused_container_color: Color,
27    pub unfocused_container_color: Color,
28    pub disabled_container_color: Color,
29    pub error_container_color: Color,
30    pub cursor_color: Color,
31    pub error_cursor_color: Color,
32    pub focused_indicator_color: Color,
33    pub unfocused_indicator_color: Color,
34    pub disabled_indicator_color: Color,
35    pub error_indicator_color: Color,
36    pub focused_leading_icon_color: Color,
37    pub unfocused_leading_icon_color: Color,
38    pub disabled_leading_icon_color: Color,
39    pub error_leading_icon_color: Color,
40    pub focused_trailing_icon_color: Color,
41    pub unfocused_trailing_icon_color: Color,
42    pub disabled_trailing_icon_color: Color,
43    pub error_trailing_icon_color: Color,
44    pub focused_label_color: Color,
45    pub unfocused_label_color: Color,
46    pub disabled_label_color: Color,
47    pub error_label_color: Color,
48    pub focused_placeholder_color: Color,
49    pub unfocused_placeholder_color: Color,
50    pub disabled_placeholder_color: Color,
51    pub error_placeholder_color: Color,
52    pub focused_supporting_text_color: Color,
53    pub unfocused_supporting_text_color: Color,
54    pub disabled_supporting_text_color: Color,
55    pub error_supporting_text_color: Color,
56    pub focused_prefix_color: Color,
57    pub unfocused_prefix_color: Color,
58    pub disabled_prefix_color: Color,
59    pub error_prefix_color: Color,
60    pub focused_suffix_color: Color,
61    pub unfocused_suffix_color: Color,
62    pub disabled_suffix_color: Color,
63    pub error_suffix_color: Color,
64}
65
66#[allow(dead_code)]
67impl TextFieldColors {
68    pub fn text_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
69        if !enabled {
70            self.disabled_text_color
71        } else if is_error {
72            self.error_text_color
73        } else if focused {
74            self.focused_text_color
75        } else {
76            self.unfocused_text_color
77        }
78    }
79    pub fn container_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
80        if !enabled {
81            self.disabled_container_color
82        } else if is_error {
83            self.error_container_color
84        } else if focused {
85            self.focused_container_color
86        } else {
87            self.unfocused_container_color
88        }
89    }
90    pub fn cursor_color(&self, is_error: bool) -> Color {
91        if is_error {
92            self.error_cursor_color
93        } else {
94            self.cursor_color
95        }
96    }
97    pub fn indicator_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
98        if !enabled {
99            self.disabled_indicator_color
100        } else if is_error {
101            self.error_indicator_color
102        } else if focused {
103            self.focused_indicator_color
104        } else {
105            self.unfocused_indicator_color
106        }
107    }
108    pub fn leading_icon_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
109        if !enabled {
110            self.disabled_leading_icon_color
111        } else if is_error {
112            self.error_leading_icon_color
113        } else if focused {
114            self.focused_leading_icon_color
115        } else {
116            self.unfocused_leading_icon_color
117        }
118    }
119    pub fn trailing_icon_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
120        if !enabled {
121            self.disabled_trailing_icon_color
122        } else if is_error {
123            self.error_trailing_icon_color
124        } else if focused {
125            self.focused_trailing_icon_color
126        } else {
127            self.unfocused_trailing_icon_color
128        }
129    }
130    pub fn label_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
131        if !enabled {
132            self.disabled_label_color
133        } else if is_error {
134            self.error_label_color
135        } else if focused {
136            self.focused_label_color
137        } else {
138            self.unfocused_label_color
139        }
140    }
141    pub fn placeholder_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
142        if !enabled {
143            self.disabled_placeholder_color
144        } else if is_error {
145            self.error_placeholder_color
146        } else if focused {
147            self.focused_placeholder_color
148        } else {
149            self.unfocused_placeholder_color
150        }
151    }
152    pub fn supporting_text_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
153        if !enabled {
154            self.disabled_supporting_text_color
155        } else if is_error {
156            self.error_supporting_text_color
157        } else if focused {
158            self.focused_supporting_text_color
159        } else {
160            self.unfocused_supporting_text_color
161        }
162    }
163}
164
165/// Default values for text field colors.
166pub struct TextFieldDefaults;
167
168impl TextFieldDefaults {
169    /// Default minimum height for a filled TextField (56dp matches M3 spec).
170    pub const MIN_HEIGHT: f32 = 56.0;
171    /// Default minimum width for a filled TextField (280dp matches M3 spec).
172    pub const MIN_WIDTH: f32 = 280.0;
173
174    pub fn colors() -> TextFieldColors {
175        let th = theme();
176        TextFieldColors {
177            focused_text_color: th.on_surface,
178            unfocused_text_color: th.on_surface,
179            disabled_text_color: th.on_surface.with_alpha_f32(0.38),
180            error_text_color: th.on_surface,
181            focused_container_color: th.surface_container_highest,
182            unfocused_container_color: th.surface_container_highest,
183            disabled_container_color: th.on_surface.with_alpha_f32(0.04),
184            error_container_color: th.surface_container_highest,
185            cursor_color: th.primary,
186            error_cursor_color: th.error,
187            focused_indicator_color: th.primary,
188            unfocused_indicator_color: th.on_surface_variant,
189            disabled_indicator_color: th.on_surface.with_alpha_f32(0.12),
190            error_indicator_color: th.error,
191            focused_leading_icon_color: th.on_surface_variant,
192            unfocused_leading_icon_color: th.on_surface_variant,
193            disabled_leading_icon_color: th.on_surface.with_alpha_f32(0.38),
194            error_leading_icon_color: th.error,
195            focused_trailing_icon_color: th.on_surface_variant,
196            unfocused_trailing_icon_color: th.on_surface_variant,
197            disabled_trailing_icon_color: th.on_surface.with_alpha_f32(0.38),
198            error_trailing_icon_color: th.error,
199            focused_label_color: th.primary,
200            unfocused_label_color: th.on_surface_variant,
201            disabled_label_color: th.on_surface.with_alpha_f32(0.38),
202            error_label_color: th.error,
203            focused_placeholder_color: th.on_surface_variant,
204            unfocused_placeholder_color: th.on_surface_variant,
205            disabled_placeholder_color: th.on_surface.with_alpha_f32(0.38),
206            error_placeholder_color: th.error,
207            focused_supporting_text_color: th.on_surface_variant,
208            unfocused_supporting_text_color: th.on_surface_variant,
209            disabled_supporting_text_color: th.on_surface.with_alpha_f32(0.38),
210            error_supporting_text_color: th.error,
211            focused_prefix_color: th.on_surface,
212            unfocused_prefix_color: th.on_surface,
213            disabled_prefix_color: th.on_surface.with_alpha_f32(0.38),
214            error_prefix_color: th.on_surface,
215            focused_suffix_color: th.on_surface,
216            unfocused_suffix_color: th.on_surface,
217            disabled_suffix_color: th.on_surface.with_alpha_f32(0.38),
218            error_suffix_color: th.on_surface,
219        }
220    }
221}
222
223/// Configuration for an `OutlinedTextField`.
224#[derive(Clone)]
225pub struct OutlinedTextFieldConfig {
226    /// Floating label shown above the input when the field has text or is focused.
227    /// When set, this acts as the visual placeholder (the TextField's own placeholder
228    /// is suppressed). When the label floats, it animates to the top border.
229    pub label: Option<String>,
230    /// Placeholder text shown inside the TextField when empty and unfocused.
231    /// Only shown when `label` is `None`; when a label is present the label
232    /// itself serves as the visual placeholder.
233    pub placeholder: Option<String>,
234    /// Icon displayed at the start of the input.
235    pub leading_icon: Option<View>,
236    /// Icon displayed at the end of the input.
237    pub trailing_icon: Option<View>,
238    /// If true, Enter submits; if false, Enter inserts a newline.
239    pub single_line: bool,
240    /// If true, border and label color switch to error color.
241    pub is_error: bool,
242    /// If false, input is visually disabled and `on_value_change` won't fire.
243    pub enabled: bool,
244    /// Called when the user presses Enter on a single-line field.
245    pub on_submit: Option<Rc<dyn Fn(String)>>,
246    /// Colors for all text field UI elements.
247    pub colors: Option<TextFieldColors>,
248    /// Optional external focus tracker. When `None`, an internal focus tracker
249    /// is created (keyed by label). Pass a tracker to synchronize focus state
250    /// (e.g. to avoid overriding external text while the user is editing).
251    pub focus_tracker: Option<Rc<Cell<bool>>>,
252}
253
254impl Default for OutlinedTextFieldConfig {
255    fn default() -> Self {
256        Self {
257            label: None,
258            placeholder: None,
259            leading_icon: None,
260            trailing_icon: None,
261            single_line: true,
262            is_error: false,
263            enabled: true,
264            on_submit: None,
265            colors: None,
266            focus_tracker: None,
267        }
268    }
269}
270
271/// M3 Outlined Text Field with floating label, leading/trailing icons, and error state.
272///
273/// The label floats up when `value` is non-empty or when the field is focused.
274/// Note: focus-based floating is approximated via animated `float_t` - the label
275/// begins floating once `on_value_change` fires (i.e. when the user types).
276/// For strict focus-on-tap floating, pair with an external focus signal.
277///
278/// # Example
279/// ```ignore
280/// let text = remember(|| signal(String::new()));
281/// OutlinedTextField(
282///     Modifier::new().fill_max_width().padding(16.0),
283///     text.get(),
284///     { let t = text.clone(); move |v| t.set(v) },
285///     OutlinedTextFieldConfig {
286///         label: Some("Email".into()),
287///         placeholder: Some("user@example.com".into()),
288///         ..Default::default()
289///     },
290/// );
291/// ```
292pub fn OutlinedTextField(
293    modifier: Modifier,
294    value: String,
295    on_value_change: impl Fn(String) + 'static,
296    config: OutlinedTextFieldConfig,
297) -> View {
298    let label_str: Option<Rc<str>> = config.label.clone().map(Rc::from);
299    let has_label = label_str.is_some();
300
301    // Unique animation key per label to avoid conflicts when multiple fields exist
302    let anim_key = match &label_str {
303        Some(l) => format!("otf_{}", &l[..l.len().min(32)]),
304        None => "otf_nolabel".into(),
305    };
306
307    // Persistent focus tracker - set by layout/paint when this field is focused,
308    // read here on the next frame. This gives a one-frame delay on tap-to-float,
309    // which is negligible at 60fps. An external tracker takes precedence.
310    let focus_tracker: Rc<Cell<bool>> = match config.focus_tracker.clone() {
311        Some(ft) => ft,
312        None => remember_with_key(format!("otf_focus_{}", anim_key), || Cell::new(false)),
313    };
314    let is_focused = focus_tracker.get();
315    let should_float = !value.is_empty() || is_focused;
316
317    let tf_placeholder = if has_label {
318        if should_float {
319            config.placeholder.clone().unwrap_or_default()
320        } else {
321            String::new()
322        }
323    } else {
324        config.placeholder.clone().unwrap_or_default()
325    };
326
327    let text_input = View::new(0, ViewKind::Box)
328        .modifier(
329            Modifier::new().flex_grow(1.0).text_input(TextInputConfig {
330                hint: tf_placeholder,
331                multiline: false,
332                on_change: Some(Rc::new(on_value_change) as _),
333                on_submit: config.on_submit.clone().map(|f| {
334                    let f = f.clone();
335                    Rc::new(move |s| f(s)) as Rc<dyn Fn(String)>
336                }),
337                focus_tracker: Some(focus_tracker),
338                value: value.clone(),
339                visual_transformation: None,
340                keyboard_type: Default::default(),
341                capitalization: Default::default(),
342                ime_action: Default::default(),
343                enabled: config.enabled,
344                read_only: false,
345                max_lines: None,
346                min_lines: 1,
347                cursor_color: config
348                    .colors
349                    .as_ref()
350                    .map(|c| c.cursor_color(config.is_error)),
351                on_text_layout: None,
352                text_style: None,
353                keyboard_actions: None,
354                interaction_source: None,
355                line_limits: None,
356            }),
357        )
358        .semantics(Semantics {
359            role: Role::TextField,
360            label: None,
361            focused: false,
362            enabled: true,
363            selectable_group: false,
364        });
365
366    outlined_field_decoration(
367        modifier,
368        anim_key,
369        label_str,
370        &config,
371        is_focused,
372        !value.is_empty(),
373        text_input,
374    )
375}
376
377/// State-based M3 Outlined Text Field.
378pub fn OutlinedTextFieldState(
379    modifier: Modifier,
380    state: Rc<RefCell<TextFieldState>>,
381    on_value_change: impl Fn(String) + 'static,
382    config: OutlinedTextFieldConfig,
383) -> View {
384    let label_str: Option<Rc<str>> = config.label.clone().map(Rc::from);
385    let has_label = label_str.is_some();
386
387    // Unique animation key per label to avoid conflicts when multiple fields exist
388    let anim_key = match &label_str {
389        Some(l) => format!("otf_{}", &l[..l.len().min(32)]),
390        None => "otf_nolabel".into(),
391    };
392
393    let focus_tracker: Rc<Cell<bool>> = match config.focus_tracker.clone() {
394        Some(ft) => ft,
395        None => remember_with_key(format!("otf_focus_{}", anim_key), || Cell::new(false)),
396    };
397    let is_focused = focus_tracker.get();
398    let has_content = !state.borrow().text.is_empty();
399    let should_float = has_content || is_focused;
400
401    // Placeholder shows when there's no label, or when label is floating (focused/has content)
402    let tf_placeholder = if has_label {
403        if should_float {
404            config.placeholder.clone().unwrap_or_default()
405        } else {
406            String::new()
407        }
408    } else {
409        config.placeholder.clone().unwrap_or_default()
410    };
411
412    let text_input = BasicTextField(
413        state,
414        Modifier::new().flex_grow(1.0),
415        tf_placeholder,
416        BasicTextFieldConfig {
417            line_limits: if config.single_line {
418                TextFieldLineLimits::SingleLine
419            } else {
420                TextFieldLineLimits::MultiLine {
421                    min_height_in_lines: 1,
422                    max_height_in_lines: usize::MAX,
423                }
424            },
425            on_change: Some(Rc::new(on_value_change)),
426            on_submit: config.on_submit.clone(),
427            focus_tracker: Some(focus_tracker),
428            enabled: config.enabled,
429            ..Default::default()
430        },
431    );
432
433    outlined_field_decoration(
434        modifier,
435        anim_key,
436        label_str,
437        &config,
438        is_focused,
439        has_content,
440        text_input,
441    )
442}
443
444fn outlined_field_decoration(
445    modifier: Modifier,
446    anim_key: String,
447    label_str: Option<Rc<str>>,
448    config: &OutlinedTextFieldConfig,
449    is_focused: bool,
450    has_content: bool,
451    text_input: View,
452) -> View {
453    let th = theme();
454    let has_label = label_str.is_some();
455
456    let should_float = has_content || is_focused;
457    let float_t = animate_f32(
458        anim_key.clone(),
459        if should_float { 1.0 } else { 0.0 },
460        th.motion.color,
461    );
462
463    let target_border_w = if config.is_error || should_float {
464        OutlinedTextFieldDefaults::FOCUSED_BORDER_THICKNESS
465    } else {
466        OutlinedTextFieldDefaults::UNFOCUSED_BORDER_THICKNESS
467    };
468    let border_w = animate_f32(
469        format!("otf_bw_{}", anim_key),
470        target_border_w,
471        th.motion.color,
472    );
473
474    let (border_color, label_color, container_bg) = if let Some(ref tc) = config.colors {
475        (
476            tc.indicator_color(config.enabled, config.is_error, is_focused),
477            tc.label_color(config.enabled, config.is_error, is_focused),
478            tc.container_color(config.enabled, config.is_error, is_focused),
479        )
480    } else {
481        (
482            if config.is_error {
483                th.error
484            } else if is_focused {
485                th.primary
486            } else {
487                th.outline
488            },
489            if config.is_error {
490                th.error
491            } else if is_focused {
492                th.primary
493            } else {
494                th.on_surface_variant
495            },
496            th.surface,
497        )
498    };
499
500    // Label font size: 16dp (expanded, inside) -> 12dp (minimized, at border)
501    let label_size = 16.0 - 4.0 * float_t;
502
503    // Minimized label half-height matches bodySmall line height (~16dp) / 2
504    let min_label_half_h: f32 = if has_label { 8.0 } else { 0.0 };
505
506    // Label Y: expanded centered within 56dp field -> minimized overlapping top border (-labelHeight/2)
507    let label_start_y = (56.0 - 16.0) / 2.0;
508    let label_end_y = -min_label_half_h;
509    let label_y = label_start_y - (label_start_y - label_end_y) * float_t;
510
511    // Label X: expanded at text-input start (~24dp) -> minimized at border-start (~20dp)
512    let label_start_x = if has_label { 24.0 } else { 0.0 };
513    let label_end_x = if has_label { 20.0 } else { 0.0 };
514    let label_x = label_start_x - (label_start_x - label_end_x) * float_t;
515
516    // Container padding matches reference: 8dp top/bottom with label, 16dp without
517    let (top_pad, bottom_pad) = if has_label { (8.0, 8.0) } else { (16.0, 16.0) };
518
519    // Outer Stack holds both the clipped content and the unclipped label.
520    // The label sits outside the clipped Box so it can extend above the border.
521    let label_cutout = label_str.as_ref().map(|lbl| {
522        let font_px = dp_to_px(label_size) * repose_core::locals::text_scale().0;
523        let m = measure_text(lbl, font_px, TextMeasureConfig::default());
524        let text_width_px = m.positions.last().copied().unwrap_or(0.0);
525        let text_width_dp = px_to_dp(text_width_px);
526        let pad = 1.0;
527        let line_h = 16.0;
528        (
529            label_x - pad,
530            label_y - pad,
531            label_x + text_width_dp + pad,
532            label_y + line_h + pad,
533        )
534    });
535
536    ZStack(
537        modifier
538            .min_height(OutlinedTextFieldDefaults::MIN_HEIGHT)
539            .min_width(OutlinedTextFieldDefaults::MIN_WIDTH),
540    )
541    .child((
542        // Background layer -> no border, full surface color (no notch)
543        Box(Modifier::new()
544            .fill_max_size()
545            .clip_rounded(th.shapes.small)
546            .background(container_bg)),
547        // Border layer -> drawn on top of background, with notch for the label
548        if has_label {
549            let mut bm = Modifier::new()
550                .fill_max_size()
551                .clip_rounded(th.shapes.small)
552                .border(border_w, border_color, th.shapes.small);
553            if let Some((l, t, r, b)) = label_cutout {
554                bm = bm.clip_rect(l, t, r, b, ClipOp::Difference);
555            }
556            Box(bm)
557        } else {
558            Box(Modifier::new()
559                .fill_max_size()
560                .clip_rounded(th.shapes.small)
561                .border(border_w, border_color, th.shapes.small))
562        },
563        // Content layer -> text input with proper padding
564        Row(Modifier::new()
565            .fill_max_size()
566            .padding_values(PaddingValues {
567                left: 16.0,
568                right: 16.0,
569                top: top_pad,
570                bottom: bottom_pad,
571            })
572            .align_items(AlignItems::CENTER))
573        .child((
574            config.leading_icon.clone().unwrap_or(Box(Modifier::new())),
575            text_input,
576            config.trailing_icon.clone().unwrap_or(Box(Modifier::new())),
577        )),
578        // Floating label -> plain text, no background chip (matches M3 reference)
579        if let Some(lbl) = label_str {
580            Box(Modifier::new()
581                .min_width(200.0)
582                .padding_values(PaddingValues {
583                    left: label_x,
584                    right: 20.0,
585                    top: 0.0,
586                    bottom: 0.0,
587                })
588                .absolute()
589                .offset(Some(0.0), Some(label_y), None, None))
590            .child(
591                Text(lbl.as_ref().to_string())
592                    .color(label_color)
593                    .size(label_size),
594            )
595        } else {
596            Box(Modifier::new())
597        },
598    ))
599}
600
601/// Configuration for a filled M3 [`TextField`].
602#[derive(Clone)]
603pub struct TextFieldConfig {
604    pub label: Option<String>,
605    pub placeholder: Option<String>,
606    pub leading_icon: Option<View>,
607    pub trailing_icon: Option<View>,
608    pub single_line: bool,
609    pub is_error: bool,
610    pub enabled: bool,
611    pub on_submit: Option<Rc<dyn Fn(String)>>,
612    pub colors: Option<TextFieldColors>,
613}
614
615impl Default for TextFieldConfig {
616    fn default() -> Self {
617        Self {
618            label: None,
619            placeholder: None,
620            leading_icon: None,
621            trailing_icon: None,
622            single_line: true,
623            is_error: false,
624            enabled: true,
625            on_submit: None,
626            colors: None,
627        }
628    }
629}
630
631/// M3 Filled Text Field with floating label, leading/trailing icons, error state,
632/// and a bottom indicator line. (Equivalent to Compose Material3's `TextField`.)
633///
634/// The label floats up when `value` is non-empty or when the field is focused.
635/// Container: `SurfaceContainerHighest` bg, top-rounded corners (4dp), flat bottom.
636/// Indicator: always visible, 1dp (unfocused) / 2dp (focused/error), animated color+thickness.
637pub fn TextField(
638    modifier: Modifier,
639    value: String,
640    on_value_change: impl Fn(String) + 'static,
641    config: TextFieldConfig,
642) -> View {
643    let th = theme();
644    let label_str: Option<Rc<str>> = config.label.map(Rc::from);
645    let has_label = label_str.is_some();
646
647    let anim_key = match &label_str {
648        Some(l) => format!("tf_{}", &l[..l.len().min(32)]),
649        None => "tf_nolabel".into(),
650    };
651
652    let focus_tracker: Rc<Cell<bool>> =
653        remember_with_key(format!("tf_focus_{}", anim_key), || Cell::new(false));
654    let is_focused = focus_tracker.get();
655    let should_float = !value.is_empty() || is_focused;
656
657    let float_t = animate_f32(
658        anim_key.clone(),
659        if should_float { 1.0 } else { 0.0 },
660        th.motion.color,
661    );
662
663    let (indicator_color, label_color, container_bg) = if let Some(ref tc) = config.colors {
664        let enf = config.enabled && is_focused;
665        let ind = tc.indicator_color(config.enabled, config.is_error, enf);
666        let lb = tc.label_color(config.enabled, config.is_error, enf);
667        let bg = tc.container_color(config.enabled, config.is_error, enf);
668        (ind, lb, bg)
669    } else {
670        let ind = if config.is_error {
671            th.error
672        } else if float_t > 0.5 {
673            th.primary
674        } else {
675            th.on_surface_variant
676        };
677        let lb = if config.is_error {
678            th.error
679        } else if float_t > 0.5 {
680            th.primary
681        } else {
682            th.on_surface_variant
683        };
684        let bg = if config.enabled {
685            th.surface_container_highest
686        } else {
687            th.on_surface
688                .with_alpha_f32(0.04)
689                .composite_over(th.surface)
690        };
691        (ind, lb, bg)
692    };
693
694    let label_size = 16.0 - 4.0 * float_t;
695
696    let label_start_y = (56.0 - 16.0) / 2.0;
697    let label_end_y = if has_label { 8.0 } else { 0.0 };
698    let label_y = label_start_y - (label_start_y - label_end_y) * float_t;
699
700    let label_start_x = if has_label { 24.0 } else { 0.0 };
701    let label_end_x = if has_label { 20.0 } else { 0.0 };
702    let label_x = label_start_x - (label_start_x - label_end_x) * float_t;
703
704    let tf_placeholder = if has_label {
705        if should_float {
706            config.placeholder.unwrap_or_default()
707        } else {
708            String::new()
709        }
710    } else {
711        config.placeholder.unwrap_or_default()
712    };
713
714    let indicator_active = config.is_error || (config.enabled && is_focused);
715    let indicator_target_w = if indicator_active { 2.0 } else { 1.0 };
716    let indicator_w = animate_f32(
717        format!("tf_ind_w_{}", anim_key),
718        indicator_target_w,
719        th.motion.color,
720    );
721
722    let (top_pad, bottom_pad) = if has_label { (8.0, 8.0) } else { (16.0, 16.0) };
723
724    Column(
725        modifier
726            .min_height(TextFieldDefaults::MIN_HEIGHT)
727            .min_width(TextFieldDefaults::MIN_WIDTH),
728    )
729    .child((
730        // Clipped background and input content
731        Box(Modifier::new()
732            .fill_max_size()
733            .clip_rounded(th.shapes.extra_small)
734            .background(container_bg))
735        .child(
736            Column(Modifier::new().fill_max_size()).child((
737                // Input row
738                Row(Modifier::new()
739                    .fill_max_size()
740                    .padding_values(PaddingValues {
741                        left: 16.0,
742                        right: 16.0,
743                        top: top_pad,
744                        bottom: bottom_pad,
745                    })
746                    .align_items(AlignItems::CENTER))
747                .child((
748                    config.leading_icon.unwrap_or(Box(Modifier::new())),
749                    View::new(0, ViewKind::Box)
750                        .modifier(
751                            Modifier::new().flex_grow(1.0).text_input(TextInputConfig {
752                                hint: tf_placeholder,
753                                multiline: !config.single_line,
754                                on_change: Some(Rc::new(on_value_change) as _),
755                                on_submit: config.on_submit.clone().map(|f| {
756                                    let f = f.clone();
757                                    Rc::new(move |s| f(s)) as Rc<dyn Fn(String)>
758                                }),
759                                focus_tracker: Some(focus_tracker.clone()),
760                                value: value.clone(),
761                                visual_transformation: None,
762                                keyboard_type: Default::default(),
763                                capitalization: Default::default(),
764                                ime_action: Default::default(),
765                                enabled: config.enabled,
766                                read_only: false,
767                                max_lines: None,
768                                min_lines: 1,
769                                cursor_color: config
770                                    .colors
771                                    .as_ref()
772                                    .map(|c| c.cursor_color(config.is_error)),
773                                on_text_layout: None,
774                                text_style: None,
775                                keyboard_actions: None,
776                                interaction_source: None,
777                                line_limits: None,
778                            }),
779                        )
780                        .semantics(Semantics {
781                            role: Role::TextField,
782                            label: None,
783                            focused: false,
784                            enabled: true,
785                            selectable_group: false,
786                        }),
787                    config.trailing_icon.unwrap_or(Box(Modifier::new())),
788                )),
789                // Bottom indicator line
790                Box(Modifier::new()
791                    .fill_max_width()
792                    .height(indicator_w)
793                    .absolute()
794                    .offset(None, None, None, Some(0.0))
795                    .background(indicator_color)),
796            )),
797        ),
798        // Floating label
799        if let Some(lbl) = label_str {
800            Box(Modifier::new()
801                .min_width(200.0)
802                .padding_values(PaddingValues {
803                    left: label_x,
804                    right: 20.0,
805                    top: 0.0,
806                    bottom: 0.0,
807                })
808                .absolute()
809                .offset(Some(0.0), Some(label_y), None, None))
810            .child(
811                Text(lbl.as_ref().to_string())
812                    .color(label_color)
813                    .size(label_size),
814            )
815        } else {
816            Box(Modifier::new())
817        },
818    ))
819}