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                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 || should_float {
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, label_color, 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    // Label font size: 16dp (expanded, inside) -> 12dp (minimized, at border)
560    let label_size = 16.0 - 4.0 * float_t;
561
562    // Minimized label half-height matches bodySmall line height (~16dp) / 2
563    let min_label_half_h: f32 = if has_label { 8.0 } else { 0.0 };
564
565    // Label Y: expanded centered within 56dp field -> minimized overlapping top border (-labelHeight/2)
566    let label_start_y = (56.0 - 16.0) / 2.0;
567    let label_end_y = -min_label_half_h;
568    let label_y = label_start_y - (label_start_y - label_end_y) * float_t;
569
570    // Label X: expanded at text-input start (~24dp) -> minimized at border-start (~20dp)
571    let label_start_x = if has_label { 24.0 } else { 0.0 };
572    let label_end_x = if has_label { 20.0 } else { 0.0 };
573    let label_x = label_start_x - (label_start_x - label_end_x) * float_t;
574
575    // Container padding matches reference: 8dp top/bottom with label, 16dp without
576    let (top_pad, bottom_pad) = if has_label { (8.0, 8.0) } else { (16.0, 16.0) };
577
578    let (prefix_color, suffix_color) = if let Some(ref tc) = config.colors {
579        (
580            tc.prefix_color(config.enabled, config.is_error, is_focused),
581            tc.suffix_color(config.enabled, config.is_error, is_focused),
582        )
583    } else {
584        (
585            if config.is_error {
586                th.error
587            } else {
588                th.on_surface
589            },
590            if config.is_error {
591                th.error
592            } else {
593                th.on_surface
594            },
595        )
596    };
597
598    let (lead_c, trail_c) = if let Some(ref tc) = config.colors {
599        (
600            tc.leading_icon_color(config.enabled, config.is_error, is_focused),
601            tc.trailing_icon_color(config.enabled, config.is_error, is_focused),
602        )
603    } else {
604        let c = if !config.enabled {
605            th.on_surface.with_alpha_f32(0.38)
606        } else if config.is_error {
607            th.error
608        } else {
609            th.on_surface_variant
610        };
611        (c, c)
612    };
613
614    let text_c = config
615        .colors
616        .as_ref()
617        .map(|c| c.text_color(config.enabled, config.is_error, is_focused))
618        .unwrap_or(if config.enabled {
619            th.on_surface
620        } else {
621            th.on_surface.with_alpha_f32(0.38)
622        });
623
624    let supporting = config.supporting_text.as_ref().map(|st| {
625        let c = if let Some(ref tc) = config.colors {
626            tc.supporting_text_color(config.enabled, config.is_error, is_focused)
627        } else if config.is_error {
628            th.error
629        } else {
630            th.on_surface_variant
631        };
632        Text(st.clone())
633            .color(c)
634            .size(th.typography.body_small)
635            .modifier(Modifier::new().padding_values(PaddingValues {
636                left: 16.0,
637                right: 16.0,
638                top: 4.0,
639                bottom: 0.0,
640            }))
641    });
642
643    // Outer Stack holds both the clipped content and the unclipped label.
644    // The label sits outside the clipped Box so it can extend above the border.
645    let label_cutout = label_str.as_ref().map(|lbl| {
646        let font_px = dp_to_px(label_size) * repose_core::locals::text_scale().0;
647        let m = measure_text(lbl, font_px, TextMeasureConfig::default());
648        let text_width_px = m.positions.last().copied().unwrap_or(0.0);
649        let text_width_dp = px_to_dp(text_width_px);
650        let pad = 1.0;
651        let line_h = 16.0;
652        (
653            label_x - pad,
654            label_y - pad,
655            label_x + text_width_dp + pad,
656            label_y + line_h + pad,
657        )
658    });
659
660    Column(modifier.min_width(OutlinedTextFieldDefaults::MIN_WIDTH)).child((
661        ZStack(
662            Modifier::new()
663                .fill_max_width()
664                .min_height(OutlinedTextFieldDefaults::MIN_HEIGHT),
665        )
666        .child((
667            Box(Modifier::new()
668                .fill_max_size()
669                .clip_rounded(th.shapes.small)
670                .background(container_bg)),
671            if has_label {
672                let mut bm = Modifier::new()
673                    .fill_max_size()
674                    .clip_rounded(th.shapes.small)
675                    .border(border_w, border_color, th.shapes.small);
676                if let Some((l, t, r, b)) = label_cutout {
677                    bm = bm.clip_rect(l, t, r, b, ClipOp::Difference);
678                }
679                Box(bm)
680            } else {
681                Box(Modifier::new()
682                    .fill_max_size()
683                    .clip_rounded(th.shapes.small)
684                    .border(border_w, border_color, th.shapes.small))
685            },
686            Row(Modifier::new()
687                .fill_max_size()
688                .padding_values(PaddingValues {
689                    left: 16.0,
690                    right: 16.0,
691                    top: top_pad,
692                    bottom: bottom_pad,
693                })
694                .align_items(AlignItems::CENTER))
695            .child((
696                tint_icon(lead_c, config.leading_icon.clone()),
697                config
698                    .prefix
699                    .as_ref()
700                    .map(|p| {
701                        Text(p.clone())
702                            .color(prefix_color)
703                            .size(th.typography.body_large)
704                            .single_line()
705                    })
706                    .unwrap_or(Box(Modifier::new())),
707                with_content_color(text_c, move || text_input),
708                config
709                    .suffix
710                    .as_ref()
711                    .map(|s| {
712                        Text(s.clone())
713                            .color(suffix_color)
714                            .size(th.typography.body_large)
715                            .single_line()
716                    })
717                    .unwrap_or(Box(Modifier::new())),
718                tint_trailing_icon(trail_c, config.trailing_icon.clone()),
719            )),
720            if let Some(lbl) = label_str {
721                Box(Modifier::new()
722                    .min_width(200.0)
723                    .padding_values(PaddingValues {
724                        left: label_x,
725                        right: 20.0,
726                        top: 0.0,
727                        bottom: 0.0,
728                    })
729                    .absolute()
730                    .offset(Some(0.0), Some(label_y), None, None))
731                .child(
732                    Text(lbl.as_ref().to_string())
733                        .color(label_color)
734                        .size(label_size),
735                )
736            } else {
737                Box(Modifier::new())
738            },
739        )),
740        supporting.unwrap_or(Box(Modifier::new())),
741    ))
742}
743
744/// Configuration for a filled M3 [`TextField`].
745#[derive(Clone)]
746pub struct TextFieldConfig {
747    pub label: Option<String>,
748    pub placeholder: Option<String>,
749    pub leading_icon: Option<View>,
750    pub trailing_icon: Option<View>,
751    pub single_line: bool,
752    pub is_error: bool,
753    pub enabled: bool,
754    /// If true, the field can be focused and text selected/copied but not modified.
755    pub read_only: bool,
756    /// Transforms the displayed text without changing the underlying value
757    /// (e.g. password masking). Passed through to the lower-level text field.
758    pub visual_transformation: Option<Rc<dyn VisualTransformation>>,
759    /// Supporting text shown below the field.
760    pub supporting_text: Option<String>,
761    /// Static text prefix inside the field, before the input.
762    pub prefix: Option<String>,
763    /// Static text suffix inside the field, after the input.
764    pub suffix: Option<String>,
765    pub on_submit: Option<Rc<dyn Fn(String)>>,
766    pub colors: Option<TextFieldColors>,
767}
768
769impl Default for TextFieldConfig {
770    fn default() -> Self {
771        Self {
772            label: None,
773            placeholder: None,
774            leading_icon: None,
775            trailing_icon: None,
776            single_line: true,
777            is_error: false,
778            enabled: true,
779            read_only: false,
780            visual_transformation: None,
781            supporting_text: None,
782            prefix: None,
783            suffix: None,
784            on_submit: None,
785            colors: None,
786        }
787    }
788}
789
790/// M3 Filled Text Field with floating label, leading/trailing icons, error state,
791/// and a bottom indicator line. (Equivalent to Compose Material3's `TextField`.)
792///
793/// The label floats up when `value` is non-empty or when the field is focused.
794/// Container: `SurfaceContainerHighest` bg, top-rounded corners (4dp), flat bottom.
795/// Indicator: always visible, 1dp (unfocused) / 2dp (focused/error), animated color+thickness.
796pub fn TextField(
797    modifier: Modifier,
798    value: String,
799    on_value_change: impl Fn(String) + 'static,
800    config: TextFieldConfig,
801) -> View {
802    let th = theme();
803    let label_str: Option<Rc<str>> = config.label.clone().map(Rc::from);
804    let has_label = label_str.is_some();
805
806    let id = *remember(|| TF_COUNTER.fetch_add(1, Ordering::Relaxed));
807    let anim_key = format!("tf_{id}");
808
809    let focus_tracker: Rc<Cell<bool>> =
810        remember_with_key(format!("tf_focus_{}", anim_key), || Cell::new(false));
811    let is_focused = focus_tracker.get();
812    let should_float = !value.is_empty() || is_focused;
813
814    let float_t = animate_f32(
815        anim_key.clone(),
816        if should_float { 1.0 } else { 0.0 },
817        th.motion.color,
818    );
819
820    let (indicator_color, label_color, container_bg) = if let Some(ref tc) = config.colors {
821        let enf = config.enabled && is_focused;
822        let ind = tc.indicator_color(config.enabled, config.is_error, enf);
823        let lb = tc.label_color(config.enabled, config.is_error, enf);
824        let bg = tc.container_color(config.enabled, config.is_error, enf);
825        (ind, lb, bg)
826    } else {
827        let ind = if !config.enabled {
828            th.on_surface.with_alpha_f32(0.38)
829        } else if config.is_error {
830            th.error
831        } else if is_focused {
832            th.primary
833        } else {
834            th.on_surface_variant
835        };
836        let lb = if !config.enabled {
837            th.on_surface.with_alpha_f32(0.38)
838        } else if config.is_error {
839            th.error
840        } else if is_focused {
841            th.primary
842        } else {
843            th.on_surface_variant
844        };
845        let bg = if config.enabled {
846            th.surface_container_highest
847        } else {
848            th.on_surface
849                .with_alpha_f32(0.04)
850                .composite_over(th.surface)
851        };
852        (ind, lb, bg)
853    };
854
855    let label_size = 16.0 - 4.0 * float_t;
856
857    let label_start_y = (56.0 - 16.0) / 2.0;
858    let label_end_y = if has_label { 8.0 } else { 0.0 };
859    let label_y = label_start_y - (label_start_y - label_end_y) * float_t;
860
861    let label_start_x = if has_label { 24.0 } else { 0.0 };
862    let label_end_x = if has_label { 20.0 } else { 0.0 };
863    let label_x = label_start_x - (label_start_x - label_end_x) * float_t;
864
865    let tf_placeholder = if has_label {
866        if should_float {
867            config.placeholder.unwrap_or_default()
868        } else {
869            String::new()
870        }
871    } else {
872        config.placeholder.unwrap_or_default()
873    };
874
875    let indicator_active = config.is_error || (config.enabled && is_focused);
876    let indicator_target_w = if indicator_active { 2.0 } else { 1.0 };
877    let indicator_w = animate_f32(
878        format!("tf_ind_w_{}", anim_key),
879        indicator_target_w,
880        th.motion.color,
881    );
882
883    let (top_pad, bottom_pad) = if has_label { (8.0, 8.0) } else { (16.0, 16.0) };
884
885    let (prefix_color, suffix_color) = if let Some(ref tc) = config.colors {
886        (
887            tc.prefix_color(config.enabled, config.is_error, is_focused),
888            tc.suffix_color(config.enabled, config.is_error, is_focused),
889        )
890    } else {
891        (
892            if config.is_error {
893                th.error
894            } else {
895                th.on_surface
896            },
897            if config.is_error {
898                th.error
899            } else {
900                th.on_surface
901            },
902        )
903    };
904
905    let (lead_c, trail_c) = if let Some(ref tc) = config.colors {
906        (
907            tc.leading_icon_color(config.enabled, config.is_error, is_focused),
908            tc.trailing_icon_color(config.enabled, config.is_error, is_focused),
909        )
910    } else {
911        let c = if !config.enabled {
912            th.on_surface.with_alpha_f32(0.38)
913        } else if config.is_error {
914            th.error
915        } else {
916            th.on_surface_variant
917        };
918        (c, c)
919    };
920
921    let text_c = config
922        .colors
923        .as_ref()
924        .map(|c| c.text_color(config.enabled, config.is_error, is_focused))
925        .unwrap_or(if config.enabled {
926            th.on_surface
927        } else {
928            th.on_surface.with_alpha_f32(0.38)
929        });
930
931    let supporting = config.supporting_text.as_ref().map(|st| {
932        let c = if let Some(ref tc) = config.colors {
933            tc.supporting_text_color(config.enabled, config.is_error, is_focused)
934        } else if config.is_error {
935            th.error
936        } else {
937            th.on_surface_variant
938        };
939        Text(st.clone())
940            .color(c)
941            .size(th.typography.body_small)
942            .modifier(Modifier::new().padding_values(PaddingValues {
943                left: 16.0,
944                right: 16.0,
945                top: 4.0,
946                bottom: 0.0,
947            }))
948    });
949
950    let text_input = View::new(0, ViewKind::Box)
951        .modifier(
952            Modifier::new().flex_grow(1.0).text_input(TextInputConfig {
953                hint: tf_placeholder,
954                multiline: !config.single_line,
955                on_change: Some(Rc::new(on_value_change) as _),
956                on_submit: config.on_submit.clone().map(|f| {
957                    let f = f.clone();
958                    Rc::new(move |s| f(s)) as Rc<dyn Fn(String)>
959                }),
960                focus_tracker: Some(focus_tracker),
961                value: value.clone(),
962                visual_transformation: config.visual_transformation.clone(),
963                enabled: config.enabled,
964                read_only: config.read_only,
965                cursor_color: config
966                    .colors
967                    .as_ref()
968                    .map(|c| c.cursor_color(config.is_error)),
969                ..Default::default()
970            }),
971        )
972        .semantics(Semantics {
973            role: Role::TextField,
974            label: config
975                .label
976                .clone()
977                .or_else(|| config.supporting_text.clone()),
978            enabled: config.enabled,
979            ..Default::default()
980        });
981
982    Column(modifier.min_width(TextFieldDefaults::MIN_WIDTH)).child((
983        ZStack(
984            Modifier::new()
985                .fill_max_width()
986                .min_height(TextFieldDefaults::MIN_HEIGHT),
987        )
988        .child((
989            // Container: top-rounded only (M3 filled shape)
990            Box(Modifier::new()
991                .fill_max_size()
992                .clip_rounded_radii([
993                    0.0,                   // BL
994                    0.0,                   // BR
995                    th.shapes.extra_small, // TR
996                    th.shapes.extra_small, // TL
997                ])
998                .background(container_bg)),
999            // Input row
1000            Row(Modifier::new()
1001                .fill_max_size()
1002                .padding_values(PaddingValues {
1003                    left: 16.0,
1004                    right: 16.0,
1005                    top: top_pad,
1006                    bottom: bottom_pad,
1007                })
1008                .align_items(AlignItems::CENTER))
1009            .child((
1010                tint_icon(lead_c, config.leading_icon.clone()),
1011                config
1012                    .prefix
1013                    .as_ref()
1014                    .map(|p| {
1015                        Text(p.clone())
1016                            .color(prefix_color)
1017                            .size(th.typography.body_large)
1018                            .single_line()
1019                    })
1020                    .unwrap_or(Box(Modifier::new())),
1021                with_content_color(text_c, move || text_input),
1022                config
1023                    .suffix
1024                    .as_ref()
1025                    .map(|s| {
1026                        Text(s.clone())
1027                            .color(suffix_color)
1028                            .size(th.typography.body_large)
1029                            .single_line()
1030                    })
1031                    .unwrap_or(Box(Modifier::new())),
1032                tint_trailing_icon(trail_c, config.trailing_icon.clone()),
1033            )),
1034            // Bottom indicator line
1035            Box(Modifier::new()
1036                .fill_max_width()
1037                .height(indicator_w)
1038                .absolute()
1039                .offset(None, None, None, Some(0.0))
1040                .background(indicator_color)),
1041            // Floating label inside the stack
1042            if let Some(lbl) = label_str {
1043                Box(Modifier::new()
1044                    .absolute()
1045                    .offset(Some(label_x), Some(label_y), None, None))
1046                .child(
1047                    Text(lbl.as_ref().to_string())
1048                        .color(label_color)
1049                        .size(label_size),
1050                )
1051            } else {
1052                Box(Modifier::new())
1053            },
1054        )),
1055        supporting.unwrap_or(Box(Modifier::new())),
1056    ))
1057}