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_color, 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                enabled: config.enabled,
408                read_only: config.read_only,
409                cursor_color: config
410                    .colors
411                    .as_ref()
412                    .map(|c| c.cursor_color(config.is_error)),
413                ..Default::default()
414            }),
415        )
416        .semantics(Semantics {
417            role: Role::TextField,
418            label: config
419                .label
420                .clone()
421                .or_else(|| config.supporting_text.clone()),
422            enabled: config.enabled,
423            ..Default::default()
424        });
425
426    outlined_field_decoration(
427        modifier,
428        anim_key,
429        label_str,
430        &config,
431        is_focused,
432        !value.is_empty(),
433        text_input,
434    )
435}
436
437/// State-based M3 Outlined Text Field.
438pub fn OutlinedTextFieldState(
439    modifier: Modifier,
440    state: Rc<RefCell<TextFieldState>>,
441    on_value_change: impl Fn(String) + 'static,
442    config: OutlinedTextFieldConfig,
443) -> View {
444    let label_str: Option<Rc<str>> = config.label.clone().map(Rc::from);
445    let has_label = label_str.is_some();
446
447    // Unique stable animation key (label text collides when two fields share a label)
448    let id = *remember(|| OTFS_COUNTER.fetch_add(1, Ordering::Relaxed));
449    let anim_key = format!("otfs_{id}");
450
451    let focus_tracker: Rc<Cell<bool>> = match config.focus_tracker.clone() {
452        Some(ft) => ft,
453        None => remember_with_key(format!("otf_focus_{}", anim_key), || Cell::new(false)),
454    };
455    let is_focused = focus_tracker.get();
456    let has_content = !state.borrow().text.is_empty();
457    let should_float = has_content || is_focused;
458
459    // Placeholder shows when there's no label, or when label is floating (focused/has content)
460    let tf_placeholder = if has_label {
461        if should_float {
462            config.placeholder.clone().unwrap_or_default()
463        } else {
464            String::new()
465        }
466    } else {
467        config.placeholder.clone().unwrap_or_default()
468    };
469
470    let text_input = BasicTextField(
471        state,
472        Modifier::new().flex_grow(1.0),
473        tf_placeholder,
474        BasicTextFieldConfig {
475            line_limits: if config.single_line {
476                TextFieldLineLimits::SingleLine
477            } else {
478                TextFieldLineLimits::MultiLine {
479                    min_height_in_lines: 1,
480                    max_height_in_lines: usize::MAX,
481                }
482            },
483            on_change: Some(Rc::new(on_value_change)),
484            on_submit: config.on_submit.clone(),
485            focus_tracker: Some(focus_tracker),
486            enabled: config.enabled,
487            read_only: config.read_only,
488            ..Default::default()
489        },
490    );
491
492    outlined_field_decoration(
493        modifier,
494        anim_key,
495        label_str,
496        &config,
497        is_focused,
498        has_content,
499        text_input,
500    )
501}
502
503fn outlined_field_decoration(
504    modifier: Modifier,
505    anim_key: String,
506    label_str: Option<Rc<str>>,
507    config: &OutlinedTextFieldConfig,
508    is_focused: bool,
509    has_content: bool,
510    text_input: View,
511) -> View {
512    let th = theme();
513    let has_label = label_str.is_some();
514
515    let should_float = has_content || is_focused;
516    let float_t = animate_f32(
517        anim_key.clone(),
518        if should_float { 1.0 } else { 0.0 },
519        th.motion.color,
520    );
521
522    let target_border_w = if config.is_error || is_focused {
523        OutlinedTextFieldDefaults::FOCUSED_BORDER_THICKNESS
524    } else {
525        OutlinedTextFieldDefaults::UNFOCUSED_BORDER_THICKNESS
526    };
527    let border_w = animate_f32(
528        format!("otf_bw_{}", anim_key),
529        target_border_w,
530        th.motion.color,
531    );
532
533    let (border_color_target, label_color_target, container_bg) = if let Some(ref tc) = config.colors {
534        (
535            tc.indicator_color(config.enabled, config.is_error, is_focused),
536            tc.label_color(config.enabled, config.is_error, is_focused),
537            tc.container_color(config.enabled, config.is_error, is_focused),
538        )
539    } else {
540        (
541            if config.is_error {
542                th.error
543            } else if is_focused {
544                th.primary
545            } else {
546                th.outline
547            },
548            if config.is_error {
549                th.error
550            } else if is_focused {
551                th.primary
552            } else {
553                th.on_surface_variant
554            },
555            Color::TRANSPARENT,
556        )
557    };
558
559    let border_color = animate_color(
560        format!("otf_bc_{}", anim_key),
561        border_color_target,
562        th.motion.color,
563    );
564    let label_color = animate_color(
565        format!("otf_lc_{}", anim_key),
566        label_color_target,
567        th.motion.color,
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_target, label_color_target, 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 indicator_color = animate_color(
867        format!("tf_ind_c_{}", anim_key),
868        indicator_color_target,
869        th.motion.color,
870    );
871    let label_color = animate_color(
872        format!("tf_lc_{}", anim_key),
873        label_color_target,
874        th.motion.color,
875    );
876
877    let label_size = 16.0 - 4.0 * float_t;
878
879    let label_start_y = (56.0 - 16.0) / 2.0;
880    let label_end_y = if has_label { 8.0 } else { 0.0 };
881    let label_y = label_start_y - (label_start_y - label_end_y) * float_t;
882
883    let label_start_x = if has_label { 24.0 } else { 0.0 };
884    let label_end_x = if has_label { 20.0 } else { 0.0 };
885    let label_x = label_start_x - (label_start_x - label_end_x) * float_t;
886
887    let tf_placeholder = if has_label {
888        if should_float {
889            config.placeholder.unwrap_or_default()
890        } else {
891            String::new()
892        }
893    } else {
894        config.placeholder.unwrap_or_default()
895    };
896
897    let indicator_active = config.is_error || (config.enabled && is_focused);
898    let indicator_target_w = if indicator_active { 2.0 } else { 1.0 };
899    let indicator_w = animate_f32(
900        format!("tf_ind_w_{}", anim_key),
901        indicator_target_w,
902        th.motion.color,
903    );
904
905    let (top_pad, bottom_pad) = if has_label { (8.0, 8.0) } else { (16.0, 16.0) };
906
907    let (prefix_color, suffix_color) = if let Some(ref tc) = config.colors {
908        (
909            tc.prefix_color(config.enabled, config.is_error, is_focused),
910            tc.suffix_color(config.enabled, config.is_error, is_focused),
911        )
912    } else {
913        (
914            if config.is_error {
915                th.error
916            } else {
917                th.on_surface
918            },
919            if config.is_error {
920                th.error
921            } else {
922                th.on_surface
923            },
924        )
925    };
926
927    let (lead_c, trail_c) = if let Some(ref tc) = config.colors {
928        (
929            tc.leading_icon_color(config.enabled, config.is_error, is_focused),
930            tc.trailing_icon_color(config.enabled, config.is_error, is_focused),
931        )
932    } else {
933        let c = if !config.enabled {
934            th.on_surface.with_alpha_f32(0.38)
935        } else if config.is_error {
936            th.error
937        } else {
938            th.on_surface_variant
939        };
940        (c, c)
941    };
942
943    let text_c = config
944        .colors
945        .as_ref()
946        .map(|c| c.text_color(config.enabled, config.is_error, is_focused))
947        .unwrap_or(if config.enabled {
948            th.on_surface
949        } else {
950            th.on_surface.with_alpha_f32(0.38)
951        });
952
953    let supporting = config.supporting_text.as_ref().map(|st| {
954        let c = if let Some(ref tc) = config.colors {
955            tc.supporting_text_color(config.enabled, config.is_error, is_focused)
956        } else if config.is_error {
957            th.error
958        } else {
959            th.on_surface_variant
960        };
961        Text(st.clone())
962            .color(c)
963            .size(th.typography.body_small)
964            .modifier(Modifier::new().padding_values(PaddingValues {
965                left: 16.0,
966                right: 16.0,
967                top: 4.0,
968                bottom: 0.0,
969            }))
970    });
971
972    let text_input = View::new(0, ViewKind::Box)
973        .modifier(
974            Modifier::new().flex_grow(1.0).text_input(TextInputConfig {
975                hint: tf_placeholder,
976                multiline: !config.single_line,
977                on_change: Some(Rc::new(on_value_change) as _),
978                on_submit: config.on_submit.clone().map(|f| {
979                    let f = f.clone();
980                    Rc::new(move |s| f(s)) as Rc<dyn Fn(String)>
981                }),
982                focus_tracker: Some(focus_tracker),
983                value: value.clone(),
984                visual_transformation: config.visual_transformation.clone(),
985                enabled: config.enabled,
986                read_only: config.read_only,
987                cursor_color: config
988                    .colors
989                    .as_ref()
990                    .map(|c| c.cursor_color(config.is_error)),
991                ..Default::default()
992            }),
993        )
994        .semantics(Semantics {
995            role: Role::TextField,
996            label: config
997                .label
998                .clone()
999                .or_else(|| config.supporting_text.clone()),
1000            enabled: config.enabled,
1001            ..Default::default()
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}