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;
5use std::sync::atomic::{AtomicU64, Ordering};
6
7use repose_core::*;
8use repose_ui::{
9    BasicTextField, Box, Column, Row, Text, TextFieldConfig as BasicTextFieldConfig,
10    TextFieldState, TextStyle, ViewExt, ZStack,
11    anim::animate_f32,
12    textfield::{TextMeasureConfig, measure_text},
13};
14
15use super::*;
16
17static OTF_COUNTER: AtomicU64 = AtomicU64::new(0);
18static OTFS_COUNTER: AtomicU64 = AtomicU64::new(0);
19static TF_COUNTER: AtomicU64 = AtomicU64::new(0);
20
21/// Tint a leading icon with the M3 icon color (12dp gap to the input, Compose style).
22fn tint_icon(color: Color, icon: Option<View>) -> View {
23    match icon {
24        Some(v) => Box(Modifier::new().padding_values(PaddingValues {
25            left: 0.0,
26            right: 12.0,
27            top: 0.0,
28            bottom: 0.0,
29        }))
30        .child(with_content_color(color, move || v)),
31        None => Box(Modifier::new()),
32    }
33}
34
35/// Tint a trailing icon with the M3 icon color (12dp gap to the input, Compose style).
36fn tint_trailing_icon(color: Color, icon: Option<View>) -> View {
37    match icon {
38        Some(v) => Box(Modifier::new().padding_values(PaddingValues {
39            left: 12.0,
40            right: 0.0,
41            top: 0.0,
42            bottom: 0.0,
43        }))
44        .child(with_content_color(color, move || v)),
45        None => Box(Modifier::new()),
46    }
47}
48
49/// Color slots for text fields -> matches Compose Material3 `TextFieldColors`.
50/// All 42 color fields (focused/unfocused/disabled/error variants of each slot).
51#[allow(dead_code)]
52#[derive(Clone, Debug)]
53pub struct TextFieldColors {
54    pub focused_text_color: Color,
55    pub unfocused_text_color: Color,
56    pub disabled_text_color: Color,
57    pub error_text_color: Color,
58    pub focused_container_color: Color,
59    pub unfocused_container_color: Color,
60    pub disabled_container_color: Color,
61    pub error_container_color: Color,
62    pub cursor_color: Color,
63    pub error_cursor_color: Color,
64    pub focused_indicator_color: Color,
65    pub unfocused_indicator_color: Color,
66    pub disabled_indicator_color: Color,
67    pub error_indicator_color: Color,
68    pub focused_leading_icon_color: Color,
69    pub unfocused_leading_icon_color: Color,
70    pub disabled_leading_icon_color: Color,
71    pub error_leading_icon_color: Color,
72    pub focused_trailing_icon_color: Color,
73    pub unfocused_trailing_icon_color: Color,
74    pub disabled_trailing_icon_color: Color,
75    pub error_trailing_icon_color: Color,
76    pub focused_label_color: Color,
77    pub unfocused_label_color: Color,
78    pub disabled_label_color: Color,
79    pub error_label_color: Color,
80    pub focused_placeholder_color: Color,
81    pub unfocused_placeholder_color: Color,
82    pub disabled_placeholder_color: Color,
83    pub error_placeholder_color: Color,
84    pub focused_supporting_text_color: Color,
85    pub unfocused_supporting_text_color: Color,
86    pub disabled_supporting_text_color: Color,
87    pub error_supporting_text_color: Color,
88    pub focused_prefix_color: Color,
89    pub unfocused_prefix_color: Color,
90    pub disabled_prefix_color: Color,
91    pub error_prefix_color: Color,
92    pub focused_suffix_color: Color,
93    pub unfocused_suffix_color: Color,
94    pub disabled_suffix_color: Color,
95    pub error_suffix_color: Color,
96}
97
98#[allow(dead_code)]
99impl TextFieldColors {
100    pub fn text_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
101        if !enabled {
102            self.disabled_text_color
103        } else if is_error {
104            self.error_text_color
105        } else if focused {
106            self.focused_text_color
107        } else {
108            self.unfocused_text_color
109        }
110    }
111    pub fn container_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
112        if !enabled {
113            self.disabled_container_color
114        } else if is_error {
115            self.error_container_color
116        } else if focused {
117            self.focused_container_color
118        } else {
119            self.unfocused_container_color
120        }
121    }
122    pub fn cursor_color(&self, is_error: bool) -> Color {
123        if is_error {
124            self.error_cursor_color
125        } else {
126            self.cursor_color
127        }
128    }
129    pub fn indicator_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
130        if !enabled {
131            self.disabled_indicator_color
132        } else if is_error {
133            self.error_indicator_color
134        } else if focused {
135            self.focused_indicator_color
136        } else {
137            self.unfocused_indicator_color
138        }
139    }
140    pub fn leading_icon_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
141        if !enabled {
142            self.disabled_leading_icon_color
143        } else if is_error {
144            self.error_leading_icon_color
145        } else if focused {
146            self.focused_leading_icon_color
147        } else {
148            self.unfocused_leading_icon_color
149        }
150    }
151    pub fn trailing_icon_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
152        if !enabled {
153            self.disabled_trailing_icon_color
154        } else if is_error {
155            self.error_trailing_icon_color
156        } else if focused {
157            self.focused_trailing_icon_color
158        } else {
159            self.unfocused_trailing_icon_color
160        }
161    }
162    pub fn label_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
163        if !enabled {
164            self.disabled_label_color
165        } else if is_error {
166            self.error_label_color
167        } else if focused {
168            self.focused_label_color
169        } else {
170            self.unfocused_label_color
171        }
172    }
173    pub fn placeholder_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
174        if !enabled {
175            self.disabled_placeholder_color
176        } else if is_error {
177            self.error_placeholder_color
178        } else if focused {
179            self.focused_placeholder_color
180        } else {
181            self.unfocused_placeholder_color
182        }
183    }
184    pub fn supporting_text_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
185        if !enabled {
186            self.disabled_supporting_text_color
187        } else if is_error {
188            self.error_supporting_text_color
189        } else if focused {
190            self.focused_supporting_text_color
191        } else {
192            self.unfocused_supporting_text_color
193        }
194    }
195    pub fn prefix_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
196        if !enabled {
197            self.disabled_prefix_color
198        } else if is_error {
199            self.error_prefix_color
200        } else if focused {
201            self.focused_prefix_color
202        } else {
203            self.unfocused_prefix_color
204        }
205    }
206    pub fn suffix_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
207        if !enabled {
208            self.disabled_suffix_color
209        } else if is_error {
210            self.error_suffix_color
211        } else if focused {
212            self.focused_suffix_color
213        } else {
214            self.unfocused_suffix_color
215        }
216    }
217}
218
219/// Default values for text field colors.
220pub struct TextFieldDefaults;
221
222impl TextFieldDefaults {
223    /// Default minimum height for a filled TextField (56dp matches M3 spec).
224    pub const MIN_HEIGHT: f32 = 56.0;
225    /// Default minimum width for a filled TextField (280dp matches M3 spec).
226    pub const MIN_WIDTH: f32 = 280.0;
227
228    pub fn colors() -> TextFieldColors {
229        let th = theme();
230        TextFieldColors {
231            focused_text_color: th.on_surface,
232            unfocused_text_color: th.on_surface,
233            disabled_text_color: th.on_surface.with_alpha_f32(0.38),
234            error_text_color: th.on_surface,
235            focused_container_color: th.surface_container_highest,
236            unfocused_container_color: th.surface_container_highest,
237            disabled_container_color: th.on_surface.with_alpha_f32(0.04),
238            error_container_color: th.surface_container_highest,
239            cursor_color: th.primary,
240            error_cursor_color: th.error,
241            focused_indicator_color: th.primary,
242            unfocused_indicator_color: th.on_surface_variant,
243            disabled_indicator_color: th.on_surface.with_alpha_f32(0.12),
244            error_indicator_color: th.error,
245            focused_leading_icon_color: th.on_surface_variant,
246            unfocused_leading_icon_color: th.on_surface_variant,
247            disabled_leading_icon_color: th.on_surface.with_alpha_f32(0.38),
248            error_leading_icon_color: th.error,
249            focused_trailing_icon_color: th.on_surface_variant,
250            unfocused_trailing_icon_color: th.on_surface_variant,
251            disabled_trailing_icon_color: th.on_surface.with_alpha_f32(0.38),
252            error_trailing_icon_color: th.error,
253            focused_label_color: th.primary,
254            unfocused_label_color: th.on_surface_variant,
255            disabled_label_color: th.on_surface.with_alpha_f32(0.38),
256            error_label_color: th.error,
257            focused_placeholder_color: th.on_surface_variant,
258            unfocused_placeholder_color: th.on_surface_variant,
259            disabled_placeholder_color: th.on_surface.with_alpha_f32(0.38),
260            error_placeholder_color: th.error,
261            focused_supporting_text_color: th.on_surface_variant,
262            unfocused_supporting_text_color: th.on_surface_variant,
263            disabled_supporting_text_color: th.on_surface.with_alpha_f32(0.38),
264            error_supporting_text_color: th.error,
265            focused_prefix_color: th.on_surface,
266            unfocused_prefix_color: th.on_surface,
267            disabled_prefix_color: th.on_surface.with_alpha_f32(0.38),
268            error_prefix_color: th.on_surface,
269            focused_suffix_color: th.on_surface,
270            unfocused_suffix_color: th.on_surface,
271            disabled_suffix_color: th.on_surface.with_alpha_f32(0.38),
272            error_suffix_color: th.on_surface,
273        }
274    }
275}
276
277/// Configuration for an `OutlinedTextField`.
278#[derive(Clone)]
279pub struct OutlinedTextFieldConfig {
280    /// Floating label shown above the input when the field has text or is focused.
281    /// When set, this acts as the visual placeholder (the TextField's own placeholder
282    /// is suppressed). When the label floats, it animates to the top border.
283    pub label: Option<String>,
284    /// Placeholder text shown inside the TextField when empty and unfocused.
285    /// Only shown when `label` is `None`; when a label is present the label
286    /// itself serves as the visual placeholder.
287    pub placeholder: Option<String>,
288    /// Icon displayed at the start of the input.
289    pub leading_icon: Option<View>,
290    /// Icon displayed at the end of the input.
291    pub trailing_icon: Option<View>,
292    /// If true, Enter submits; if false, Enter inserts a newline.
293    pub single_line: bool,
294    /// If true, border and label color switch to error color.
295    pub is_error: bool,
296    /// If false, input is visually disabled and `on_value_change` won't fire.
297    pub enabled: bool,
298    /// If true, the field can be focused and text selected/copied but not modified.
299    pub read_only: bool,
300    /// Transforms the displayed text without changing the underlying value
301    /// (e.g. password masking). Passed through to the lower-level text field.
302    pub visual_transformation: Option<Rc<dyn VisualTransformation>>,
303    /// Supporting text shown below the field.
304    pub supporting_text: Option<String>,
305    /// Static text prefix inside the field, before the input.
306    pub prefix: Option<String>,
307    /// Static text suffix inside the field, after the input.
308    pub suffix: Option<String>,
309    /// Called when the user presses Enter on a single-line field.
310    pub on_submit: Option<Rc<dyn Fn(String)>>,
311    /// Colors for all text field UI elements.
312    pub colors: Option<TextFieldColors>,
313    /// Optional external focus tracker. When `None`, an internal focus tracker
314    /// is created (keyed by label). Pass a tracker to synchronize focus state
315    /// (e.g. to avoid overriding external text while the user is editing).
316    pub focus_tracker: Option<Rc<Cell<bool>>>,
317}
318
319impl Default for OutlinedTextFieldConfig {
320    fn default() -> Self {
321        Self {
322            label: None,
323            placeholder: None,
324            leading_icon: None,
325            trailing_icon: None,
326            single_line: true,
327            is_error: false,
328            enabled: true,
329            read_only: false,
330            visual_transformation: None,
331            supporting_text: None,
332            prefix: None,
333            suffix: None,
334            on_submit: None,
335            colors: None,
336            focus_tracker: None,
337        }
338    }
339}
340
341/// M3 Outlined Text Field with floating label, leading/trailing icons, and error state.
342///
343/// The label floats up when `value` is non-empty or when the field is focused.
344/// Focus state comes from the persistent `focus_tracker`, which paint updates
345/// on the frame the field gains/loses focus (one-frame delay on tap-to-float).
346///
347/// # Example
348/// ```ignore
349/// let text = remember(|| signal(String::new()));
350/// OutlinedTextField(
351///     Modifier::new().fill_max_width().padding(16.0),
352///     text.get(),
353///     { let t = text.clone(); move |v| t.set(v) },
354///     OutlinedTextFieldConfig {
355///         label: Some("Email".into()),
356///         placeholder: Some("user@example.com".into()),
357///         ..Default::default()
358///     },
359/// );
360/// ```
361pub fn OutlinedTextField(
362    modifier: Modifier,
363    value: String,
364    on_value_change: impl Fn(String) + 'static,
365    config: OutlinedTextFieldConfig,
366) -> View {
367    let label_str: Option<Rc<str>> = config.label.clone().map(Rc::from);
368    let has_label = label_str.is_some();
369
370    // Unique stable animation key (label text collides when two fields share a label)
371    let id = *remember(|| OTF_COUNTER.fetch_add(1, Ordering::Relaxed));
372    let anim_key = format!("otf_{id}");
373
374    // Persistent focus tracker - set by layout/paint when this field is focused,
375    // read here on the next frame. This gives a one-frame delay on tap-to-float,
376    // which is negligible at 60fps. An external tracker takes precedence.
377    let focus_tracker: Rc<Cell<bool>> = match config.focus_tracker.clone() {
378        Some(ft) => ft,
379        None => remember_with_key(format!("otf_focus_{}", anim_key), || Cell::new(false)),
380    };
381    let is_focused = focus_tracker.get();
382    let should_float = !value.is_empty() || is_focused;
383
384    let tf_placeholder = if has_label {
385        if should_float {
386            config.placeholder.clone().unwrap_or_default()
387        } else {
388            String::new()
389        }
390    } else {
391        config.placeholder.clone().unwrap_or_default()
392    };
393
394    let text_input = View::new(0, ViewKind::Box)
395        .modifier(
396            Modifier::new().flex_grow(1.0).text_input(TextInputConfig {
397                hint: tf_placeholder,
398                multiline: !config.single_line,
399                on_change: Some(Rc::new(on_value_change) as _),
400                on_submit: config.on_submit.clone().map(|f| {
401                    let f = f.clone();
402                    Rc::new(move |s| f(s)) as Rc<dyn Fn(String)>
403                }),
404                focus_tracker: Some(focus_tracker),
405                value: value.clone(),
406                visual_transformation: config.visual_transformation.clone(),
407                keyboard_type: Default::default(),
408                capitalization: Default::default(),
409                ime_action: Default::default(),
410                auto_correct_enabled: None,
411                enabled: config.enabled,
412                read_only: config.read_only,
413                max_lines: None,
414                min_lines: 1,
415                cursor_color: config
416                    .colors
417                    .as_ref()
418                    .map(|c| c.cursor_color(config.is_error)),
419                on_text_layout: None,
420                text_style: None,
421                keyboard_actions: None,
422                interaction_source: None,
423                line_limits: None,
424            }),
425        )
426        .semantics(Semantics {
427            role: Role::TextField,
428            label: config
429                .label
430                .clone()
431                .or_else(|| config.supporting_text.clone()),
432            focused: false,
433            enabled: config.enabled,
434            selectable_group: false,
435        });
436
437    outlined_field_decoration(
438        modifier,
439        anim_key,
440        label_str,
441        &config,
442        is_focused,
443        !value.is_empty(),
444        text_input,
445    )
446}
447
448/// State-based M3 Outlined Text Field.
449pub fn OutlinedTextFieldState(
450    modifier: Modifier,
451    state: Rc<RefCell<TextFieldState>>,
452    on_value_change: impl Fn(String) + 'static,
453    config: OutlinedTextFieldConfig,
454) -> View {
455    let label_str: Option<Rc<str>> = config.label.clone().map(Rc::from);
456    let has_label = label_str.is_some();
457
458    // Unique stable animation key (label text collides when two fields share a label)
459    let id = *remember(|| OTFS_COUNTER.fetch_add(1, Ordering::Relaxed));
460    let anim_key = format!("otfs_{id}");
461
462    let focus_tracker: Rc<Cell<bool>> = match config.focus_tracker.clone() {
463        Some(ft) => ft,
464        None => remember_with_key(format!("otf_focus_{}", anim_key), || Cell::new(false)),
465    };
466    let is_focused = focus_tracker.get();
467    let has_content = !state.borrow().text.is_empty();
468    let should_float = has_content || is_focused;
469
470    // Placeholder shows when there's no label, or when label is floating (focused/has content)
471    let tf_placeholder = if has_label {
472        if should_float {
473            config.placeholder.clone().unwrap_or_default()
474        } else {
475            String::new()
476        }
477    } else {
478        config.placeholder.clone().unwrap_or_default()
479    };
480
481    let text_input = BasicTextField(
482        state,
483        Modifier::new().flex_grow(1.0),
484        tf_placeholder,
485        BasicTextFieldConfig {
486            line_limits: if config.single_line {
487                TextFieldLineLimits::SingleLine
488            } else {
489                TextFieldLineLimits::MultiLine {
490                    min_height_in_lines: 1,
491                    max_height_in_lines: usize::MAX,
492                }
493            },
494            on_change: Some(Rc::new(on_value_change)),
495            on_submit: config.on_submit.clone(),
496            focus_tracker: Some(focus_tracker),
497            enabled: config.enabled,
498            read_only: config.read_only,
499            ..Default::default()
500        },
501    );
502
503    outlined_field_decoration(
504        modifier,
505        anim_key,
506        label_str,
507        &config,
508        is_focused,
509        has_content,
510        text_input,
511    )
512}
513
514fn outlined_field_decoration(
515    modifier: Modifier,
516    anim_key: String,
517    label_str: Option<Rc<str>>,
518    config: &OutlinedTextFieldConfig,
519    is_focused: bool,
520    has_content: bool,
521    text_input: View,
522) -> View {
523    let th = theme();
524    let has_label = label_str.is_some();
525
526    let should_float = has_content || is_focused;
527    let float_t = animate_f32(
528        anim_key.clone(),
529        if should_float { 1.0 } else { 0.0 },
530        th.motion.color,
531    );
532
533    let target_border_w = if config.is_error || should_float {
534        OutlinedTextFieldDefaults::FOCUSED_BORDER_THICKNESS
535    } else {
536        OutlinedTextFieldDefaults::UNFOCUSED_BORDER_THICKNESS
537    };
538    let border_w = animate_f32(
539        format!("otf_bw_{}", anim_key),
540        target_border_w,
541        th.motion.color,
542    );
543
544    let (border_color, label_color, container_bg) = if let Some(ref tc) = config.colors {
545        (
546            tc.indicator_color(config.enabled, config.is_error, is_focused),
547            tc.label_color(config.enabled, config.is_error, is_focused),
548            tc.container_color(config.enabled, config.is_error, is_focused),
549        )
550    } else {
551        (
552            if config.is_error {
553                th.error
554            } else if is_focused {
555                th.primary
556            } else {
557                th.outline
558            },
559            if config.is_error {
560                th.error
561            } else if is_focused {
562                th.primary
563            } else {
564                th.on_surface_variant
565            },
566            Color::TRANSPARENT,
567        )
568    };
569
570    // Label font size: 16dp (expanded, inside) -> 12dp (minimized, at border)
571    let label_size = 16.0 - 4.0 * float_t;
572
573    // Minimized label half-height matches bodySmall line height (~16dp) / 2
574    let min_label_half_h: f32 = if has_label { 8.0 } else { 0.0 };
575
576    // Label Y: expanded centered within 56dp field -> minimized overlapping top border (-labelHeight/2)
577    let label_start_y = (56.0 - 16.0) / 2.0;
578    let label_end_y = -min_label_half_h;
579    let label_y = label_start_y - (label_start_y - label_end_y) * float_t;
580
581    // Label X: expanded at text-input start (~24dp) -> minimized at border-start (~20dp)
582    let label_start_x = if has_label { 24.0 } else { 0.0 };
583    let label_end_x = if has_label { 20.0 } else { 0.0 };
584    let label_x = label_start_x - (label_start_x - label_end_x) * float_t;
585
586    // Container padding matches reference: 8dp top/bottom with label, 16dp without
587    let (top_pad, bottom_pad) = if has_label { (8.0, 8.0) } else { (16.0, 16.0) };
588
589    let (prefix_color, suffix_color) = if let Some(ref tc) = config.colors {
590        (
591            tc.prefix_color(config.enabled, config.is_error, is_focused),
592            tc.suffix_color(config.enabled, config.is_error, is_focused),
593        )
594    } else {
595        (
596            if config.is_error {
597                th.error
598            } else {
599                th.on_surface
600            },
601            if config.is_error {
602                th.error
603            } else {
604                th.on_surface
605            },
606        )
607    };
608
609    let (lead_c, trail_c) = if let Some(ref tc) = config.colors {
610        (
611            tc.leading_icon_color(config.enabled, config.is_error, is_focused),
612            tc.trailing_icon_color(config.enabled, config.is_error, is_focused),
613        )
614    } else {
615        let c = if !config.enabled {
616            th.on_surface.with_alpha_f32(0.38)
617        } else if config.is_error {
618            th.error
619        } else {
620            th.on_surface_variant
621        };
622        (c, c)
623    };
624
625    let text_c = config
626        .colors
627        .as_ref()
628        .map(|c| c.text_color(config.enabled, config.is_error, is_focused))
629        .unwrap_or(if config.enabled {
630            th.on_surface
631        } else {
632            th.on_surface.with_alpha_f32(0.38)
633        });
634
635    let supporting = config.supporting_text.as_ref().map(|st| {
636        let c = if let Some(ref tc) = config.colors {
637            tc.supporting_text_color(config.enabled, config.is_error, is_focused)
638        } else if config.is_error {
639            th.error
640        } else {
641            th.on_surface_variant
642        };
643        Text(st.clone())
644            .color(c)
645            .size(th.typography.body_small)
646            .modifier(Modifier::new().padding_values(PaddingValues {
647                left: 16.0,
648                right: 16.0,
649                top: 4.0,
650                bottom: 0.0,
651            }))
652    });
653
654    // Outer Stack holds both the clipped content and the unclipped label.
655    // The label sits outside the clipped Box so it can extend above the border.
656    let label_cutout = label_str.as_ref().map(|lbl| {
657        let font_px = dp_to_px(label_size) * repose_core::locals::text_scale().0;
658        let m = measure_text(lbl, font_px, TextMeasureConfig::default());
659        let text_width_px = m.positions.last().copied().unwrap_or(0.0);
660        let text_width_dp = px_to_dp(text_width_px);
661        let pad = 1.0;
662        let line_h = 16.0;
663        (
664            label_x - pad,
665            label_y - pad,
666            label_x + text_width_dp + pad,
667            label_y + line_h + pad,
668        )
669    });
670
671    Column(modifier.min_width(OutlinedTextFieldDefaults::MIN_WIDTH)).child((
672        ZStack(
673            Modifier::new()
674                .fill_max_width()
675                .min_height(OutlinedTextFieldDefaults::MIN_HEIGHT),
676        )
677        .child((
678            Box(Modifier::new()
679                .fill_max_size()
680                .clip_rounded(th.shapes.small)
681                .background(container_bg)),
682            if has_label {
683                let mut bm = Modifier::new()
684                    .fill_max_size()
685                    .clip_rounded(th.shapes.small)
686                    .border(border_w, border_color, th.shapes.small);
687                if let Some((l, t, r, b)) = label_cutout {
688                    bm = bm.clip_rect(l, t, r, b, ClipOp::Difference);
689                }
690                Box(bm)
691            } else {
692                Box(Modifier::new()
693                    .fill_max_size()
694                    .clip_rounded(th.shapes.small)
695                    .border(border_w, border_color, th.shapes.small))
696            },
697            Row(Modifier::new()
698                .fill_max_size()
699                .padding_values(PaddingValues {
700                    left: 16.0,
701                    right: 16.0,
702                    top: top_pad,
703                    bottom: bottom_pad,
704                })
705                .align_items(AlignItems::CENTER))
706            .child((
707                tint_icon(lead_c, config.leading_icon.clone()),
708                config
709                    .prefix
710                    .as_ref()
711                    .map(|p| {
712                        Text(p.clone())
713                            .color(prefix_color)
714                            .size(th.typography.body_large)
715                            .single_line()
716                    })
717                    .unwrap_or(Box(Modifier::new())),
718                with_content_color(text_c, move || text_input),
719                config
720                    .suffix
721                    .as_ref()
722                    .map(|s| {
723                        Text(s.clone())
724                            .color(suffix_color)
725                            .size(th.typography.body_large)
726                            .single_line()
727                    })
728                    .unwrap_or(Box(Modifier::new())),
729                tint_trailing_icon(trail_c, config.trailing_icon.clone()),
730            )),
731            if let Some(lbl) = label_str {
732                Box(Modifier::new()
733                    .min_width(200.0)
734                    .padding_values(PaddingValues {
735                        left: label_x,
736                        right: 20.0,
737                        top: 0.0,
738                        bottom: 0.0,
739                    })
740                    .absolute()
741                    .offset(Some(0.0), Some(label_y), None, None))
742                .child(
743                    Text(lbl.as_ref().to_string())
744                        .color(label_color)
745                        .size(label_size),
746                )
747            } else {
748                Box(Modifier::new())
749            },
750        )),
751        supporting.unwrap_or(Box(Modifier::new())),
752    ))
753}
754
755/// Configuration for a filled M3 [`TextField`].
756#[derive(Clone)]
757pub struct TextFieldConfig {
758    pub label: Option<String>,
759    pub placeholder: Option<String>,
760    pub leading_icon: Option<View>,
761    pub trailing_icon: Option<View>,
762    pub single_line: bool,
763    pub is_error: bool,
764    pub enabled: bool,
765    /// If true, the field can be focused and text selected/copied but not modified.
766    pub read_only: bool,
767    /// Transforms the displayed text without changing the underlying value
768    /// (e.g. password masking). Passed through to the lower-level text field.
769    pub visual_transformation: Option<Rc<dyn VisualTransformation>>,
770    /// Supporting text shown below the field.
771    pub supporting_text: Option<String>,
772    /// Static text prefix inside the field, before the input.
773    pub prefix: Option<String>,
774    /// Static text suffix inside the field, after the input.
775    pub suffix: Option<String>,
776    pub on_submit: Option<Rc<dyn Fn(String)>>,
777    pub colors: Option<TextFieldColors>,
778}
779
780impl Default for TextFieldConfig {
781    fn default() -> Self {
782        Self {
783            label: None,
784            placeholder: None,
785            leading_icon: None,
786            trailing_icon: None,
787            single_line: true,
788            is_error: false,
789            enabled: true,
790            read_only: false,
791            visual_transformation: None,
792            supporting_text: None,
793            prefix: None,
794            suffix: None,
795            on_submit: None,
796            colors: None,
797        }
798    }
799}
800
801/// M3 Filled Text Field with floating label, leading/trailing icons, error state,
802/// and a bottom indicator line. (Equivalent to Compose Material3's `TextField`.)
803///
804/// The label floats up when `value` is non-empty or when the field is focused.
805/// Container: `SurfaceContainerHighest` bg, top-rounded corners (4dp), flat bottom.
806/// Indicator: always visible, 1dp (unfocused) / 2dp (focused/error), animated color+thickness.
807pub fn TextField(
808    modifier: Modifier,
809    value: String,
810    on_value_change: impl Fn(String) + 'static,
811    config: TextFieldConfig,
812) -> View {
813    let th = theme();
814    let label_str: Option<Rc<str>> = config.label.clone().map(Rc::from);
815    let has_label = label_str.is_some();
816
817    let id = *remember(|| TF_COUNTER.fetch_add(1, Ordering::Relaxed));
818    let anim_key = format!("tf_{id}");
819
820    let focus_tracker: Rc<Cell<bool>> =
821        remember_with_key(format!("tf_focus_{}", anim_key), || Cell::new(false));
822    let is_focused = focus_tracker.get();
823    let should_float = !value.is_empty() || is_focused;
824
825    let float_t = animate_f32(
826        anim_key.clone(),
827        if should_float { 1.0 } else { 0.0 },
828        th.motion.color,
829    );
830
831    let (indicator_color, label_color, container_bg) = if let Some(ref tc) = config.colors {
832        let enf = config.enabled && is_focused;
833        let ind = tc.indicator_color(config.enabled, config.is_error, enf);
834        let lb = tc.label_color(config.enabled, config.is_error, enf);
835        let bg = tc.container_color(config.enabled, config.is_error, enf);
836        (ind, lb, bg)
837    } else {
838        let ind = if !config.enabled {
839            th.on_surface.with_alpha_f32(0.38)
840        } else if config.is_error {
841            th.error
842        } else if is_focused {
843            th.primary
844        } else {
845            th.on_surface_variant
846        };
847        let lb = if !config.enabled {
848            th.on_surface.with_alpha_f32(0.38)
849        } else if config.is_error {
850            th.error
851        } else if is_focused {
852            th.primary
853        } else {
854            th.on_surface_variant
855        };
856        let bg = if config.enabled {
857            th.surface_container_highest
858        } else {
859            th.on_surface
860                .with_alpha_f32(0.04)
861                .composite_over(th.surface)
862        };
863        (ind, lb, bg)
864    };
865
866    let label_size = 16.0 - 4.0 * float_t;
867
868    let label_start_y = (56.0 - 16.0) / 2.0;
869    let label_end_y = if has_label { 8.0 } else { 0.0 };
870    let label_y = label_start_y - (label_start_y - label_end_y) * float_t;
871
872    let label_start_x = if has_label { 24.0 } else { 0.0 };
873    let label_end_x = if has_label { 20.0 } else { 0.0 };
874    let label_x = label_start_x - (label_start_x - label_end_x) * float_t;
875
876    let tf_placeholder = if has_label {
877        if should_float {
878            config.placeholder.unwrap_or_default()
879        } else {
880            String::new()
881        }
882    } else {
883        config.placeholder.unwrap_or_default()
884    };
885
886    let indicator_active = config.is_error || (config.enabled && is_focused);
887    let indicator_target_w = if indicator_active { 2.0 } else { 1.0 };
888    let indicator_w = animate_f32(
889        format!("tf_ind_w_{}", anim_key),
890        indicator_target_w,
891        th.motion.color,
892    );
893
894    let (top_pad, bottom_pad) = if has_label { (8.0, 8.0) } else { (16.0, 16.0) };
895
896    let (prefix_color, suffix_color) = if let Some(ref tc) = config.colors {
897        (
898            tc.prefix_color(config.enabled, config.is_error, is_focused),
899            tc.suffix_color(config.enabled, config.is_error, is_focused),
900        )
901    } else {
902        (
903            if config.is_error {
904                th.error
905            } else {
906                th.on_surface
907            },
908            if config.is_error {
909                th.error
910            } else {
911                th.on_surface
912            },
913        )
914    };
915
916    let (lead_c, trail_c) = if let Some(ref tc) = config.colors {
917        (
918            tc.leading_icon_color(config.enabled, config.is_error, is_focused),
919            tc.trailing_icon_color(config.enabled, config.is_error, is_focused),
920        )
921    } else {
922        let c = if !config.enabled {
923            th.on_surface.with_alpha_f32(0.38)
924        } else if config.is_error {
925            th.error
926        } else {
927            th.on_surface_variant
928        };
929        (c, c)
930    };
931
932    let text_c = config
933        .colors
934        .as_ref()
935        .map(|c| c.text_color(config.enabled, config.is_error, is_focused))
936        .unwrap_or(if config.enabled {
937            th.on_surface
938        } else {
939            th.on_surface.with_alpha_f32(0.38)
940        });
941
942    let supporting = config.supporting_text.as_ref().map(|st| {
943        let c = if let Some(ref tc) = config.colors {
944            tc.supporting_text_color(config.enabled, config.is_error, is_focused)
945        } else if config.is_error {
946            th.error
947        } else {
948            th.on_surface_variant
949        };
950        Text(st.clone())
951            .color(c)
952            .size(th.typography.body_small)
953            .modifier(Modifier::new().padding_values(PaddingValues {
954                left: 16.0,
955                right: 16.0,
956                top: 4.0,
957                bottom: 0.0,
958            }))
959    });
960
961    let text_input = View::new(0, ViewKind::Box)
962        .modifier(
963            Modifier::new().flex_grow(1.0).text_input(TextInputConfig {
964                hint: tf_placeholder,
965                multiline: !config.single_line,
966                on_change: Some(Rc::new(on_value_change) as _),
967                on_submit: config.on_submit.clone().map(|f| {
968                    let f = f.clone();
969                    Rc::new(move |s| f(s)) as Rc<dyn Fn(String)>
970                }),
971                focus_tracker: Some(focus_tracker),
972                value: value.clone(),
973                visual_transformation: config.visual_transformation.clone(),
974                keyboard_type: Default::default(),
975                capitalization: Default::default(),
976                ime_action: Default::default(),
977                auto_correct_enabled: None,
978                enabled: config.enabled,
979                read_only: config.read_only,
980                max_lines: None,
981                min_lines: 1,
982                cursor_color: config
983                    .colors
984                    .as_ref()
985                    .map(|c| c.cursor_color(config.is_error)),
986                on_text_layout: None,
987                text_style: None,
988                keyboard_actions: None,
989                interaction_source: None,
990                line_limits: None,
991            }),
992        )
993        .semantics(Semantics {
994            role: Role::TextField,
995            label: config
996                .label
997                .clone()
998                .or_else(|| config.supporting_text.clone()),
999            focused: false,
1000            enabled: config.enabled,
1001            selectable_group: false,
1002        });
1003
1004    Column(modifier.min_width(TextFieldDefaults::MIN_WIDTH)).child((
1005        ZStack(
1006            Modifier::new()
1007                .fill_max_width()
1008                .min_height(TextFieldDefaults::MIN_HEIGHT),
1009        )
1010        .child((
1011            // Container: top-rounded only (M3 filled shape)
1012            Box(Modifier::new()
1013                .fill_max_size()
1014                .clip_rounded_radii([
1015                    0.0,                   // BL
1016                    0.0,                   // BR
1017                    th.shapes.extra_small, // TR
1018                    th.shapes.extra_small, // TL
1019                ])
1020                .background(container_bg)),
1021            // Input row
1022            Row(Modifier::new()
1023                .fill_max_size()
1024                .padding_values(PaddingValues {
1025                    left: 16.0,
1026                    right: 16.0,
1027                    top: top_pad,
1028                    bottom: bottom_pad,
1029                })
1030                .align_items(AlignItems::CENTER))
1031            .child((
1032                tint_icon(lead_c, config.leading_icon.clone()),
1033                config
1034                    .prefix
1035                    .as_ref()
1036                    .map(|p| {
1037                        Text(p.clone())
1038                            .color(prefix_color)
1039                            .size(th.typography.body_large)
1040                            .single_line()
1041                    })
1042                    .unwrap_or(Box(Modifier::new())),
1043                with_content_color(text_c, move || text_input),
1044                config
1045                    .suffix
1046                    .as_ref()
1047                    .map(|s| {
1048                        Text(s.clone())
1049                            .color(suffix_color)
1050                            .size(th.typography.body_large)
1051                            .single_line()
1052                    })
1053                    .unwrap_or(Box(Modifier::new())),
1054                tint_trailing_icon(trail_c, config.trailing_icon.clone()),
1055            )),
1056            // Bottom indicator line
1057            Box(Modifier::new()
1058                .fill_max_width()
1059                .height(indicator_w)
1060                .absolute()
1061                .offset(None, None, None, Some(0.0))
1062                .background(indicator_color)),
1063            // Floating label inside the stack
1064            if let Some(lbl) = label_str {
1065                Box(Modifier::new()
1066                    .absolute()
1067                    .offset(Some(label_x), Some(label_y), None, None))
1068                .child(
1069                    Text(lbl.as_ref().to_string())
1070                        .color(label_color)
1071                        .size(label_size),
1072                )
1073            } else {
1074                Box(Modifier::new())
1075            },
1076        )),
1077        supporting.unwrap_or(Box(Modifier::new())),
1078    ))
1079}