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, TextFieldConfig as BasicTextFieldConfig,
9    TextFieldState, TextStyle, ViewExt, ZStack,
10    anim::animate_f32,
11    textfield::{TextMeasureConfig, measure_text},
12};
13
14use super::*;
15
16/// Color slots for text fields -> matches Compose Material3 `TextFieldColors`.
17/// All 42 color fields (focused/unfocused/disabled/error variants of each slot).
18#[allow(dead_code)]
19#[derive(Clone, Debug)]
20pub struct TextFieldColors {
21    pub focused_text_color: Color,
22    pub unfocused_text_color: Color,
23    pub disabled_text_color: Color,
24    pub error_text_color: Color,
25    pub focused_container_color: Color,
26    pub unfocused_container_color: Color,
27    pub disabled_container_color: Color,
28    pub error_container_color: Color,
29    pub cursor_color: Color,
30    pub error_cursor_color: Color,
31    pub focused_indicator_color: Color,
32    pub unfocused_indicator_color: Color,
33    pub disabled_indicator_color: Color,
34    pub error_indicator_color: Color,
35    pub focused_leading_icon_color: Color,
36    pub unfocused_leading_icon_color: Color,
37    pub disabled_leading_icon_color: Color,
38    pub error_leading_icon_color: Color,
39    pub focused_trailing_icon_color: Color,
40    pub unfocused_trailing_icon_color: Color,
41    pub disabled_trailing_icon_color: Color,
42    pub error_trailing_icon_color: Color,
43    pub focused_label_color: Color,
44    pub unfocused_label_color: Color,
45    pub disabled_label_color: Color,
46    pub error_label_color: Color,
47    pub focused_placeholder_color: Color,
48    pub unfocused_placeholder_color: Color,
49    pub disabled_placeholder_color: Color,
50    pub error_placeholder_color: Color,
51    pub focused_supporting_text_color: Color,
52    pub unfocused_supporting_text_color: Color,
53    pub disabled_supporting_text_color: Color,
54    pub error_supporting_text_color: Color,
55    pub focused_prefix_color: Color,
56    pub unfocused_prefix_color: Color,
57    pub disabled_prefix_color: Color,
58    pub error_prefix_color: Color,
59    pub focused_suffix_color: Color,
60    pub unfocused_suffix_color: Color,
61    pub disabled_suffix_color: Color,
62    pub error_suffix_color: Color,
63}
64
65#[allow(dead_code)]
66impl TextFieldColors {
67    pub fn text_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
68        if !enabled {
69            self.disabled_text_color
70        } else if is_error {
71            self.error_text_color
72        } else if focused {
73            self.focused_text_color
74        } else {
75            self.unfocused_text_color
76        }
77    }
78    pub fn container_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
79        if !enabled {
80            self.disabled_container_color
81        } else if is_error {
82            self.error_container_color
83        } else if focused {
84            self.focused_container_color
85        } else {
86            self.unfocused_container_color
87        }
88    }
89    pub fn cursor_color(&self, is_error: bool) -> Color {
90        if is_error {
91            self.error_cursor_color
92        } else {
93            self.cursor_color
94        }
95    }
96    pub fn indicator_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
97        if !enabled {
98            self.disabled_indicator_color
99        } else if is_error {
100            self.error_indicator_color
101        } else if focused {
102            self.focused_indicator_color
103        } else {
104            self.unfocused_indicator_color
105        }
106    }
107    pub fn leading_icon_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
108        if !enabled {
109            self.disabled_leading_icon_color
110        } else if is_error {
111            self.error_leading_icon_color
112        } else if focused {
113            self.focused_leading_icon_color
114        } else {
115            self.unfocused_leading_icon_color
116        }
117    }
118    pub fn trailing_icon_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
119        if !enabled {
120            self.disabled_trailing_icon_color
121        } else if is_error {
122            self.error_trailing_icon_color
123        } else if focused {
124            self.focused_trailing_icon_color
125        } else {
126            self.unfocused_trailing_icon_color
127        }
128    }
129    pub fn label_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
130        if !enabled {
131            self.disabled_label_color
132        } else if is_error {
133            self.error_label_color
134        } else if focused {
135            self.focused_label_color
136        } else {
137            self.unfocused_label_color
138        }
139    }
140    pub fn placeholder_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
141        if !enabled {
142            self.disabled_placeholder_color
143        } else if is_error {
144            self.error_placeholder_color
145        } else if focused {
146            self.focused_placeholder_color
147        } else {
148            self.unfocused_placeholder_color
149        }
150    }
151    pub fn supporting_text_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
152        if !enabled {
153            self.disabled_supporting_text_color
154        } else if is_error {
155            self.error_supporting_text_color
156        } else if focused {
157            self.focused_supporting_text_color
158        } else {
159            self.unfocused_supporting_text_color
160        }
161    }
162}
163
164/// Default values for text field colors.
165pub struct TextFieldDefaults;
166
167impl TextFieldDefaults {
168    /// Default minimum height for a filled TextField (56dp matches M3 spec).
169    pub const MIN_HEIGHT: f32 = 56.0;
170    /// Default minimum width for a filled TextField (280dp matches M3 spec).
171    pub const MIN_WIDTH: f32 = 280.0;
172
173    pub fn colors() -> TextFieldColors {
174        let th = theme();
175        TextFieldColors {
176            focused_text_color: th.on_surface,
177            unfocused_text_color: th.on_surface,
178            disabled_text_color: th.on_surface.with_alpha_f32(0.38),
179            error_text_color: th.on_surface,
180            focused_container_color: th.surface_container_highest,
181            unfocused_container_color: th.surface_container_highest,
182            disabled_container_color: th.on_surface.with_alpha_f32(0.04),
183            error_container_color: th.surface_container_highest,
184            cursor_color: th.primary,
185            error_cursor_color: th.error,
186            focused_indicator_color: th.primary,
187            unfocused_indicator_color: th.on_surface_variant,
188            disabled_indicator_color: th.on_surface.with_alpha_f32(0.12),
189            error_indicator_color: th.error,
190            focused_leading_icon_color: th.on_surface_variant,
191            unfocused_leading_icon_color: th.on_surface_variant,
192            disabled_leading_icon_color: th.on_surface.with_alpha_f32(0.38),
193            error_leading_icon_color: th.error,
194            focused_trailing_icon_color: th.on_surface_variant,
195            unfocused_trailing_icon_color: th.on_surface_variant,
196            disabled_trailing_icon_color: th.on_surface.with_alpha_f32(0.38),
197            error_trailing_icon_color: th.error,
198            focused_label_color: th.primary,
199            unfocused_label_color: th.on_surface_variant,
200            disabled_label_color: th.on_surface.with_alpha_f32(0.38),
201            error_label_color: th.error,
202            focused_placeholder_color: th.on_surface_variant,
203            unfocused_placeholder_color: th.on_surface_variant,
204            disabled_placeholder_color: th.on_surface.with_alpha_f32(0.38),
205            error_placeholder_color: th.error,
206            focused_supporting_text_color: th.on_surface_variant,
207            unfocused_supporting_text_color: th.on_surface_variant,
208            disabled_supporting_text_color: th.on_surface.with_alpha_f32(0.38),
209            error_supporting_text_color: th.error,
210            focused_prefix_color: th.on_surface,
211            unfocused_prefix_color: th.on_surface,
212            disabled_prefix_color: th.on_surface.with_alpha_f32(0.38),
213            error_prefix_color: th.on_surface,
214            focused_suffix_color: th.on_surface,
215            unfocused_suffix_color: th.on_surface,
216            disabled_suffix_color: th.on_surface.with_alpha_f32(0.38),
217            error_suffix_color: th.on_surface,
218        }
219    }
220}
221
222/// Configuration for an `OutlinedTextField`.
223#[derive(Clone)]
224pub struct OutlinedTextFieldConfig {
225    /// Floating label shown above the input when the field has text or is focused.
226    /// When set, this acts as the visual placeholder (the TextField's own placeholder
227    /// is suppressed). When the label floats, it animates to the top border.
228    pub label: Option<String>,
229    /// Placeholder text shown inside the TextField when empty and unfocused.
230    /// Only shown when `label` is `None`; when a label is present the label
231    /// itself serves as the visual placeholder.
232    pub placeholder: Option<String>,
233    /// Icon displayed at the start of the input.
234    pub leading_icon: Option<View>,
235    /// Icon displayed at the end of the input.
236    pub trailing_icon: Option<View>,
237    /// If true, Enter submits; if false, Enter inserts a newline.
238    pub single_line: bool,
239    /// If true, border and label color switch to error color.
240    pub is_error: bool,
241    /// If false, input is visually disabled and `on_value_change` won't fire.
242    pub enabled: bool,
243    /// Called when the user presses Enter on a single-line field.
244    pub on_submit: Option<Rc<dyn Fn(String)>>,
245    /// Colors for all text field UI elements.
246    pub colors: Option<TextFieldColors>,
247    /// Optional external focus tracker. When `None`, an internal focus tracker
248    /// is created (keyed by label). Pass a tracker to synchronize focus state
249    /// (e.g. to avoid overriding external text while the user is editing).
250    pub focus_tracker: Option<Rc<Cell<bool>>>,
251}
252
253impl Default for OutlinedTextFieldConfig {
254    fn default() -> Self {
255        Self {
256            label: None,
257            placeholder: None,
258            leading_icon: None,
259            trailing_icon: None,
260            single_line: true,
261            is_error: false,
262            enabled: true,
263            on_submit: None,
264            colors: None,
265            focus_tracker: None,
266        }
267    }
268}
269
270/// M3 Outlined Text Field with floating label, leading/trailing icons, and error state.
271///
272/// The label floats up when `value` is non-empty or when the field is focused.
273/// Note: focus-based floating is approximated via animated `float_t` - the label
274/// begins floating once `on_value_change` fires (i.e. when the user types).
275/// For strict focus-on-tap floating, pair with an external focus signal.
276///
277/// # Example
278/// ```ignore
279/// let text = remember(|| signal(String::new()));
280/// OutlinedTextField(
281///     Modifier::new().fill_max_width().padding(16.0),
282///     text.get(),
283///     { let t = text.clone(); move |v| t.set(v) },
284///     OutlinedTextFieldConfig {
285///         label: Some("Email".into()),
286///         placeholder: Some("user@example.com".into()),
287///         ..Default::default()
288///     },
289/// );
290/// ```
291pub fn OutlinedTextField(
292    modifier: Modifier,
293    value: String,
294    on_value_change: impl Fn(String) + 'static,
295    config: OutlinedTextFieldConfig,
296) -> View {
297    let label_str: Option<Rc<str>> = config.label.clone().map(Rc::from);
298    let has_label = label_str.is_some();
299
300    // Unique animation key per label to avoid conflicts when multiple fields exist
301    let anim_key = match &label_str {
302        Some(l) => format!("otf_{}", &l[..l.len().min(32)]),
303        None => "otf_nolabel".into(),
304    };
305
306    // Persistent focus tracker - set by layout/paint when this field is focused,
307    // read here on the next frame. This gives a one-frame delay on tap-to-float,
308    // which is negligible at 60fps. An external tracker takes precedence.
309    let focus_tracker: Rc<Cell<bool>> = match config.focus_tracker.clone() {
310        Some(ft) => ft,
311        None => remember_with_key(format!("otf_focus_{}", anim_key), || Cell::new(false)),
312    };
313    let is_focused = focus_tracker.get();
314    let should_float = !value.is_empty() || is_focused;
315
316    let tf_placeholder = if has_label {
317        if should_float {
318            config.placeholder.clone().unwrap_or_default()
319        } else {
320            String::new()
321        }
322    } else {
323        config.placeholder.clone().unwrap_or_default()
324    };
325
326    let text_input = View::new(0, ViewKind::Box)
327        .modifier(
328            Modifier::new().flex_grow(1.0).text_input(TextInputConfig {
329                hint: tf_placeholder,
330                multiline: false,
331                on_change: Some(Rc::new(on_value_change) as _),
332                on_submit: config.on_submit.clone().map(|f| {
333                    let f = f.clone();
334                    Rc::new(move |s| f(s)) as Rc<dyn Fn(String)>
335                }),
336                focus_tracker: Some(focus_tracker),
337                value: value.clone(),
338                visual_transformation: None,
339                keyboard_type: Default::default(),
340                capitalization: Default::default(),
341                ime_action: Default::default(),
342                auto_correct_enabled: None,
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                                auto_correct_enabled: None,
766                                enabled: config.enabled,
767                                read_only: false,
768                                max_lines: None,
769                                min_lines: 1,
770                                cursor_color: config
771                                    .colors
772                                    .as_ref()
773                                    .map(|c| c.cursor_color(config.is_error)),
774                                on_text_layout: None,
775                                text_style: None,
776                                keyboard_actions: None,
777                                interaction_source: None,
778                                line_limits: None,
779                            }),
780                        )
781                        .semantics(Semantics {
782                            role: Role::TextField,
783                            label: None,
784                            focused: false,
785                            enabled: true,
786                            selectable_group: false,
787                        }),
788                    config.trailing_icon.unwrap_or(Box(Modifier::new())),
789                )),
790                // Bottom indicator line
791                Box(Modifier::new()
792                    .fill_max_width()
793                    .height(indicator_w)
794                    .absolute()
795                    .offset(None, None, None, Some(0.0))
796                    .background(indicator_color)),
797            )),
798        ),
799        // Floating label
800        if let Some(lbl) = label_str {
801            Box(Modifier::new()
802                .min_width(200.0)
803                .padding_values(PaddingValues {
804                    left: label_x,
805                    right: 20.0,
806                    top: 0.0,
807                    bottom: 0.0,
808                })
809                .absolute()
810                .offset(Some(0.0), Some(label_y), None, None))
811            .child(
812                Text(lbl.as_ref().to_string())
813                    .color(label_color)
814                    .size(label_size),
815            )
816        } else {
817            Box(Modifier::new())
818        },
819    ))
820}