Skip to main content

repose_material/material3/
components.rs

1#![allow(non_snake_case)]
2
3use std::cell::Cell;
4use std::rc::Rc;
5use std::sync::atomic::{AtomicU64, Ordering};
6
7use repose_core::*;
8use repose_ui::anim::{animate_color, animate_f32};
9use repose_ui::{Box, Button, Column, Row, Stack, Text, TextStyle, ViewExt};
10
11use crate::{Icon, Symbol};
12
13/// M3 Top App Bar (small). Displays a title with optional navigation icon and
14/// trailing action buttons.
15pub fn TopAppBar(
16    title: impl Into<String>,
17    navigation_icon: Option<View>,
18    actions: Vec<View>,
19) -> View {
20    let th = theme();
21    Row(Modifier::new()
22        .fill_max_width()
23        .height(64.0)
24        .background(th.surface)
25        .padding_values(PaddingValues {
26            left: 4.0,
27            right: 4.0,
28            top: 0.0,
29            bottom: 0.0,
30        })
31        .align_items(AlignItems::Center))
32    .child((
33        navigation_icon.unwrap_or(Box(Modifier::new().size(16.0, 1.0))),
34        Box(Modifier::new()
35            .padding_values(PaddingValues {
36                left: 16.0,
37                right: 0.0,
38                top: 0.0,
39                bottom: 0.0,
40            })
41            .flex_grow(1.0))
42        .child(
43            Text(title)
44                .color(th.on_surface)
45                .size(th.typography.title_large),
46        ),
47        Row(Modifier::new().align_items(AlignItems::Center)).child(actions),
48    ))
49}
50
51/// M3 Icon Button - a tappable circular container for an icon.
52pub fn IconButton(icon: View, on_click: impl Fn() + 'static) -> View {
53    let th = theme();
54    let _bg = Color::TRANSPARENT;
55    Box(Modifier::new()
56        .size(40.0, 40.0)
57        .clip_rounded(20.0)
58        .state_colors(StateColors {
59            default: Color::TRANSPARENT,
60            hovered: th.on_surface.with_alpha_f32(0.08),
61            pressed: th.on_surface.with_alpha_f32(0.12),
62            disabled: Color::TRANSPARENT,
63        })
64        .align_items(AlignItems::Center)
65        .justify_content(JustifyContent::Center)
66        .clickable()
67        .on_pointer_down(move |_| on_click()))
68    .child(icon)
69}
70
71/// M3 Filled Icon Button - icon button with a filled container background.
72pub fn FilledIconButton(icon: View, on_click: impl Fn() + 'static) -> View {
73    let th = theme();
74    let bg = th.primary;
75    Box(Modifier::new()
76        .size(40.0, 40.0)
77        .clip_rounded(20.0)
78        .background(bg)
79        .state_colors(StateColors {
80            default: Color::TRANSPARENT,
81            hovered: th.on_primary.with_alpha_f32(0.08),
82            pressed: th.on_primary.with_alpha_f32(0.12),
83            disabled: th.on_surface.with_alpha_f32(0.12),
84        })
85        .align_items(AlignItems::Center)
86        .justify_content(JustifyContent::Center)
87        .clickable()
88        .on_pointer_down(move |_| on_click()))
89    .child(icon)
90}
91
92/// M3 Filled Button - prominent action button with primary color fill.
93pub fn FilledButton(
94    modifier: Modifier,
95    on_click: impl Fn() + 'static,
96    content: impl FnOnce() -> View,
97) -> View {
98    let th = theme();
99    let content = with_content_color(th.on_primary, content);
100    let bg = th.primary;
101    Box(Modifier::new()
102        .height(40.0)
103        .min_width(48.0)
104        .background(bg)
105        .state_colors(StateColors {
106            default: Color::TRANSPARENT,
107            hovered: th.on_primary.with_alpha_f32(0.08),
108            pressed: th.on_primary.with_alpha_f32(0.12),
109            disabled: th.on_surface.with_alpha_f32(0.12),
110        })
111        .state_elevation(StateElevation {
112            default: 0.0,
113            hovered: 1.0,
114            pressed: 8.0,
115            disabled: 0.0,
116        })
117        .clip_rounded(20.0)
118        .padding_values(PaddingValues {
119            left: 24.0,
120            right: 24.0,
121            top: 0.0,
122            bottom: 0.0,
123        })
124        .align_items(AlignItems::Center)
125        .justify_content(JustifyContent::Center)
126        .clickable()
127        .on_pointer_down(move |_| on_click())
128        .then(modifier))
129    .child(content)
130}
131
132/// M3 Filled Tonal Button - uses secondary container colors.
133pub fn FilledTonalButton(
134    modifier: Modifier,
135    on_click: impl Fn() + 'static,
136    content: impl FnOnce() -> View,
137) -> View {
138    let th = theme();
139    let content = with_content_color(th.on_secondary_container, content);
140    let bg = th.secondary_container;
141    Box(Modifier::new()
142        .height(40.0)
143        .min_width(48.0)
144        .background(bg)
145        .state_colors(StateColors {
146            default: Color::TRANSPARENT,
147            hovered: th.on_secondary_container.with_alpha_f32(0.08),
148            pressed: th.on_secondary_container.with_alpha_f32(0.12),
149            disabled: th.on_surface.with_alpha_f32(0.12),
150        })
151        .state_elevation(StateElevation {
152            default: 0.0,
153            hovered: 1.0,
154            pressed: 8.0,
155            disabled: 0.0,
156        })
157        .clip_rounded(20.0)
158        .padding_values(PaddingValues {
159            left: 24.0,
160            right: 24.0,
161            top: 0.0,
162            bottom: 0.0,
163        })
164        .align_items(AlignItems::Center)
165        .justify_content(JustifyContent::Center)
166        .clickable()
167        .on_pointer_down(move |_| on_click())
168        .then(modifier))
169    .child(content)
170}
171
172/// M3 Outlined Button - button with an outline border and no fill.
173pub fn OutlinedButton(
174    modifier: Modifier,
175    on_click: impl Fn() + 'static,
176    content: impl FnOnce() -> View,
177) -> View {
178    let th = theme();
179    let content = with_content_color(th.on_surface, content);
180    let _bg = Color::TRANSPARENT;
181    Box(Modifier::new()
182        .height(40.0)
183        .min_width(48.0)
184        .state_colors(StateColors {
185            default: Color::TRANSPARENT,
186            hovered: th.on_surface.with_alpha_f32(0.08),
187            pressed: th.on_surface.with_alpha_f32(0.12),
188            disabled: Color::TRANSPARENT,
189        })
190        .border(1.0, th.outline_variant, 20.0)
191        .clip_rounded(20.0)
192        .padding_values(PaddingValues {
193            left: 24.0,
194            right: 24.0,
195            top: 0.0,
196            bottom: 0.0,
197        })
198        .align_items(AlignItems::Center)
199        .justify_content(JustifyContent::Center)
200        .clickable()
201        .on_pointer_down(move |_| on_click())
202        .then(modifier))
203    .child(content)
204}
205
206/// M3 Text Button - a low-emphasis button.
207pub fn TextButton(
208    modifier: Modifier,
209    on_click: impl Fn() + 'static,
210    content: impl FnOnce() -> View,
211) -> View {
212    let th = theme();
213    let content = with_content_color(th.on_surface, content);
214    let _bg = Color::TRANSPARENT;
215    Box(Modifier::new()
216        .height(40.0)
217        .min_width(48.0)
218        .state_colors(StateColors {
219            default: Color::TRANSPARENT,
220            hovered: th.on_surface.with_alpha_f32(0.08),
221            pressed: th.on_surface.with_alpha_f32(0.12),
222            disabled: Color::TRANSPARENT,
223        })
224        .clip_rounded(20.0)
225        .padding_values(PaddingValues {
226            left: 12.0,
227            right: 12.0,
228            top: 0.0,
229            bottom: 0.0,
230        })
231        .align_items(AlignItems::Center)
232        .justify_content(JustifyContent::Center)
233        .clickable()
234        .on_pointer_down(move |_| on_click())
235        .then(modifier))
236    .child(content)
237}
238
239/// M3 Elevated Button - uses `surface_container_low` background with elevation.
240pub fn ElevatedButton(
241    modifier: Modifier,
242    on_click: impl Fn() + 'static,
243    content: impl FnOnce() -> View,
244) -> View {
245    let th = theme();
246    let content = with_content_color(th.primary, content);
247    let bg = th.surface_container_low;
248    Box(Modifier::new()
249        .height(40.0)
250        .min_width(48.0)
251        .background(bg)
252        .state_colors(StateColors {
253            default: Color::TRANSPARENT,
254            hovered: th.primary.with_alpha_f32(0.08),
255            pressed: th.primary.with_alpha_f32(0.12),
256            disabled: th.on_surface.with_alpha_f32(0.12),
257        })
258        .state_elevation(StateElevation {
259            default: th.elevation.level1,
260            hovered: th.elevation.level2,
261            pressed: th.elevation.level3,
262            disabled: 0.0,
263        })
264        .clip_rounded(20.0)
265        .padding_values(PaddingValues {
266            left: 24.0,
267            right: 24.0,
268            top: 0.0,
269            bottom: 0.0,
270        })
271        .align_items(AlignItems::Center)
272        .justify_content(JustifyContent::Center)
273        .clickable()
274        .on_pointer_down(move |_| on_click())
275        .then(modifier))
276    .child(content)
277}
278
279/// M3 Floating Action Button (regular, 56dp).
280pub fn FAB(icon: View, on_click: impl Fn() + 'static) -> View {
281    let th = theme();
282    let bg = th.primary_container;
283    Box(Modifier::new()
284        .size(56.0, 56.0)
285        .background(bg)
286        .state_colors(StateColors {
287            default: Color::TRANSPARENT,
288            hovered: th.on_primary_container.with_alpha_f32(0.08),
289            pressed: th.on_primary_container.with_alpha_f32(0.12),
290            disabled: th.on_surface.with_alpha_f32(0.12),
291        })
292        .state_elevation(StateElevation {
293            default: 6.0,
294            hovered: 8.0,
295            pressed: 12.0,
296            disabled: 0.0,
297        })
298        .clip_rounded(28.0)
299        .align_items(AlignItems::Center)
300        .justify_content(JustifyContent::Center)
301        .clickable()
302        .on_pointer_down(move |_| on_click()))
303    .child(icon)
304}
305
306/// M3 Large FAB (96dp).
307pub fn LargeFAB(icon: View, on_click: impl Fn() + 'static) -> View {
308    let th = theme();
309    let bg = th.primary_container;
310    Box(Modifier::new()
311        .size(96.0, 96.0)
312        .background(bg)
313        .state_colors(StateColors {
314            default: Color::TRANSPARENT,
315            hovered: th.on_primary_container.with_alpha_f32(0.08),
316            pressed: th.on_primary_container.with_alpha_f32(0.12),
317            disabled: th.on_surface.with_alpha_f32(0.12),
318        })
319        .state_elevation(StateElevation {
320            default: 6.0,
321            hovered: 8.0,
322            pressed: 12.0,
323            disabled: 0.0,
324        })
325        .clip_rounded(28.0)
326        .align_items(AlignItems::Center)
327        .justify_content(JustifyContent::Center)
328        .clickable()
329        .on_pointer_down(move |_| on_click()))
330    .child(icon)
331}
332
333/// M3 Extended FAB - FAB with icon + label.
334pub fn ExtendedFAB(
335    icon: Option<View>,
336    label: impl Into<String>,
337    on_click: impl Fn() + 'static,
338) -> View {
339    let th = theme();
340    let has_icon = icon.is_some();
341    let bg = th.primary_container;
342    Row(Modifier::new()
343        .height(56.0)
344        .min_width(80.0)
345        .background(bg)
346        .state_colors(StateColors {
347            default: Color::TRANSPARENT,
348            hovered: th.on_primary_container.with_alpha_f32(0.08),
349            pressed: th.on_primary_container.with_alpha_f32(0.12),
350            disabled: th.on_surface.with_alpha_f32(0.12),
351        })
352        .state_elevation(StateElevation {
353            default: 6.0,
354            hovered: 8.0,
355            pressed: 12.0,
356            disabled: 0.0,
357        })
358        .clip_rounded(16.0)
359        .padding_values(PaddingValues {
360            left: 16.0,
361            right: 20.0,
362            top: 0.0,
363            bottom: 0.0,
364        })
365        .align_items(AlignItems::Center)
366        .clickable()
367        .on_pointer_down(move |_| on_click()))
368    .child((
369        icon.unwrap_or(Box(Modifier::new())),
370        Box(Modifier::new().size(if has_icon { 12.0 } else { 0.0 }, 1.0)),
371        Text(label)
372            .color(th.on_primary_container)
373            .size(th.typography.label_large)
374            .single_line(),
375    ))
376}
377
378/// M3 Horizontal Divider - a thin 1dp line.
379pub fn Divider() -> View {
380    let th = theme();
381    Box(Modifier::new()
382        .fill_max_width()
383        .height(1.0)
384        .background(th.outline_variant))
385}
386
387/// M3 Vertical Divider - a thin 1dp vertical line.
388pub fn VerticalDivider() -> View {
389    let th = theme();
390    Box(Modifier::new()
391        .width(1.0)
392        .fill_max_height()
393        .background(th.outline_variant))
394}
395
396/// M3 Badge - a small notification indicator. If `label` is `None`, shows a
397/// small 6dp dot; otherwise shows the label text inside a 16dp pill.
398pub fn Badge(label: Option<impl Into<String>>) -> View {
399    let th = theme();
400    match label {
401        None => Box(Modifier::new()
402            .size(6.0, 6.0)
403            .background(th.error)
404            .clip_rounded(3.0)),
405        Some(text) => {
406            let text = text.into();
407            Box(Modifier::new()
408                .min_width(16.0)
409                .height(16.0)
410                .background(th.error)
411                .clip_rounded(8.0)
412                .padding_values(PaddingValues {
413                    left: 4.0,
414                    right: 4.0,
415                    top: 0.0,
416                    bottom: 0.0,
417                })
418                .align_items(AlignItems::Center)
419                .justify_content(JustifyContent::Center))
420            .child(
421                Text(text)
422                    .color(th.on_error)
423                    .size(th.typography.label_small)
424                    .single_line(),
425            )
426        }
427    }
428}
429
430/// M3 List Item - a single row in a list with optional leading/trailing content.
431pub fn ListItem(
432    headline: impl Into<String>,
433    supporting_text: Option<String>,
434    leading: Option<View>,
435    trailing: Option<View>,
436    on_click: Option<Rc<dyn Fn()>>,
437) -> View {
438    let th = theme();
439    let mut modifier = Modifier::new()
440        .fill_max_width()
441        .min_height(if supporting_text.is_some() {
442            72.0
443        } else {
444            56.0
445        })
446        .state_colors(StateColors {
447            default: Color::TRANSPARENT,
448            hovered: th.on_surface.with_alpha_f32(0.08),
449            pressed: th.on_surface.with_alpha_f32(0.12),
450            disabled: Color::TRANSPARENT,
451        })
452        .padding_values(PaddingValues {
453            left: 16.0,
454            right: 24.0,
455            top: 8.0,
456            bottom: 8.0,
457        })
458        .align_items(AlignItems::Center);
459
460    if let Some(cb) = on_click {
461        modifier = modifier.clickable().on_pointer_down(move |_| cb());
462    }
463
464    Row(modifier).child((
465        leading
466            .map(|v| {
467                Box(Modifier::new().padding_values(PaddingValues {
468                    left: 0.0,
469                    right: 16.0,
470                    top: 0.0,
471                    bottom: 0.0,
472                }))
473                .child(v)
474            })
475            .unwrap_or(Box(Modifier::new())),
476        Column(
477            Modifier::new()
478                .flex_grow(1.0)
479                .justify_content(JustifyContent::Center),
480        )
481        .child((
482            Text(headline)
483                .color(th.on_surface)
484                .size(th.typography.body_large)
485                .single_line(),
486            supporting_text
487                .map(|st| {
488                    Text(st)
489                        .color(th.on_surface_variant)
490                        .size(th.typography.body_medium)
491                        .max_lines(2)
492                        .overflow_ellipsize()
493                })
494                .unwrap_or(Box(Modifier::new())),
495        )),
496        trailing
497            .map(|v| {
498                Box(Modifier::new().padding_values(PaddingValues {
499                    left: 16.0,
500                    right: 0.0,
501                    top: 0.0,
502                    bottom: 0.0,
503                }))
504                .child(v)
505            })
506            .unwrap_or(Box(Modifier::new())),
507    ))
508}
509
510/// A single tab definition for use with `TabRow`.
511pub struct Tab {
512    pub label: String,
513    pub icon: Option<View>,
514    pub on_click: Rc<dyn Fn()>,
515}
516
517static TABROW_COUNTER: AtomicU64 = AtomicU64::new(0);
518
519/// M3 Tab Row - a horizontal row of tabs with an active indicator.
520/// Text colors and indicator height animate with 150ms FastOutSlowIn.
521pub fn TabRow(selected_index: usize, tabs: Vec<Tab>) -> View {
522    let th = theme();
523    let id = remember(|| TABROW_COUNTER.fetch_add(1, Ordering::Relaxed));
524    let spec = th.motion.color;
525    Column(Modifier::new().fill_max_width()).child((
526        Row(Modifier::new()
527            .fill_max_width()
528            .height(48.0)
529            .background(th.surface))
530        .child(
531            tabs.into_iter()
532                .enumerate()
533                .map(|(i, tab)| {
534                    let selected = i == selected_index;
535                    let color = animate_color(
536                        format!("tab_clr_{}_{}", id, i),
537                        if selected {
538                            th.primary
539                        } else {
540                            th.on_surface_variant
541                        },
542                        spec,
543                    );
544                    let indicator_h = animate_f32(
545                        format!("tab_ind_{}_{}", id, i),
546                        if selected { 3.0 } else { 0.0 },
547                        spec,
548                    );
549                    let cb = tab.on_click.clone();
550
551                    Column(
552                        Modifier::new()
553                            .flex_grow(1.0)
554                            .fill_max_height()
555                            .align_items(AlignItems::Center)
556                            .justify_content(JustifyContent::Center)
557                            .state_colors(StateColors {
558                                default: Color::TRANSPARENT,
559                                hovered: th.on_surface.with_alpha_f32(0.08),
560                                pressed: th.on_surface.with_alpha_f32(0.12),
561                                disabled: Color::TRANSPARENT,
562                            })
563                            .clickable()
564                            .on_pointer_down(move |_| cb()),
565                    )
566                    .child((
567                        tab.icon.unwrap_or(Box(Modifier::new())),
568                        Text(tab.label)
569                            .color(color)
570                            .size(th.typography.title_small)
571                            .single_line(),
572                        Box(Modifier::new()
573                            .fill_max_width()
574                            .height(indicator_h)
575                            .background(th.primary)
576                            .clip_rounded(1.5)),
577                    ))
578                })
579                .collect::<Vec<_>>(),
580        ),
581        Box(Modifier::new()
582            .fill_max_width()
583            .height(1.0)
584            .background(th.outline_variant)),
585    ))
586}
587
588/// A single segment definition for `SegmentedButton`.
589pub struct Segment {
590    pub label: String,
591    pub icon: Option<View>,
592    pub on_click: Rc<dyn Fn()>,
593}
594
595static SEGBUTTON_COUNTER: AtomicU64 = AtomicU64::new(0);
596
597/// M3 Segmented Button - a row of toggle segments. `selected` contains the
598/// indices of selected segments (single-select: pass a single-element set).
599pub fn SegmentedButton(selected: &[usize], segments: Vec<Segment>) -> View {
600    let th = theme();
601    let count = segments.len();
602    let id = remember(|| SEGBUTTON_COUNTER.fetch_add(1, Ordering::Relaxed));
603    let spec = th.motion.color;
604
605    Row(Modifier::new()
606        .height(40.0)
607        .border(1.0, th.outline, 20.0)
608        .clip_rounded(20.0))
609    .child(
610        segments
611            .into_iter()
612            .enumerate()
613            .map(|(i, seg)| {
614                let is_selected = selected.contains(&i);
615
616                let bg = animate_color(
617                    format!("sb_bg_{}_{}", id, i),
618                    if is_selected {
619                        th.secondary_container
620                    } else {
621                        Color::TRANSPARENT
622                    },
623                    spec,
624                );
625                let fg = animate_color(
626                    format!("sb_fg_{}_{}", id, i),
627                    if is_selected {
628                        th.on_secondary_container
629                    } else {
630                        th.on_surface
631                    },
632                    spec,
633                );
634
635                let cb = seg.on_click.clone();
636
637                let mut modifier = Modifier::new()
638                    .flex_grow(1.0)
639                    .fill_max_height()
640                    .background(bg)
641                    .align_items(AlignItems::Center)
642                    .justify_content(JustifyContent::Center)
643                    .padding_values(PaddingValues {
644                        left: 12.0,
645                        right: 12.0,
646                        top: 0.0,
647                        bottom: 0.0,
648                    })
649                    .clickable()
650                    .on_pointer_down(move |_| cb());
651
652                if i < count - 1 {
653                    modifier = modifier.border(1.0, th.outline, 0.0);
654                }
655
656                Row(modifier).child((
657                    seg.icon.unwrap_or(Box(Modifier::new())),
658                    Text(seg.label)
659                        .color(fg)
660                        .size(th.typography.label_large)
661                        .single_line(),
662                ))
663            })
664            .collect::<Vec<_>>(),
665    )
666}
667
668/// M3 Circular Progress Indicator. Uses the built-in `ProgressBar` view kind
669/// with `circular: true`.
670///
671/// - `value`: `Some(0.0..=1.0)` for determinate, `None` for indeterminate.
672pub fn CircularProgressIndicator(value: Option<f32>) -> View {
673    View::new(
674        0,
675        ViewKind::ProgressBar {
676            value: value.unwrap_or(0.0),
677            min: 0.0,
678            max: 1.0,
679            circular: true,
680        },
681    )
682    .modifier(Modifier::new().size(48.0, 48.0))
683    .semantics(Semantics {
684        role: Role::ProgressBar,
685        label: None,
686        focused: false,
687        enabled: true,
688    })
689}
690
691/// M3 Linear Progress Indicator. Uses the built-in `ProgressBar` view kind.
692pub fn LinearProgressIndicator(value: Option<f32>) -> View {
693    View::new(
694        0,
695        ViewKind::ProgressBar {
696            value: value.unwrap_or(0.0),
697            min: 0.0,
698            max: 1.0,
699            circular: false,
700        },
701    )
702    .modifier(Modifier::new().fill_max_width().height(4.0))
703    .semantics(Semantics {
704        role: Role::ProgressBar,
705        label: None,
706        focused: false,
707        enabled: true,
708    })
709}
710
711/// Configuration for an `OutlinedTextField`.
712#[derive(Clone)]
713pub struct OutlinedTextFieldConfig {
714    /// Floating label shown above the input when the field has text or is focused.
715    /// When set, this acts as the visual placeholder (the TextField's own placeholder
716    /// is suppressed). When the label floats, it animates to the top border.
717    pub label: Option<String>,
718    /// Placeholder text shown inside the TextField when empty and unfocused.
719    /// Only shown when `label` is `None`; when a label is present the label
720    /// itself serves as the visual placeholder.
721    pub placeholder: Option<String>,
722    /// Icon displayed at the start of the input.
723    pub leading_icon: Option<View>,
724    /// Icon displayed at the end of the input.
725    pub trailing_icon: Option<View>,
726    /// If true, Enter submits; if false, Enter inserts a newline.
727    pub single_line: bool,
728    /// If true, border and label color switch to error color.
729    pub is_error: bool,
730    /// If false, input is visually disabled and `on_value_change` won't fire.
731    pub enabled: bool,
732    /// Called when the user presses Enter on a single-line field.
733    pub on_submit: Option<Rc<dyn Fn(String)>>,
734}
735
736impl Default for OutlinedTextFieldConfig {
737    fn default() -> Self {
738        Self {
739            label: None,
740            placeholder: None,
741            leading_icon: None,
742            trailing_icon: None,
743            single_line: true,
744            is_error: false,
745            enabled: true,
746            on_submit: None,
747        }
748    }
749}
750
751/// M3 Outlined Text Field with floating label, leading/trailing icons, and error state.
752///
753/// The label floats up when `value` is non-empty or when the field is focused.
754/// Note: focus-based floating is approximated via animated `float_t` - the label
755/// begins floating once `on_value_change` fires (i.e. when the user types).
756/// For strict focus-on-tap floating, pair with an external focus signal.
757///
758/// # Example
759/// ```ignore
760/// let text = remember(|| signal(String::new()));
761/// OutlinedTextField(
762///     Modifier::new().fill_max_width().padding(16.0),
763///     text.get(),
764///     { let t = text.clone(); move |v| t.set(v) },
765///     OutlinedTextFieldConfig {
766///         label: Some("Email".into()),
767///         placeholder: Some("user@example.com".into()),
768///         ..Default::default()
769///     },
770/// );
771/// ```
772pub fn OutlinedTextField(
773    modifier: Modifier,
774    value: String,
775    on_value_change: impl Fn(String) + 'static,
776    config: OutlinedTextFieldConfig,
777) -> View {
778    let th = theme();
779    let label_str: Option<Rc<str>> = config.label.map(Rc::from);
780    let has_label = label_str.is_some();
781
782    // Unique animation key per label to avoid conflicts when multiple fields exist
783    let anim_key = match &label_str {
784        Some(l) => format!("otf_{}", &l[..l.len().min(32)]),
785        None => "otf_nolabel".into(),
786    };
787
788    // Persistent focus tracker - set by layout/paint when this field is focused,
789    // read here on the next frame. This gives a one-frame delay on tap-to-float,
790    // which is negligible at 60fps.
791    let focus_tracker: Rc<Cell<bool>> =
792        remember_with_key(format!("otf_focus_{}", anim_key), || Cell::new(false));
793    let is_focused = focus_tracker.get();
794    let should_float = !value.is_empty() || is_focused;
795
796    let float_t = animate_f32(
797        anim_key.clone(),
798        if should_float { 1.0 } else { 0.0 },
799        th.motion.color,
800    );
801
802    // Border color: error > focused (float) > default
803    let border_color = if config.is_error {
804        th.error
805    } else if float_t > 0.5 {
806        th.primary
807    } else {
808        th.outline
809    };
810
811    // Label color: error > focused > default
812    let label_color = if config.is_error {
813        th.error
814    } else if float_t > 0.5 {
815        th.primary
816    } else {
817        th.on_surface_variant
818    };
819
820    // Label font size: 16dp at rest (placeholder position) → 12dp when floating
821    let label_size = 16.0 - 4.0 * float_t;
822
823    // Label Y offset: 16dp (same line as text) → -4dp (overlapping top border)
824    let label_y = 16.0 - 20.0 * float_t;
825
826    // The TextField inside uses no placeholder when a label is present -
827    // the label itself serves as the visual placeholder.
828    let tf_placeholder = if has_label {
829        String::new()
830    } else {
831        config.placeholder.unwrap_or_default()
832    };
833
834    Box(modifier
835        .clip_rounded(th.shapes.small)
836        .border(1.0, border_color, th.shapes.small)
837        .background(th.surface))
838    .child(
839        Stack(Modifier::new().fill_max_size()).child((
840            // Input row - always at the same position, with room at the top
841            // for the floating label to overlap.
842            Row(Modifier::new()
843                .fill_max_size()
844                .padding_values(PaddingValues {
845                    left: 16.0,
846                    right: 16.0,
847                    top: 16.0,
848                    bottom: 8.0,
849                })
850                .align_items(AlignItems::Center))
851            .child((
852                config.leading_icon.unwrap_or(Box(Modifier::new())),
853                View::new(
854                    0,
855                    ViewKind::TextField {
856                        state_key: 0,
857                        hint: tf_placeholder,
858                        multiline: false,
859                        on_change: Some(Rc::new(on_value_change) as _),
860                        on_submit: config.on_submit.clone().map(|f| {
861                            let f = f.clone();
862                            Rc::new(move |s| f(s)) as Rc<dyn Fn(String)>
863                        }),
864                        focus_tracker: Some(focus_tracker.clone()),
865                        value: value.clone(),
866                        visual_transformation: None,
867                        keyboard_type: None,
868                        ime_action: None,
869                    },
870                )
871                .modifier(
872                    Modifier::new()
873                        .flex_grow(1.0)
874                        .padding_values(PaddingValues {
875                            left: 8.0,
876                            right: 8.0,
877                            top: 0.0,
878                            bottom: 0.0,
879                        }),
880                )
881                .semantics(Semantics {
882                    role: Role::TextField,
883                    label: None,
884                    focused: false,
885                    enabled: true,
886                }),
887                config.trailing_icon.unwrap_or(Box(Modifier::new())),
888            )),
889            // Floating label - absolutely positioned, animates between text-line
890            // and top-border positions as the field gains content / focus.
891            // A surface-colored background box hides the border stroke behind the label.
892            if let Some(lbl) = label_str {
893                Box(Modifier::new()
894                    .fill_max_width()
895                    .padding_values(PaddingValues {
896                        left: 20.0,
897                        right: 20.0,
898                        top: 0.0,
899                        bottom: 0.0,
900                    })
901                    .absolute()
902                    .offset(Some(0.0), Some(label_y), None, None))
903                .child(
904                    Box(Modifier::new()
905                        .background(th.surface)
906                        .padding_values(PaddingValues {
907                            left: 4.0,
908                            right: 4.0,
909                            top: 2.0,
910                            bottom: 2.0,
911                        }))
912                    .child(
913                        Text(lbl.as_ref().to_string())
914                            .color(label_color)
915                            .size(label_size),
916                    ),
917                )
918            } else {
919                Box(Modifier::new())
920            },
921        )),
922    )
923}
924
925/// M3 Checkbox.
926/// Renders a 40dp touch-target with an 18dp check box inside.
927/// Fill, border, and check mark animate with 100ms FastOutSlowIn.
928static CHECKBOX_COUNTER: AtomicU64 = AtomicU64::new(0);
929pub fn Checkbox(checked: bool, on_change: impl Fn(bool) + 'static) -> View {
930    let th = theme();
931    let sz = 18.0;
932
933    let id = remember(|| CHECKBOX_COUNTER.fetch_add(1, Ordering::Relaxed));
934    let spec = th.motion.color_fast;
935
936    let fill = animate_color(
937        format!("cb_fill_{}", id),
938        if checked {
939            th.primary
940        } else {
941            Color::TRANSPARENT
942        },
943        spec,
944    );
945    let bd_w = animate_f32(
946        format!("cb_bw_{}", id),
947        if checked { 0.0 } else { 2.0 },
948        spec,
949    );
950    let bd = animate_color(
951        format!("cb_bd_{}", id),
952        if checked {
953            Color::TRANSPARENT
954        } else {
955            th.on_surface_variant
956        },
957        spec,
958    );
959    let check_alpha = animate_f32(
960        format!("cb_ca_{}", id),
961        if checked { 1.0 } else { 0.0 },
962        spec,
963    );
964
965    Button(
966        Box(Modifier::new()
967            .size(sz, sz)
968            .background(fill)
969            .border(bd_w, bd, 2.0)
970            .clip_rounded(2.0)
971            .align_items(AlignItems::Center)
972            .justify_content(JustifyContent::Center))
973        .child(if check_alpha > 0.01 {
974            Box(Modifier::new().alpha(check_alpha)).child(
975                Icon(Symbol::new("done", '\u{E876}'))
976                    .color(th.on_primary)
977                    .size(14.0),
978            )
979        } else {
980            Box(Modifier::new())
981        }),
982        move || on_change(!checked),
983    )
984    .modifier(
985        Modifier::new()
986            .width(40.0)
987            .height(40.0)
988            .padding(0.0)
989            .clip_rounded(20.0)
990            .background(Color::TRANSPARENT),
991    )
992}
993
994/// M3 RadioButton.
995/// Renders a 40dp touch-target with a 20dp outer circle + inner dot.
996/// Ring color animates with 100ms FastOutSlowIn; dot size animates with spring.
997static RADIO_COUNTER: AtomicU64 = AtomicU64::new(0);
998pub fn RadioButton(selected: bool, on_select: impl Fn() + 'static) -> View {
999    let th = theme();
1000    let d = 20.0;
1001
1002    let id = remember(|| RADIO_COUNTER.fetch_add(1, Ordering::Relaxed));
1003    let color_spec = th.motion.color_fast;
1004    let spring = th.motion.spring;
1005
1006    let ring_col = animate_color(
1007        format!("rb_ring_{}", id),
1008        if selected {
1009            th.primary
1010        } else {
1011            th.on_surface_variant
1012        },
1013        color_spec,
1014    );
1015    let dot_size = animate_f32(
1016        format!("rb_dot_{}", id),
1017        if selected { 10.0 } else { 0.0 },
1018        spring,
1019    );
1020
1021    Button(
1022        Box(Modifier::new()
1023            .size(d, d)
1024            .border(2.0, ring_col, d * 0.5)
1025            .clip_rounded(d * 0.5)
1026            .align_items(AlignItems::Center)
1027            .justify_content(JustifyContent::Center))
1028        .child(if dot_size > 0.5 {
1029            Box(Modifier::new()
1030                .size(dot_size, dot_size)
1031                .background(th.primary)
1032                .clip_rounded(dot_size * 0.5))
1033        } else {
1034            Box(Modifier::new())
1035        }),
1036        on_select,
1037    )
1038    .modifier(
1039        Modifier::new()
1040            .width(40.0)
1041            .height(40.0)
1042            .padding(0.0)
1043            .clip_rounded(20.0)
1044            .background(Color::TRANSPARENT),
1045    )
1046}
1047
1048/// M3 Switch.
1049/// Renders a pill track with an animated thumb knob.
1050/// Thumb position, size, and colors animate with spring/tween physics.
1051static SWITCH_COUNTER: AtomicU64 = AtomicU64::new(0);
1052pub fn Switch(checked: bool, on_change: impl Fn(bool) + 'static) -> View {
1053    let th = theme();
1054    let track_w = 52.0;
1055    let track_h = 32.0;
1056
1057    let id = remember(|| SWITCH_COUNTER.fetch_add(1, Ordering::Relaxed));
1058
1059    // Thumb: spring-animated position and size
1060    let thumb_target_pos = if checked { track_w - 24.0 - 4.0 } else { 8.0 };
1061    let thumb_target_d = if checked { 24.0 } else { 16.0 };
1062    let spring = th.motion.spring;
1063
1064    let thumb_left = animate_f32(format!("sw_pos_{}", id), thumb_target_pos, spring);
1065    let thumb_d = animate_f32(format!("sw_d_{}", id), thumb_target_d, spring);
1066    let thumb_top = (track_h - thumb_d) * 0.5;
1067
1068    let color_spec = th.motion.color_fast;
1069    let track_bg = animate_color(
1070        format!("sw_tbg_{}", id),
1071        if checked {
1072            th.primary
1073        } else {
1074            th.surface_container_highest
1075        },
1076        color_spec,
1077    );
1078    let thumb_bg = animate_color(
1079        format!("sw_tmbg_{}", id),
1080        if checked { th.on_primary } else { th.outline },
1081        color_spec,
1082    );
1083    let track_border = animate_f32(
1084        format!("sw_tb_{}", id),
1085        if checked { 0.0 } else { 2.0 },
1086        color_spec,
1087    );
1088    let border_color = animate_color(
1089        format!("sw_bc_{}", id),
1090        if checked {
1091            Color::TRANSPARENT
1092        } else {
1093            th.outline
1094        },
1095        color_spec,
1096    );
1097
1098    Button(
1099        Box(Modifier::new()
1100            .size(track_w, track_h)
1101            .background(track_bg)
1102            .border(track_border, border_color, track_h * 0.5)
1103            .clip_rounded(track_h * 0.5))
1104        .child(Box(Modifier::new()
1105            .size(thumb_d, thumb_d)
1106            .background(thumb_bg)
1107            .clip_rounded(thumb_d * 0.5)
1108            .absolute()
1109            .offset(Some(thumb_left), Some(thumb_top), None, None))),
1110        move || on_change(!checked),
1111    )
1112    .modifier(
1113        Modifier::new()
1114            .size(track_w, track_h)
1115            .padding(0.0)
1116            .clip_rounded(track_h * 0.5)
1117            .background(Color::TRANSPARENT),
1118    )
1119}
1120
1121/// M3 Slider.
1122/// Wraps the low-level `ViewKind::Slider` with M3 sizing and theme colors.
1123pub fn M3Slider(
1124    value: f32,
1125    range: (f32, f32),
1126    step: Option<f32>,
1127    on_change: impl Fn(f32) + 'static,
1128) -> View {
1129    View::new(
1130        0,
1131        ViewKind::Slider {
1132            value,
1133            min: range.0,
1134            max: range.1,
1135            step,
1136            on_change: Some(Rc::new(on_change)),
1137        },
1138    )
1139    .modifier(Modifier::new().height(28.0))
1140    .semantics(Semantics {
1141        role: Role::Slider,
1142        label: None,
1143        focused: false,
1144        enabled: true,
1145    })
1146}
1147
1148/// M3 RangeSlider.
1149/// Wraps the low-level `ViewKind::RangeSlider` with M3 sizing and theme colors.
1150pub fn M3RangeSlider(
1151    start: f32,
1152    end: f32,
1153    range: (f32, f32),
1154    step: Option<f32>,
1155    on_change: impl Fn(f32, f32) + 'static,
1156) -> View {
1157    View::new(
1158        0,
1159        ViewKind::RangeSlider {
1160            start,
1161            end,
1162            min: range.0,
1163            max: range.1,
1164            step,
1165            on_change: Some(Rc::new(on_change)),
1166        },
1167    )
1168    .modifier(Modifier::new().height(28.0))
1169    .semantics(Semantics {
1170        role: Role::Slider,
1171        label: None,
1172        focused: false,
1173        enabled: true,
1174    })
1175}