Skip to main content

repose_material/material3/
components.rs

1#![allow(non_snake_case)]
2
3use std::cell::{Cell, RefCell};
4use std::rc::Rc;
5use std::sync::atomic::{AtomicU64, Ordering};
6use web_time::Duration;
7
8use repose_core::NestedScrollConnection;
9use repose_core::animation::{AnimationSpec, CubicBezier, Easing, KeyframesSpec, RepeatableSpec};
10use repose_core::*;
11use repose_ui::anim::{animate_color, animate_f32};
12use repose_ui::textfield::{TextMeasureConfig, measure_text};
13use repose_ui::TextFieldConfig as BasicTextFieldConfig;
14use repose_ui::{BasicTextField, Box, Column, Row, Text, TextFieldState, TextStyle, ViewExt};
15
16use super::*;
17
18use crate::ripple::{RippleConfig, ripple};
19use crate::{Icon, Symbol};
20
21fn lerp_color(a: Color, b: Color, t: f32) -> Color {
22    let t = t.clamp(0.0, 1.0);
23    Color(
24        (a.0 as f32 + (b.0 as f32 - a.0 as f32) * t)
25            .round()
26            .clamp(0.0, 255.0) as u8,
27        (a.1 as f32 + (b.1 as f32 - a.1 as f32) * t)
28            .round()
29            .clamp(0.0, 255.0) as u8,
30        (a.2 as f32 + (b.2 as f32 - a.2 as f32) * t)
31            .round()
32            .clamp(0.0, 255.0) as u8,
33        (a.3 as f32 + (b.3 as f32 - a.3 as f32) * t)
34            .round()
35            .clamp(0.0, 255.0) as u8,
36    )
37}
38
39/// Color slots for [`TopAppBar`].
40#[derive(Clone, Copy, Debug)]
41pub struct TopAppBarColors {
42    pub container_color: Color,
43    pub scrolled_container_color: Color,
44    pub navigation_icon_content_color: Color,
45    pub title_content_color: Color,
46    pub subtitle_content_color: Color,
47    pub action_icon_content_color: Color,
48}
49
50impl TopAppBarColors {
51    pub fn container_color(&self, scroll_fraction: f32) -> Color {
52        lerp_color(
53            self.container_color,
54            self.scrolled_container_color,
55            scroll_fraction.clamp(0.0, 1.0),
56        )
57    }
58}
59
60impl Default for TopAppBarColors {
61    fn default() -> Self {
62        Self {
63            container_color: TopAppBarDefaults::container_color(),
64            scrolled_container_color: TopAppBarDefaults::scrolled_container_color(),
65            navigation_icon_content_color: TopAppBarDefaults::navigation_icon_content_color(),
66            title_content_color: TopAppBarDefaults::title_content_color(),
67            subtitle_content_color: TopAppBarDefaults::subtitle_content_color(),
68            action_icon_content_color: TopAppBarDefaults::action_icon_content_color(),
69        }
70    }
71}
72
73/// Scroll response mode for [`TopAppBarScrollBehavior`].
74#[derive(Clone, Copy, Debug, PartialEq)]
75pub enum TopAppBarScrollMode {
76    /// Always visible, no scroll response.
77    Pinned,
78    /// Collapses upward when scrolling down, expands when scrolling up.
79    EnterAlways,
80}
81
82/// Drives scroll-based collapsing/expanding of a TopAppBar.
83///
84/// Create one, pass its [`nested_scroll_connection`](TopAppBarScrollBehavior::nested_scroll_connection)
85/// to a lazy list's [`set_nested_scroll_parent`] method, and set the
86/// resulting [`collapsed_offset`](TopAppBarScrollBehavior::collapsed_offset)
87/// on the TopAppBar via [`TopAppBarConfig::scroll_offset`].
88pub struct TopAppBarScrollBehavior {
89    pub collapsed_offset: Signal<f32>,
90    pub height: f32,
91    pub collapsed_height: f32,
92    pub mode: TopAppBarScrollMode,
93    _pending: Rc<Cell<f32>>,
94}
95
96impl TopAppBarScrollBehavior {
97    pub fn new(height: f32, collapsed_height: f32, mode: TopAppBarScrollMode) -> Self {
98        Self {
99            collapsed_offset: signal(0.0),
100            height,
101            collapsed_height,
102            mode,
103            _pending: Rc::new(Cell::new(0.0)),
104        }
105    }
106
107    /// Returns a [`NestedScrollConnection`] that collapses the bar on
108    /// downward scroll and expands on upward scroll.
109    pub fn nested_scroll_connection(&self) -> NestedScrollConnection {
110        let off = self.collapsed_offset.clone();
111        let max_collapse = -(self.height - self.collapsed_height);
112        let mode = self.mode;
113
114        NestedScrollConnection::new().on_pre_scroll(move |d: Vec2, _source| -> Vec2 {
115            if mode == TopAppBarScrollMode::Pinned {
116                return Vec2::ZERO;
117            }
118            let current = off.get();
119            if d.y > 0.0 {
120                // Scrolling down → collapse bar
121                if current <= max_collapse {
122                    return Vec2::ZERO;
123                }
124                let collapse_room = current - max_collapse;
125                let consume = d.y.min(collapse_room);
126                off.set(current - consume);
127                repose_core::request_frame();
128                Vec2 { x: 0.0, y: consume }
129            } else {
130                // Scrolling up → expand bar
131                if current >= 0.0 {
132                    return Vec2::ZERO;
133                }
134                let expansion_room = -current;
135                let consume = (-d.y).min(expansion_room);
136                off.set(current + consume);
137                repose_core::request_frame();
138                Vec2 { x: 0.0, y: consume }
139            }
140        })
141    }
142
143    /// Returns the current collapsed offset (0 = fully expanded, negative = collapsed).
144    pub fn offset(&self) -> f32 {
145        self.collapsed_offset.get()
146    }
147}
148
149/// Configuration for [`TopAppBar`].
150#[derive(Clone, Debug)]
151pub struct TopAppBarConfig {
152    pub modifier: Modifier,
153    pub colors: TopAppBarColors,
154    pub height: f32,
155    pub scroll_fraction: f32,
156    /// Vertical translate offset (negative = collapsed upward).
157    /// Set this from [`TopAppBarScrollBehavior::collapsed_offset`].
158    pub scroll_offset: f32,
159    pub window_insets: WindowInsets,
160    pub content_padding: PaddingValues,
161}
162
163/// System window insets for top app bar padding.
164#[derive(Clone, Copy, Debug)]
165pub struct WindowInsets {
166    pub top: f32,
167    pub bottom: f32,
168    pub left: f32,
169    pub right: f32,
170}
171
172impl Default for WindowInsets {
173    fn default() -> Self {
174        Self {
175            top: 0.0,
176            bottom: 0.0,
177            left: 0.0,
178            right: 0.0,
179        }
180    }
181}
182
183impl Default for TopAppBarConfig {
184    fn default() -> Self {
185        Self {
186            modifier: Modifier::new(),
187            colors: TopAppBarColors::default(),
188            height: TopAppBarDefaults::HEIGHT,
189            scroll_fraction: 0.0,
190            scroll_offset: 0.0,
191            window_insets: WindowInsets::default(),
192            content_padding: PaddingValues {
193                left: 4.0,
194                right: 4.0,
195                top: 0.0,
196                bottom: 0.0,
197            },
198        }
199    }
200}
201
202fn top_app_bar_layout(
203    title: View,
204    subtitle: Option<View>,
205    navigation_icon: Option<View>,
206    actions: Vec<View>,
207    config: TopAppBarConfig,
208    centered: bool,
209) -> View {
210    let insets = config.window_insets;
211    let bg = config.colors.container_color(config.scroll_fraction);
212    let mut m = Modifier::new()
213        .min_width(200.0)
214        .height(config.height + insets.top)
215        .background(bg)
216        .translate(0.0, config.scroll_offset)
217        .padding_values(PaddingValues {
218            left: config.content_padding.left + insets.left,
219            right: config.content_padding.right + insets.right,
220            top: config.content_padding.top + insets.top,
221            bottom: config.content_padding.bottom + insets.bottom,
222        })
223        .align_items(AlignItems::CENTER)
224        .then(config.modifier);
225    if centered {
226        m = m.justify_content(JustifyContent::CENTER);
227    }
228    Row(m).child((
229        navigation_icon.unwrap_or(Box(Modifier::new().width(16.0).fill_max_height())),
230        Box(Modifier::new()
231            .padding_values(PaddingValues {
232                left: 16.0,
233                right: 0.0,
234                top: 0.0,
235                bottom: 0.0,
236            })
237            .flex_grow(1.0))
238        .child(
239            Column(Modifier::new().justify_content(JustifyContent::CENTER)).child((
240                Box(Modifier::new()).child(with_content_color(
241                    config.colors.title_content_color,
242                    || title,
243                )),
244                subtitle
245                    .map(|s| {
246                        Box(Modifier::new()).child(with_content_color(
247                            config.colors.subtitle_content_color,
248                            || s,
249                        ))
250                    })
251                    .unwrap_or(Box(Modifier::new())),
252            )),
253        ),
254        Row(Modifier::new()
255            .align_items(AlignItems::CENTER)
256            .clip_rounded(20.0))
257        .child(
258            actions
259                .into_iter()
260                .map(|a| {
261                    with_content_color(config.colors.action_icon_content_color, move || a.clone())
262                })
263                .collect::<Vec<_>>(),
264        ),
265    ))
266}
267
268/// M3 Top App Bar (small). Displays a title with optional navigation icon,
269/// subtitle, and trailing action buttons.
270pub fn TopAppBar(
271    title: View,
272    subtitle: Option<View>,
273    navigation_icon: Option<View>,
274    actions: Vec<View>,
275    config: TopAppBarConfig,
276) -> View {
277    top_app_bar_layout(title, subtitle, navigation_icon, actions, config, false)
278}
279
280/// M3 Center-Aligned Top App Bar - same as TopAppBar but title is centered.
281pub fn CenterAlignedTopAppBar(
282    title: View,
283    subtitle: Option<View>,
284    navigation_icon: Option<View>,
285    actions: Vec<View>,
286    config: TopAppBarConfig,
287) -> View {
288    top_app_bar_layout(title, subtitle, navigation_icon, actions, config, true)
289}
290
291/// Configuration for [`Surface`].
292#[derive(Clone, Debug)]
293pub struct SurfaceConfig {
294    pub modifier: Modifier,
295    pub enabled: bool,
296    pub color: Color,
297    pub content_color: Color,
298    pub shape_radius: f32,
299    pub tonal_elevation: f32,
300    pub shadow_elevation: f32,
301    pub border: Option<(f32, Color)>,
302    pub interaction_source: Option<MutableInteractionSource>,
303}
304
305impl Default for SurfaceConfig {
306    fn default() -> Self {
307        Self {
308            modifier: Modifier::new(),
309            enabled: true,
310            color: SurfaceDefaults::color(),
311            content_color: SurfaceDefaults::content_color(),
312            shape_radius: SurfaceDefaults::SHAPE_RADIUS,
313            tonal_elevation: SurfaceDefaults::TONAL_ELEVATION,
314            shadow_elevation: SurfaceDefaults::SHADOW_ELEVATION,
315            border: None,
316            interaction_source: None,
317        }
318    }
319}
320
321/// M3 Surface - a basic container with shape, color, elevation, and border.
322/// Sets the ContentColor local for children based on the surface color.
323pub fn Surface(config: SurfaceConfig, content: impl FnOnce() -> View) -> View {
324    let sf_source: Rc<MutableInteractionSource> = config
325        .interaction_source
326        .clone()
327        .map(Rc::new)
328        .unwrap_or_else(|| remember(MutableInteractionSource::new));
329    let mut m = Modifier::new()
330        .background(config.color)
331        .clip_rounded(config.shape_radius)
332        .interaction_source(&*sf_source)
333        .then(config.modifier);
334    if config.tonal_elevation > 0.0 {
335        m = m.state_elevation(StateElevation {
336            default: config.tonal_elevation,
337            hovered: config.tonal_elevation,
338            pressed: config.tonal_elevation,
339            disabled: 0.0,
340        });
341    }
342    if config.shadow_elevation > 0.0 {
343        m = m.shadow(config.shadow_elevation, 0.0);
344    }
345    if let Some((w, c)) = config.border {
346        m = m.border(w, c, config.shape_radius);
347    }
348    Box(m).color(config.content_color).child(content())
349}
350
351/// Color slots for icon buttons.
352#[derive(Clone, Copy, Debug)]
353pub struct IconButtonColors {
354    pub container_color: Color,
355    pub content_color: Color,
356    pub disabled_container_color: Color,
357    pub disabled_content_color: Color,
358}
359
360impl IconButtonColors {
361    pub fn container(&self, enabled: bool) -> Color {
362        if enabled {
363            self.container_color
364        } else {
365            self.disabled_container_color
366        }
367    }
368    pub fn content(&self, enabled: bool) -> Color {
369        if enabled {
370            self.content_color
371        } else {
372            self.disabled_content_color
373        }
374    }
375}
376
377/// Configuration for [`IconButton`], [`FilledIconButton`], [`FilledTonalIconButton`], and [`OutlinedIconButton`].
378#[derive(Clone, Debug)]
379pub struct IconButtonConfig {
380    pub modifier: Modifier,
381    pub enabled: bool,
382    pub colors: IconButtonColors,
383    pub container_size: Option<f32>,
384    pub interaction_source: Option<MutableInteractionSource>,
385    pub shape_radius: Option<f32>,
386}
387
388impl Default for IconButtonConfig {
389    fn default() -> Self {
390        Self {
391            modifier: Modifier::new(),
392            enabled: true,
393            colors: IconButtonColors {
394                container_color: Color::TRANSPARENT,
395                content_color: IconButtonDefaults::content_color(),
396                disabled_container_color: Color::TRANSPARENT,
397                disabled_content_color: IconButtonDefaults::disabled_content_color(),
398            },
399            container_size: None,
400            interaction_source: None,
401            shape_radius: None,
402        }
403    }
404}
405
406fn icon_button_render(
407    icon: View,
408    on_click: impl Fn() + 'static,
409    config: &IconButtonConfig,
410    sz: f32,
411    bg: Option<Color>,
412    bdr: Option<(f32, Color)>,
413    state_colors: StateColors,
414) -> View {
415    let is_enabled = config.enabled;
416    let content_color = config.colors.content(is_enabled);
417    let radius = config.shape_radius.unwrap_or(sz * 0.5);
418    let mut m = Modifier::new()
419        .size(sz, sz)
420        .clip_rounded(radius)
421        .state_colors(state_colors)
422        .align_items(AlignItems::CENTER)
423        .justify_content(JustifyContent::CENTER)
424        .then(config.modifier.clone());
425
426    if let Some(bg_color) = bg {
427        m = m.background(bg_color);
428    }
429    if let Some((w, c)) = bdr {
430        m = m.border(w, c, radius);
431    }
432    let source: Rc<MutableInteractionSource> = config
433        .interaction_source
434        .clone()
435        .map(Rc::new)
436        .unwrap_or_else(|| remember(MutableInteractionSource::new));
437    m = m.interaction_source(&*source);
438    if is_enabled {
439        m = m.clickable().on_click(move || on_click());
440    }
441
442    Box(m).child(icon)
443}
444
445/// M3 Icon Button - a tappable circular container for an icon.
446pub fn IconButton(icon: View, on_click: impl Fn() + 'static, config: IconButtonConfig) -> View {
447    let th = theme();
448    let sz = config
449        .container_size
450        .unwrap_or(IconButtonDefaults::CONTAINER_SIZE);
451    icon_button_render(
452        icon,
453        on_click,
454        &config,
455        sz,
456        None,
457        None,
458        StateColors {
459            default: Color::TRANSPARENT,
460            hovered: th.on_surface.with_alpha_f32(0.08),
461            pressed: th.on_surface.with_alpha_f32(0.12),
462            disabled: Color::TRANSPARENT,
463        },
464    )
465}
466
467/// M3 Filled Icon Button - icon button with a filled container background.
468pub fn FilledIconButton(
469    icon: View,
470    on_click: impl Fn() + 'static,
471    config: IconButtonConfig,
472) -> View {
473    let th = theme();
474    let is_enabled = config.enabled;
475    let sz = config
476        .container_size
477        .unwrap_or(IconButtonDefaults::FILLED_CONTAINER_SIZE);
478    let bg = config.colors.container(is_enabled);
479    let content_color = config.colors.content(is_enabled);
480    icon_button_render(
481        icon,
482        on_click,
483        &config,
484        sz,
485        Some(bg),
486        None,
487        StateColors {
488            default: Color::TRANSPARENT,
489            hovered: content_color.with_alpha_f32(0.08),
490            pressed: content_color.with_alpha_f32(0.12),
491            disabled: th.on_surface.with_alpha_f32(0.12),
492        },
493    )
494}
495
496/// M3 Filled Tonal Icon Button - icon button with a secondary container background.
497pub fn FilledTonalIconButton(
498    icon: View,
499    on_click: impl Fn() + 'static,
500    config: IconButtonConfig,
501) -> View {
502    let th = theme();
503    let is_enabled = config.enabled;
504    let sz = config
505        .container_size
506        .unwrap_or(IconButtonDefaults::FILLED_CONTAINER_SIZE);
507    let bg = config.colors.container(is_enabled);
508    let content_color = config.colors.content(is_enabled);
509    icon_button_render(
510        icon,
511        on_click,
512        &config,
513        sz,
514        Some(bg),
515        None,
516        StateColors {
517            default: Color::TRANSPARENT,
518            hovered: content_color.with_alpha_f32(0.08),
519            pressed: content_color.with_alpha_f32(0.12),
520            disabled: th.on_surface.with_alpha_f32(0.12),
521        },
522    )
523}
524
525/// M3 Outlined Icon Button - icon button with a transparent background and border.
526pub fn OutlinedIconButton(
527    icon: View,
528    on_click: impl Fn() + 'static,
529    config: IconButtonConfig,
530) -> View {
531    let th = theme();
532    let sz = config
533        .container_size
534        .unwrap_or(IconButtonDefaults::CONTAINER_SIZE);
535    let border_color = if config.enabled {
536        th.outline
537    } else {
538        th.on_surface.with_alpha_f32(0.12)
539    };
540    icon_button_render(
541        icon,
542        on_click,
543        &config,
544        sz,
545        None,
546        Some((1.0, border_color)),
547        StateColors {
548            default: Color::TRANSPARENT,
549            hovered: th.on_surface.with_alpha_f32(0.08),
550            pressed: th.on_surface.with_alpha_f32(0.12),
551            disabled: Color::TRANSPARENT,
552        },
553    )
554}
555
556/// Color slots for buttons (matching Compose Material3 `ButtonColors`).
557#[derive(Clone, Copy, Debug)]
558pub struct ButtonColors {
559    pub container_color: Color,
560    pub content_color: Color,
561    pub disabled_container_color: Color,
562    pub disabled_content_color: Color,
563}
564
565impl ButtonColors {
566    pub fn container(&self, enabled: bool) -> Color {
567        if enabled {
568            self.container_color
569        } else {
570            self.disabled_container_color
571        }
572    }
573    pub fn content(&self, enabled: bool) -> Color {
574        if enabled {
575            self.content_color
576        } else {
577            self.disabled_content_color
578        }
579    }
580}
581
582/// Elevation levels for buttons (matching Compose Material3 `ButtonElevation`).
583#[derive(Clone, Copy, Debug)]
584pub struct ButtonElevation {
585    pub default: f32,
586    pub pressed: f32,
587    pub focused: f32,
588    pub hovered: f32,
589    pub disabled: f32,
590}
591
592/// Configuration for button components.
593#[derive(Clone, Debug)]
594pub struct ButtonConfig {
595    pub modifier: Modifier,
596    pub enabled: bool,
597    pub content_color: Option<Color>,
598    pub container_color: Option<Color>,
599    pub state_colors: StateColors,
600    pub state_elevation: Option<StateElevation>,
601    pub border: Option<(f32, Color, f32)>,
602    pub shape_radius: f32,
603    pub content_padding: Option<PaddingValues>,
604    pub height: f32,
605    pub colors: Option<ButtonColors>,
606    pub elevation: Option<ButtonElevation>,
607    pub interaction_source: Option<MutableInteractionSource>,
608}
609
610impl Default for ButtonConfig {
611    fn default() -> Self {
612        Self {
613            modifier: Modifier::new(),
614            enabled: true,
615            content_color: None,
616            container_color: None,
617            state_colors: ButtonDefaults::state_colors_default(),
618            state_elevation: None,
619            border: None,
620            shape_radius: ButtonDefaults::SHAPE_RADIUS,
621            content_padding: None,
622            height: ButtonDefaults::HEIGHT,
623            colors: None,
624            elevation: None,
625            interaction_source: None,
626        }
627    }
628}
629
630/// Resolve effective button colors from config, given the variant's default colors.
631/// When `config.colors` is set, it takes priority over individual fields.
632fn resolve_button_colors(
633    config: &ButtonConfig,
634    def: ButtonColors,
635) -> (Color, Option<Color>, StateColors, Option<StateElevation>) {
636    if let Some(colors) = &config.colors {
637        let bg = if config.enabled {
638            colors.container_color
639        } else {
640            colors.disabled_container_color
641        };
642        let cc = if config.enabled {
643            colors.content_color
644        } else {
645            colors.disabled_content_color
646        };
647        let sc = StateColors {
648            default: Color::TRANSPARENT,
649            hovered: colors.content_color.with_alpha_f32(0.08),
650            pressed: colors.content_color.with_alpha_f32(0.12),
651            disabled: Color::TRANSPARENT,
652        };
653        let se = config.elevation.map(|e| StateElevation {
654            default: e.default,
655            hovered: e.hovered,
656            pressed: e.pressed,
657            disabled: e.disabled,
658        });
659        (cc, Some(bg), sc, se)
660    } else {
661        let cc = config.content_color.unwrap_or(def.content_color);
662        let bg = Some(config.container_color.unwrap_or(def.container_color));
663        let sc = if config.enabled {
664            config.state_colors
665        } else {
666            StateColors {
667                default: Color::TRANSPARENT,
668                hovered: Color::TRANSPARENT,
669                pressed: Color::TRANSPARENT,
670                disabled: config.state_colors.disabled,
671            }
672        };
673        let se = config.state_elevation;
674        (cc, bg, sc, se)
675    }
676}
677
678fn button_impl(
679    outer_modifier: Modifier,
680    on_click: impl Fn() + 'static,
681    content: impl FnOnce() -> View,
682    content_color: Color,
683    container_color: Option<Color>,
684    state_colors: StateColors,
685    state_elevation: Option<StateElevation>,
686    border: Option<(f32, Color, f32)>,
687    padding_left: f32,
688    padding_right: f32,
689    height: f32,
690    shape_radius: f32,
691    enabled: bool,
692    interaction_source: Option<MutableInteractionSource>,
693) -> View {
694    let mut m = Modifier::new().min_height(height).min_width(48.0).flex_shrink(0.0);
695    if let Some(bg) = container_color {
696        m = m.background(bg);
697    }
698    m = m.state_colors(if enabled {
699        state_colors
700    } else {
701        StateColors {
702            default: Color::TRANSPARENT,
703            hovered: Color::TRANSPARENT,
704            pressed: Color::TRANSPARENT,
705            disabled: state_colors.disabled,
706        }
707    });
708    if let Some(se) = state_elevation {
709        m = m.state_elevation(se);
710    }
711    if let Some((w, c, r)) = border {
712        m = m.border(w, c, r);
713    }
714    m = m
715        .clip_rounded(shape_radius)
716        .padding_values(PaddingValues {
717            left: padding_left,
718            right: padding_right,
719            top: 8.0,
720            bottom: 8.0,
721        })
722        .align_items(AlignItems::CENTER)
723        .justify_content(JustifyContent::CENTER);
724
725    // Interaction source + ripple indication
726    let source: Rc<MutableInteractionSource> = interaction_source.map(Rc::new).unwrap_or_else(|| {
727        match outer_modifier.key {
728            Some(k) => {
729                remember_with_key(format!("m3_btn_src:{k}"), MutableInteractionSource::new)
730            }
731            None => remember(MutableInteractionSource::new),
732        }
733    });
734    m = m.interaction_source(&*source);
735    m = m.indication(ripple(RippleConfig {
736        color: Some(content_color),
737        bounded: true,
738        ..Default::default()
739    }));
740
741    if enabled {
742        m = m.clickable().on_click(move || on_click());
743    }
744    m = m.then(outer_modifier);
745    let effective = if enabled {
746        content_color
747    } else {
748        content_color.with_alpha_f32(0.38)
749    };
750    let content = with_content_color(effective, content);
751    Box(m).child(content)
752}
753
754/// M3 Button - prominent action button with primary color fill.
755/// (Equivalent to Compose Material3's `Button`.)
756pub fn Button(
757    modifier: Modifier,
758    on_click: impl Fn() + 'static,
759    config: ButtonConfig,
760    content: impl FnOnce() -> View,
761) -> View {
762    let def = ButtonColors {
763        container_color: ButtonDefaults::container_color(),
764        content_color: ButtonDefaults::content_color(),
765        disabled_container_color: ButtonDefaults::container_color()
766            .with_alpha_f32(0.12)
767            .composite_over(theme().surface_container_low),
768        disabled_content_color: ButtonDefaults::content_color().with_alpha_f32(0.38),
769    };
770    let (cc, bg, sc, se) = resolve_button_colors(&config, def);
771    let pad = config.content_padding.unwrap_or(PaddingValues {
772        left: 24.0,
773        right: 24.0,
774        top: 0.0,
775        bottom: 0.0,
776    });
777    button_impl(
778        modifier.then(config.modifier),
779        on_click,
780        content,
781        cc,
782        bg,
783        sc,
784        se.or(Some(ButtonDefaults::state_elevation_default())),
785        config.border,
786        pad.left,
787        pad.right,
788        config.height,
789        config.shape_radius,
790        config.enabled,
791        config.interaction_source.clone(),
792    )
793}
794
795/// M3 Filled Tonal Button - uses secondary container colors.
796pub fn FilledTonalButton(
797    modifier: Modifier,
798    on_click: impl Fn() + 'static,
799    config: ButtonConfig,
800    content: impl FnOnce() -> View,
801) -> View {
802    let th = theme();
803    let def = ButtonColors {
804        container_color: ButtonDefaults::tonal_container_color(),
805        content_color: ButtonDefaults::tonal_content_color(),
806        disabled_container_color: th
807            .on_surface
808            .with_alpha_f32(0.12)
809            .composite_over(th.surface_container_low),
810        disabled_content_color: th.on_surface.with_alpha_f32(0.38),
811    };
812    let (cc, bg, sc, se) = resolve_button_colors(&config, def);
813    let pad = config.content_padding.unwrap_or(PaddingValues {
814        left: 24.0,
815        right: 24.0,
816        top: 0.0,
817        bottom: 0.0,
818    });
819    button_impl(
820        modifier.then(config.modifier),
821        on_click,
822        content,
823        cc,
824        bg,
825        sc,
826        se.or(Some(ButtonDefaults::state_elevation_default())),
827        config.border,
828        pad.left,
829        pad.right,
830        config.height,
831        config.shape_radius,
832        config.enabled,
833        config.interaction_source.clone(),
834    )
835}
836
837/// M3 Outlined Button - button with an outline border and no fill.
838pub fn OutlinedButton(
839    modifier: Modifier,
840    on_click: impl Fn() + 'static,
841    config: ButtonConfig,
842    content: impl FnOnce() -> View,
843) -> View {
844    let th = theme();
845    let def = ButtonColors {
846        container_color: Color::TRANSPARENT,
847        content_color: ButtonDefaults::outlined_content_color(),
848        disabled_container_color: Color::TRANSPARENT,
849        disabled_content_color: th.on_surface.with_alpha_f32(0.38),
850    };
851    let (cc, bg, sc, se) = resolve_button_colors(&config, def);
852    let border = config
853        .border
854        .unwrap_or((1.0, ButtonDefaults::outlined_border_color(), 20.0));
855    let pad = config.content_padding.unwrap_or(PaddingValues {
856        left: 24.0,
857        right: 24.0,
858        top: 0.0,
859        bottom: 0.0,
860    });
861    button_impl(
862        modifier.then(config.modifier),
863        on_click,
864        content,
865        cc,
866        bg,
867        sc,
868        se,
869        Some(border),
870        pad.left,
871        pad.right,
872        config.height,
873        config.shape_radius,
874        config.enabled,
875        config.interaction_source.clone(),
876    )
877}
878
879/// M3 Text Button - a low-emphasis button.
880pub fn TextButton(
881    modifier: Modifier,
882    on_click: impl Fn() + 'static,
883    config: ButtonConfig,
884    content: impl FnOnce() -> View,
885) -> View {
886    let th = theme();
887    let def = ButtonColors {
888        container_color: Color::TRANSPARENT,
889        content_color: ButtonDefaults::text_content_color(),
890        disabled_container_color: Color::TRANSPARENT,
891        disabled_content_color: th.on_surface.with_alpha_f32(0.38),
892    };
893    let (cc, bg, sc, se) = resolve_button_colors(&config, def);
894    let pad = config.content_padding.unwrap_or(PaddingValues {
895        left: 12.0,
896        right: 12.0,
897        top: 0.0,
898        bottom: 0.0,
899    });
900    button_impl(
901        modifier.then(config.modifier),
902        on_click,
903        content,
904        cc,
905        bg,
906        sc,
907        se,
908        None,
909        pad.left,
910        pad.right,
911        config.height,
912        config.shape_radius,
913        config.enabled,
914        config.interaction_source.clone(),
915    )
916}
917
918/// M3 Elevated Button - uses `surface_container_low` background with elevation.
919pub fn ElevatedButton(
920    modifier: Modifier,
921    on_click: impl Fn() + 'static,
922    config: ButtonConfig,
923    content: impl FnOnce() -> View,
924) -> View {
925    let th = theme();
926    let def = ButtonColors {
927        container_color: ButtonDefaults::elevated_container_color(),
928        content_color: ButtonDefaults::elevated_content_color(),
929        disabled_container_color: th.on_surface.with_alpha_f32(0.04),
930        disabled_content_color: th.on_surface.with_alpha_f32(0.38),
931    };
932    let (cc, bg, sc, se) = resolve_button_colors(&config, def);
933    let pad = config.content_padding.unwrap_or(PaddingValues {
934        left: 24.0,
935        right: 24.0,
936        top: 0.0,
937        bottom: 0.0,
938    });
939    button_impl(
940        modifier.then(config.modifier),
941        on_click,
942        content,
943        cc,
944        bg,
945        sc,
946        se.or(Some(ButtonDefaults::elevated_state_elevation())),
947        config.border,
948        pad.left,
949        pad.right,
950        config.height,
951        config.shape_radius,
952        config.enabled,
953        config.interaction_source.clone(),
954    )
955}
956
957/// Configuration for toggle button components.
958#[derive(Clone, Debug)]
959pub struct ToggleButtonConfig {
960    pub modifier: Modifier,
961    pub enabled: bool,
962    pub container_color: Option<Color>,
963    pub content_color: Option<Color>,
964    pub checked_container_color: Option<Color>,
965    pub checked_content_color: Option<Color>,
966    pub state_colors: StateColors,
967    pub state_elevation: Option<StateElevation>,
968    pub border: Option<(f32, Color, f32)>,
969    pub shape_radius: f32,
970    pub height: f32,
971    pub content_padding: Option<PaddingValues>,
972    pub interaction_source: Option<MutableInteractionSource>,
973}
974
975impl Default for ToggleButtonConfig {
976    fn default() -> Self {
977        Self {
978            modifier: Modifier::new(),
979            enabled: true,
980            container_color: None,
981            content_color: None,
982            checked_container_color: None,
983            checked_content_color: None,
984            state_colors: ToggleButtonDefaults::state_colors_default(),
985            state_elevation: None,
986            border: None,
987            shape_radius: ToggleButtonDefaults::SHAPE_RADIUS,
988            height: ToggleButtonDefaults::HEIGHT,
989            content_padding: None,
990            interaction_source: None,
991        }
992    }
993}
994
995fn toggle_button_impl(
996    checked: bool,
997    on_checked_change: impl Fn(bool) + 'static,
998    content: impl FnOnce(bool) -> View,
999    content_color: Color,
1000    container_color: Option<Color>,
1001    checked_container_color: Option<Color>,
1002    checked_content_color: Option<Color>,
1003    state_colors: StateColors,
1004    state_elevation: StateElevation,
1005    border: Option<(f32, Color, f32)>,
1006    pad_left: f32,
1007    pad_right: f32,
1008    height: f32,
1009    shape_radius: f32,
1010    enabled: bool,
1011    interaction_source: Option<MutableInteractionSource>,
1012) -> View {
1013    let th = theme();
1014    let bg = if checked {
1015        checked_container_color.unwrap_or(th.primary)
1016    } else {
1017        container_color.unwrap_or(Color::TRANSPARENT)
1018    };
1019    let fg = if checked {
1020        checked_content_color.unwrap_or(th.on_primary)
1021    } else {
1022        content_color
1023    };
1024    let mut m = Modifier::new()
1025        .min_height(height)
1026        .padding_values(PaddingValues {
1027            left: pad_left,
1028            right: pad_right,
1029            top: 8.0,
1030            bottom: 8.0,
1031        })
1032        .background(bg)
1033        .clip_rounded(shape_radius)
1034        .align_items(AlignItems::CENTER)
1035        .justify_content(JustifyContent::CENTER)
1036        .state_colors(state_colors)
1037        .state_elevation(state_elevation);
1038    let tg_source: Rc<MutableInteractionSource> = interaction_source
1039        .map(Rc::new)
1040        .unwrap_or_else(|| remember(MutableInteractionSource::new));
1041    m = m.interaction_source(&*tg_source);
1042    if let Some((w, c, r)) = border {
1043        m = m.border(w, c, r);
1044    }
1045    if enabled {
1046        let cb = on_checked_change;
1047        m = m.clickable().on_click(move || cb(!checked));
1048    } else {
1049        m = m.alpha(0.38);
1050    }
1051    with_content_color(fg, || Box(m).child(content(checked)))
1052}
1053
1054/// M3 Toggle Button - a button that toggles between checked/unchecked states.
1055pub fn ToggleButton(
1056    checked: bool,
1057    on_checked_change: impl Fn(bool) + 'static,
1058    config: ToggleButtonConfig,
1059    content: impl FnOnce(bool) -> View,
1060) -> View {
1061    let cc = config
1062        .content_color
1063        .unwrap_or_else(ToggleButtonDefaults::content_color);
1064    let checked_cc = config
1065        .checked_content_color
1066        .unwrap_or_else(ToggleButtonDefaults::checked_content_color);
1067    let checked_bg = config
1068        .checked_container_color
1069        .unwrap_or_else(ToggleButtonDefaults::checked_container_color);
1070    let se = config
1071        .state_elevation
1072        .unwrap_or_else(ToggleButtonDefaults::state_elevation_default);
1073    let pad_l = config
1074        .content_padding
1075        .map(|p| p.left)
1076        .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING);
1077    let pad_r = config
1078        .content_padding
1079        .map(|p| p.right)
1080        .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING);
1081    toggle_button_impl(
1082        checked,
1083        on_checked_change,
1084        content,
1085        cc,
1086        None,
1087        Some(checked_bg),
1088        Some(checked_cc),
1089        config.state_colors,
1090        se,
1091        config.border,
1092        pad_l,
1093        pad_r,
1094        config.height,
1095        config.shape_radius,
1096        config.enabled,
1097        config.interaction_source.clone(),
1098    )
1099}
1100
1101/// M3 Tonal Toggle Button - uses secondary container colors.
1102pub fn TonalToggleButton(
1103    checked: bool,
1104    on_checked_change: impl Fn(bool) + 'static,
1105    config: ToggleButtonConfig,
1106    content: impl FnOnce(bool) -> View,
1107) -> View {
1108    let cc = config
1109        .content_color
1110        .unwrap_or_else(ToggleButtonDefaults::tonal_content_color);
1111    let checked_cc = config
1112        .checked_content_color
1113        .unwrap_or_else(ToggleButtonDefaults::tonal_checked_content_color);
1114    let checked_bg = config
1115        .checked_container_color
1116        .unwrap_or_else(ToggleButtonDefaults::tonal_checked_container_color);
1117    let se = config
1118        .state_elevation
1119        .unwrap_or_else(ToggleButtonDefaults::state_elevation_default);
1120    toggle_button_impl(
1121        checked,
1122        on_checked_change,
1123        content,
1124        cc,
1125        None,
1126        Some(checked_bg),
1127        Some(checked_cc),
1128        config.state_colors,
1129        se,
1130        config.border,
1131        config
1132            .content_padding
1133            .map(|p| p.left)
1134            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
1135        config
1136            .content_padding
1137            .map(|p| p.right)
1138            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
1139        config.height,
1140        config.shape_radius,
1141        config.enabled,
1142        config.interaction_source.clone(),
1143    )
1144}
1145
1146/// M3 Outlined Toggle Button - outlined button that toggles between states.
1147pub fn OutlinedToggleButton(
1148    checked: bool,
1149    on_checked_change: impl Fn(bool) + 'static,
1150    config: ToggleButtonConfig,
1151    content: impl FnOnce(bool) -> View,
1152) -> View {
1153    let cc = config
1154        .content_color
1155        .unwrap_or_else(ToggleButtonDefaults::outlined_content_color);
1156    let checked_cc = config
1157        .checked_content_color
1158        .unwrap_or_else(ToggleButtonDefaults::outlined_checked_content_color);
1159    let checked_bg = config
1160        .checked_container_color
1161        .unwrap_or_else(ToggleButtonDefaults::outlined_checked_container_color);
1162    let se = config
1163        .state_elevation
1164        .unwrap_or_else(ToggleButtonDefaults::state_elevation_default);
1165    let border = if !checked {
1166        Some(config.border.unwrap_or((
1167            1.0,
1168            ToggleButtonDefaults::outlined_border_color(),
1169            config.shape_radius,
1170        )))
1171    } else {
1172        config.border
1173    };
1174    toggle_button_impl(
1175        checked,
1176        on_checked_change,
1177        content,
1178        cc,
1179        None,
1180        Some(checked_bg),
1181        Some(checked_cc),
1182        config.state_colors,
1183        se,
1184        border,
1185        config
1186            .content_padding
1187            .map(|p| p.left)
1188            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
1189        config
1190            .content_padding
1191            .map(|p| p.right)
1192            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
1193        config.height,
1194        config.shape_radius,
1195        config.enabled,
1196        config.interaction_source.clone(),
1197    )
1198}
1199
1200/// M3 Elevated Toggle Button - elevated button that toggles between states.
1201pub fn ElevatedToggleButton(
1202    checked: bool,
1203    on_checked_change: impl Fn(bool) + 'static,
1204    config: ToggleButtonConfig,
1205    content: impl FnOnce(bool) -> View,
1206) -> View {
1207    let cc = config
1208        .content_color
1209        .unwrap_or_else(ToggleButtonDefaults::elevated_content_color);
1210    let checked_cc = config
1211        .checked_content_color
1212        .unwrap_or_else(ToggleButtonDefaults::elevated_checked_content_color);
1213    let checked_bg = config
1214        .checked_container_color
1215        .unwrap_or_else(ToggleButtonDefaults::elevated_checked_container_color);
1216    let se = config
1217        .state_elevation
1218        .unwrap_or_else(ToggleButtonDefaults::elevated_state_elevation);
1219    toggle_button_impl(
1220        checked,
1221        on_checked_change,
1222        content,
1223        cc,
1224        None,
1225        Some(checked_bg),
1226        Some(checked_cc),
1227        config.state_colors,
1228        se,
1229        config.border,
1230        config
1231            .content_padding
1232            .map(|p| p.left)
1233            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
1234        config
1235            .content_padding
1236            .map(|p| p.right)
1237            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
1238        config.height,
1239        config.shape_radius,
1240        config.enabled,
1241        config.interaction_source.clone(),
1242    )
1243}
1244
1245/// Configuration for FAB components.
1246#[derive(Clone, Debug)]
1247pub struct FABConfig {
1248    pub modifier: Modifier,
1249    pub enabled: bool,
1250    pub container_color: Color,
1251    pub content_color: Color,
1252    pub state_elevation: StateElevation,
1253    pub shape_radius: f32,
1254    pub size: f32,
1255    pub interaction_source: Option<MutableInteractionSource>,
1256}
1257
1258impl Default for FABConfig {
1259    fn default() -> Self {
1260        Self {
1261            modifier: Modifier::new(),
1262            enabled: true,
1263            container_color: FABDefaults::container_color(),
1264            content_color: FABDefaults::content_color(),
1265            state_elevation: FABDefaults::state_elevation(),
1266            shape_radius: FABDefaults::SHAPE_RADIUS,
1267            size: FABDefaults::SIZE,
1268            interaction_source: None,
1269        }
1270    }
1271}
1272
1273fn fab_impl(
1274    icon: View,
1275    on_click: impl Fn() + 'static,
1276    size: f32,
1277    shape_r: f32,
1278    config: FABConfig,
1279) -> View {
1280    let th = theme();
1281    let is_enabled = config.enabled;
1282    let bg = if is_enabled {
1283        config.container_color
1284    } else {
1285        th.on_surface
1286            .with_alpha_f32(0.12)
1287            .composite_over(th.surface_container_low)
1288    };
1289    let content_color = if is_enabled {
1290        config.content_color
1291    } else {
1292        th.on_surface.with_alpha_f32(0.38)
1293    };
1294
1295    let mut m = Modifier::new()
1296        .size(size, size)
1297        .background(bg)
1298        .state_colors(StateColors {
1299            default: Color::TRANSPARENT,
1300            hovered: config.content_color.with_alpha_f32(0.08),
1301            pressed: config.content_color.with_alpha_f32(0.12),
1302            disabled: th.on_surface.with_alpha_f32(0.12),
1303        })
1304        .state_elevation(config.state_elevation)
1305        .clip_rounded(shape_r)
1306        .align_items(AlignItems::CENTER)
1307        .justify_content(JustifyContent::CENTER)
1308        .then(config.modifier);
1309
1310    let source: Rc<MutableInteractionSource> = config
1311        .interaction_source
1312        .map(Rc::new)
1313        .unwrap_or_else(|| remember(MutableInteractionSource::new));
1314    m = m.interaction_source(&*source);
1315    if is_enabled {
1316        m = m.clickable().on_click(move || on_click());
1317    }
1318
1319    Box(m).child(icon)
1320}
1321
1322/// M3 Floating Action Button (regular, 56dp).
1323pub fn FAB(icon: View, on_click: impl Fn() + 'static, config: FABConfig) -> View {
1324    fab_impl(
1325        icon,
1326        on_click,
1327        FABDefaults::SIZE,
1328        FABDefaults::SHAPE_RADIUS,
1329        config,
1330    )
1331}
1332
1333/// M3 Small FAB (40dp).
1334pub fn SmallFAB(icon: View, on_click: impl Fn() + 'static, config: FABConfig) -> View {
1335    fab_impl(
1336        icon,
1337        on_click,
1338        FABDefaults::SMALL_SIZE,
1339        FABDefaults::SMALL_SHAPE_RADIUS,
1340        config,
1341    )
1342}
1343
1344/// M3 Large FAB (96dp).
1345pub fn LargeFAB(icon: View, on_click: impl Fn() + 'static, config: FABConfig) -> View {
1346    fab_impl(
1347        icon,
1348        on_click,
1349        FABDefaults::LARGE_SIZE,
1350        FABDefaults::LARGE_SHAPE_RADIUS,
1351        config,
1352    )
1353}
1354
1355/// M3 Extended FAB - FAB with icon + label.
1356pub fn ExtendedFAB(
1357    icon: Option<View>,
1358    label: impl Into<String>,
1359    on_click: impl Fn() + 'static,
1360    config: FABConfig,
1361) -> View {
1362    let th = theme();
1363    let has_icon = icon.is_some();
1364    let is_enabled = config.enabled;
1365    let bg = if is_enabled {
1366        config.container_color
1367    } else {
1368        th.on_surface
1369            .with_alpha_f32(0.12)
1370            .composite_over(th.surface_container_low)
1371    };
1372    let content_color = if is_enabled {
1373        config.content_color
1374    } else {
1375        th.on_surface.with_alpha_f32(0.38)
1376    };
1377
1378    let source: Rc<MutableInteractionSource> = config
1379        .interaction_source
1380        .clone()
1381        .map(Rc::new)
1382        .unwrap_or_else(|| remember(MutableInteractionSource::new));
1383
1384    let mut m = Modifier::new()
1385        .height(56.0)
1386        .min_width(80.0)
1387        .background(bg)
1388        .state_colors(StateColors {
1389            default: Color::TRANSPARENT,
1390            hovered: config.content_color.with_alpha_f32(0.08),
1391            pressed: config.content_color.with_alpha_f32(0.12),
1392            disabled: theme().on_surface.with_alpha_f32(0.12),
1393        })
1394        .state_elevation(config.state_elevation)
1395        .clip_rounded(FABDefaults::SHAPE_RADIUS)
1396        .padding_values(PaddingValues {
1397            left: 16.0,
1398            right: 20.0,
1399            top: 0.0,
1400            bottom: 0.0,
1401        })
1402        .align_items(AlignItems::CENTER);
1403
1404    m = m.interaction_source(&*source);
1405    if is_enabled {
1406        m = m.clickable().on_click(move || on_click());
1407    }
1408    m = m.then(config.modifier);
1409    Row(m).child((
1410        icon.unwrap_or(Box(Modifier::new())),
1411        Box(Modifier::new()
1412            .width(if has_icon { 12.0 } else { 0.0 })
1413            .fill_max_height()),
1414        Text(label)
1415            .color(content_color)
1416            .size(th.typography.label_large)
1417            .single_line(),
1418    ))
1419}
1420
1421/// Configuration for divider components.
1422#[derive(Clone, Debug)]
1423pub struct DividerConfig {
1424    pub modifier: Modifier,
1425    pub thickness: f32,
1426    pub color: Color,
1427}
1428
1429impl Default for DividerConfig {
1430    fn default() -> Self {
1431        Self {
1432            modifier: Modifier::new(),
1433            thickness: DividerDefaults::THICKNESS,
1434            color: DividerDefaults::color(),
1435        }
1436    }
1437}
1438
1439/// M3 Horizontal Divider - a thin 1dp line.
1440/// (Equivalent to Compose Material3's `HorizontalDivider`.)
1441pub fn HorizontalDivider(config: DividerConfig) -> View {
1442    Box(Modifier::new()
1443        .min_width(200.0)
1444        .height(config.thickness)
1445        .background(config.color)
1446        .then(config.modifier))
1447}
1448
1449#[deprecated(since = "0.19.5", note = "renamed to HorizontalDivider")]
1450pub fn Divider(config: DividerConfig) -> View {
1451    HorizontalDivider(config)
1452}
1453
1454/// M3 Vertical Divider - a thin 1dp vertical line.
1455pub fn VerticalDivider(config: DividerConfig) -> View {
1456    Box(Modifier::new()
1457        .width(config.thickness)
1458        .fill_max_height()
1459        .background(config.color)
1460        .then(config.modifier))
1461}
1462
1463/// Configuration for [`Badge`].
1464#[derive(Clone, Debug)]
1465pub struct BadgeConfig {
1466    pub modifier: Modifier,
1467    pub container_color: Color,
1468    pub content_color: Color,
1469}
1470
1471impl Default for BadgeConfig {
1472    fn default() -> Self {
1473        Self {
1474            modifier: Modifier::new(),
1475            container_color: BadgeDefaults::container_color(),
1476            content_color: BadgeDefaults::content_color(),
1477        }
1478    }
1479}
1480
1481/// M3 Badge - a small notification indicator. If `content` is `None`, shows a
1482/// small 6dp dot; otherwise shows the content inside a 16dp pill.
1483pub fn Badge(content: Option<View>, config: BadgeConfig) -> View {
1484    match content {
1485        None => Box(Modifier::new()
1486            .size(BadgeDefaults::DOT_SIZE, BadgeDefaults::DOT_SIZE)
1487            .background(config.container_color)
1488            .clip_rounded(BadgeDefaults::DOT_SIZE * 0.5)
1489            .flex_shrink(0.0)
1490            .then(config.modifier)),
1491        Some(view) => Box(Modifier::new()
1492            .min_width(BadgeDefaults::LABEL_MIN_WIDTH)
1493            .height(BadgeDefaults::LABEL_HEIGHT)
1494            .background(config.container_color)
1495            .clip_rounded(BadgeDefaults::LABEL_HEIGHT * 0.5)
1496            .padding_values(PaddingValues {
1497                left: 4.0,
1498                right: 4.0,
1499                top: 0.0,
1500                bottom: 0.0,
1501            })
1502            .align_items(AlignItems::CENTER)
1503            .justify_content(JustifyContent::CENTER)
1504            .flex_shrink(0.0)
1505            .then(config.modifier))
1506        .child(with_content_color(config.content_color, move || view)),
1507    }
1508}
1509
1510/// Configuration for [`BadgedBox`].
1511#[derive(Clone, Debug)]
1512pub struct BadgedBoxConfig {
1513    pub modifier: Modifier,
1514    /// Horizontal offset for the badge when it's a small dot.
1515    pub dot_offset_x: f32,
1516    /// Vertical offset for the badge when it's a small dot.
1517    pub dot_offset_y: f32,
1518    /// Horizontal offset for the badge when it has content.
1519    pub content_offset_x: f32,
1520    /// Vertical offset for the badge when it has content.
1521    pub content_offset_y: f32,
1522    /// When true, use `content_offset_*` (labeled badge). When false, use `dot_offset_*`.
1523    pub has_content: bool,
1524}
1525
1526impl Default for BadgedBoxConfig {
1527    fn default() -> Self {
1528        Self {
1529            modifier: Modifier::new(),
1530            dot_offset_x: BadgeDefaults::DOT_OFFSET_X,
1531            dot_offset_y: BadgeDefaults::DOT_OFFSET_Y,
1532            content_offset_x: BadgeDefaults::CONTENT_OFFSET_X,
1533            content_offset_y: BadgeDefaults::CONTENT_OFFSET_Y,
1534            has_content: false,
1535        }
1536    }
1537}
1538
1539/// Wraps `content` and shows a `badge` anchored to the top-end corner.
1540pub fn BadgedBox(badge: View, content: View, config: BadgedBoxConfig) -> View {
1541    let (top, right) = if config.has_content {
1542        (
1543            config.content_offset_y - BadgeDefaults::LABEL_HEIGHT, // 14 - 16 = -2
1544            config.content_offset_x - BadgeDefaults::LABEL_MIN_WIDTH, // 12 - 16 = -4
1545        )
1546    } else {
1547        (
1548            config.dot_offset_y - BadgeDefaults::DOT_SIZE, // 6 - 6 = 0
1549            config.dot_offset_x - BadgeDefaults::DOT_SIZE, // 6 - 6 = 0
1550        )
1551    };
1552
1553    Box(config.modifier.flex_shrink(0.0)).child((
1554        content,
1555        Box(Modifier::new()
1556            .absolute()
1557            .offset(None, Some(top), Some(right), None)
1558            .flex_shrink(0.0)
1559            .hit_passthrough())
1560        .child(badge),
1561    ))
1562}
1563
1564/// Colors for [`ListItem`] -> matches Compose Material3 `ListItemColors` with
1565/// 4 state groups (default, disabled, selected, dragged) × 6 slots each.
1566#[derive(Clone, Debug)]
1567pub struct ListItemColors {
1568    pub container_color: Color,
1569    pub headline_color: Color,
1570    pub supporting_color: Color,
1571    pub overline_color: Color,
1572    pub leading_icon_color: Color,
1573    pub trailing_icon_color: Color,
1574
1575    pub disabled_container_color: Color,
1576    pub disabled_headline_color: Color,
1577    pub disabled_supporting_color: Color,
1578    pub disabled_overline_color: Color,
1579    pub disabled_leading_icon_color: Color,
1580    pub disabled_trailing_icon_color: Color,
1581
1582    pub selected_container_color: Color,
1583    pub selected_headline_color: Color,
1584    pub selected_supporting_color: Color,
1585    pub selected_overline_color: Color,
1586    pub selected_leading_icon_color: Color,
1587    pub selected_trailing_icon_color: Color,
1588
1589    pub dragged_container_color: Color,
1590    pub dragged_headline_color: Color,
1591    pub dragged_supporting_color: Color,
1592    pub dragged_overline_color: Color,
1593    pub dragged_leading_icon_color: Color,
1594    pub dragged_trailing_icon_color: Color,
1595}
1596
1597impl ListItemColors {
1598    pub fn container(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
1599        if !enabled {
1600            self.disabled_container_color
1601        } else if dragged {
1602            self.dragged_container_color
1603        } else if selected {
1604            self.selected_container_color
1605        } else {
1606            self.container_color
1607        }
1608    }
1609    pub fn headline(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
1610        if !enabled {
1611            self.disabled_headline_color
1612        } else if dragged {
1613            self.dragged_headline_color
1614        } else if selected {
1615            self.selected_headline_color
1616        } else {
1617            self.headline_color
1618        }
1619    }
1620    pub fn supporting(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
1621        if !enabled {
1622            self.disabled_supporting_color
1623        } else if dragged {
1624            self.dragged_supporting_color
1625        } else if selected {
1626            self.selected_supporting_color
1627        } else {
1628            self.supporting_color
1629        }
1630    }
1631    pub fn overline(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
1632        if !enabled {
1633            self.disabled_overline_color
1634        } else if dragged {
1635            self.dragged_overline_color
1636        } else if selected {
1637            self.selected_overline_color
1638        } else {
1639            self.overline_color
1640        }
1641    }
1642    pub fn leading_icon(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
1643        if !enabled {
1644            self.disabled_leading_icon_color
1645        } else if dragged {
1646            self.dragged_leading_icon_color
1647        } else if selected {
1648            self.selected_leading_icon_color
1649        } else {
1650            self.leading_icon_color
1651        }
1652    }
1653    pub fn trailing_icon(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
1654        if !enabled {
1655            self.disabled_trailing_icon_color
1656        } else if dragged {
1657            self.dragged_trailing_icon_color
1658        } else if selected {
1659            self.selected_trailing_icon_color
1660        } else {
1661            self.trailing_icon_color
1662        }
1663    }
1664}
1665
1666impl Default for ListItemColors {
1667    fn default() -> Self {
1668        Self {
1669            container_color: Color::TRANSPARENT,
1670            headline_color: ListItemDefaults::headline_color(),
1671            supporting_color: ListItemDefaults::supporting_color(),
1672            overline_color: ListItemDefaults::overline_color(),
1673            leading_icon_color: ListItemDefaults::leading_icon_color(),
1674            trailing_icon_color: ListItemDefaults::trailing_icon_color(),
1675            disabled_container_color: ListItemDefaults::disabled_container_color(),
1676            disabled_headline_color: ListItemDefaults::disabled_headline_color(),
1677            disabled_supporting_color: ListItemDefaults::disabled_supporting_color(),
1678            disabled_overline_color: ListItemDefaults::disabled_overline_color(),
1679            disabled_leading_icon_color: ListItemDefaults::disabled_leading_icon_color(),
1680            disabled_trailing_icon_color: ListItemDefaults::disabled_trailing_icon_color(),
1681            selected_container_color: ListItemDefaults::selected_container_color(),
1682            selected_headline_color: ListItemDefaults::selected_headline_color(),
1683            selected_supporting_color: ListItemDefaults::selected_supporting_color(),
1684            selected_overline_color: ListItemDefaults::selected_overline_color(),
1685            selected_leading_icon_color: ListItemDefaults::selected_leading_icon_color(),
1686            selected_trailing_icon_color: ListItemDefaults::selected_trailing_icon_color(),
1687            dragged_container_color: ListItemDefaults::dragged_container_color(),
1688            dragged_headline_color: ListItemDefaults::dragged_headline_color(),
1689            dragged_supporting_color: ListItemDefaults::dragged_supporting_color(),
1690            dragged_overline_color: ListItemDefaults::dragged_overline_color(),
1691            dragged_leading_icon_color: ListItemDefaults::dragged_leading_icon_color(),
1692            dragged_trailing_icon_color: ListItemDefaults::dragged_trailing_icon_color(),
1693        }
1694    }
1695}
1696
1697/// Configuration for [`ListItem`].
1698#[derive(Clone, Debug)]
1699pub struct ListItemConfig {
1700    pub modifier: Modifier,
1701    /// When false, renders disabled colors and suppresses clicks.
1702    pub enabled: bool,
1703    pub selected: bool,
1704    pub colors: ListItemColors,
1705    pub state_colors: StateColors,
1706    pub tonal_elevation: f32,
1707    pub shadow_elevation: f32,
1708    pub shape_radius: f32,
1709    /// Per-corner radii `[BL, BR, TR, TL]`. When set, overrides `shape_radius`.
1710    pub shape_radii: Option<[f32; 4]>,
1711    pub horizontal_padding: f32,
1712    pub trailing_padding: f32,
1713    pub one_line_height: f32,
1714    pub two_line_height: f32,
1715    pub three_line_height: f32,
1716    pub interaction_source: Option<MutableInteractionSource>,
1717}
1718
1719impl Default for ListItemConfig {
1720    fn default() -> Self {
1721        Self {
1722            modifier: Modifier::new(),
1723            enabled: true,
1724            selected: false,
1725            colors: ListItemColors::default(),
1726            state_colors: ListItemDefaults::state_colors_default(),
1727            tonal_elevation: 0.0,
1728            shadow_elevation: 0.0,
1729            shape_radius: 0.0,
1730            shape_radii: None,
1731            horizontal_padding: ListItemDefaults::HORIZONTAL_PADDING,
1732            trailing_padding: ListItemDefaults::TRAILING_PADDING,
1733            one_line_height: ListItemDefaults::ONE_LINE_HEIGHT,
1734            two_line_height: ListItemDefaults::TWO_LINE_HEIGHT,
1735            three_line_height: ListItemDefaults::THREE_LINE_HEIGHT,
1736            interaction_source: None,
1737        }
1738    }
1739}
1740
1741static LISTITEM_COUNTER: AtomicU64 = AtomicU64::new(0);
1742
1743/// M3 List Item - a single row in a list with optional leading/trailing content,
1744/// overline text, and click handling.
1745pub fn ListItem(
1746    headline: impl Into<String>,
1747    supporting_text: Option<String>,
1748    overline_text: Option<String>,
1749    leading: Option<View>,
1750    trailing: Option<View>,
1751    on_click: Option<Rc<dyn Fn()>>,
1752    on_long_click: Option<Rc<dyn Fn()>>,
1753    config: ListItemConfig,
1754) -> View {
1755    let th = theme();
1756    let is_enabled = config.enabled;
1757    let is_selected = config.selected;
1758    let c = &config.colors;
1759    let id = remember(|| LISTITEM_COUNTER.fetch_add(1, Ordering::Relaxed));
1760    let spec = th.motion.color;
1761
1762    let hd_col = animate_color(
1763        format!("li_hd_{}", id),
1764        c.headline(is_enabled, is_selected, false),
1765        spec,
1766    );
1767    let sp_col = animate_color(
1768        format!("li_sp_{}", id),
1769        c.supporting(is_enabled, is_selected, false),
1770        spec,
1771    );
1772    let ol_col = animate_color(
1773        format!("li_ol_{}", id),
1774        c.overline(is_enabled, is_selected, false),
1775        spec,
1776    );
1777    let ld_col = animate_color(
1778        format!("li_ld_{}", id),
1779        c.leading_icon(is_enabled, is_selected, false),
1780        spec,
1781    );
1782    let tr_col = animate_color(
1783        format!("li_tr_{}", id),
1784        c.trailing_icon(is_enabled, is_selected, false),
1785        spec,
1786    );
1787    let bg = animate_color(
1788        format!("li_bg_{}", id),
1789        c.container(is_enabled, is_selected, false),
1790        spec,
1791    );
1792
1793    let line_count = match (overline_text.is_some(), supporting_text.is_some()) {
1794        (true, true) => 3,
1795        (true, false) | (false, true) => 2,
1796        (false, false) => 1,
1797    };
1798    let min_h = match line_count {
1799        3 => config.three_line_height,
1800        2 => config.two_line_height,
1801        _ => config.one_line_height,
1802    };
1803    let top_bottom_padding = match line_count {
1804        3 => 12.0,
1805        _ => 8.0,
1806    };
1807
1808    let vert_align = if min_h >= config.three_line_height {
1809        AlignItems::START
1810    } else {
1811        AlignItems::CENTER
1812    };
1813
1814    let li_source: Rc<MutableInteractionSource> = config
1815        .interaction_source
1816        .clone()
1817        .map(Rc::new)
1818        .unwrap_or_else(|| remember(MutableInteractionSource::new));
1819
1820    let mut modifier = Modifier::new()
1821        .min_width(200.0)
1822        .min_height(min_h)
1823        .background(bg);
1824    match config.shape_radii {
1825        Some(r) => modifier = modifier.clip_rounded_radii(r),
1826        None => modifier = modifier.clip_rounded(config.shape_radius),
1827    }
1828    modifier = modifier
1829        .state_colors(config.state_colors)
1830        .padding_values(PaddingValues {
1831            left: config.horizontal_padding,
1832            right: config.trailing_padding,
1833            top: top_bottom_padding,
1834            bottom: top_bottom_padding,
1835        })
1836        .align_items(vert_align)
1837        .interaction_source(&*li_source)
1838        .then(config.modifier);
1839
1840    if config.tonal_elevation > 0.0 {
1841        modifier = modifier.state_elevation(StateElevation {
1842            default: config.tonal_elevation,
1843            hovered: config.tonal_elevation,
1844            pressed: config.tonal_elevation,
1845            disabled: 0.0,
1846        });
1847    }
1848    if config.shadow_elevation > 0.0 {
1849        modifier = modifier.shadow(config.shadow_elevation, 0.0);
1850    }
1851
1852    if on_click.is_some() || on_long_click.is_some() {
1853        modifier = modifier.clickable();
1854        if let Some(cb) = on_click {
1855            let cb = cb.clone();
1856            modifier = modifier.on_click(move || {
1857                if is_enabled {
1858                    cb();
1859                }
1860            });
1861        }
1862        if let Some(cb) = &on_long_click {
1863            let cb = cb.clone();
1864            modifier = modifier.on_long_click(move || {
1865                if is_enabled {
1866                    cb();
1867                }
1868            });
1869        }
1870    }
1871
1872    let wrap_icon = |color: Color, v: View| -> View { with_content_color(color, move || v) };
1873
1874    Row(modifier).child((
1875        leading
1876            .map(|v| {
1877                Box(Modifier::new().padding_values(PaddingValues {
1878                    left: 0.0,
1879                    right: 16.0,
1880                    top: 0.0,
1881                    bottom: 0.0,
1882                }))
1883                .child(wrap_icon(ld_col, v))
1884            })
1885            .unwrap_or(Box(Modifier::new())),
1886        Column(
1887            Modifier::new()
1888                .flex_grow(1.0)
1889                .justify_content(JustifyContent::CENTER),
1890        )
1891        .child((
1892            overline_text
1893                .map(|ot| {
1894                    Text(ot)
1895                        .color(ol_col)
1896                        .size(th.typography.label_small)
1897                        .single_line()
1898                })
1899                .unwrap_or(Box(Modifier::new())),
1900            Text(headline)
1901                .color(hd_col)
1902                .size(th.typography.body_large)
1903                .single_line(),
1904            supporting_text
1905                .map(|st| {
1906                    Text(st)
1907                        .color(sp_col)
1908                        .size(th.typography.body_medium)
1909                        .max_lines(2)
1910                        .overflow_ellipsize()
1911                })
1912                .unwrap_or(Box(Modifier::new())),
1913        )),
1914        trailing
1915            .map(|v| {
1916                Box(Modifier::new().padding_values(PaddingValues {
1917                    left: 16.0,
1918                    right: 0.0,
1919                    top: 0.0,
1920                    bottom: 0.0,
1921                }))
1922                .child(wrap_icon(tr_col, v))
1923            })
1924            .unwrap_or(Box(Modifier::new())),
1925    ))
1926}
1927
1928/// M3 Selectable List Item -> single-selection variant with `selected` state and
1929/// `Role::RadioButton` semantics.
1930pub fn SelectableListItem(
1931    headline: impl Into<String>,
1932    selected: bool,
1933    supporting_text: Option<String>,
1934    overline_text: Option<String>,
1935    leading: Option<View>,
1936    trailing: Option<View>,
1937    on_click: Option<Rc<dyn Fn()>>,
1938    mut config: ListItemConfig,
1939) -> View {
1940    config.selected = selected;
1941    let mut m = Modifier::new().semantics(Semantics::new(Role::RadioButton));
1942    m = m.then(config.modifier);
1943    config.modifier = m;
1944    ListItem(
1945        headline,
1946        supporting_text,
1947        overline_text,
1948        leading,
1949        trailing,
1950        on_click,
1951        None,
1952        config,
1953    )
1954}
1955
1956/// M3 Toggleable List Item -> multi-selection variant with `checked` state and
1957/// `Role::Checkbox` semantics. Clicking toggles the checked state.
1958pub fn ToggleableListItem(
1959    headline: impl Into<String>,
1960    checked: bool,
1961    on_checked_change: impl Fn(bool) + 'static,
1962    supporting_text: Option<String>,
1963    overline_text: Option<String>,
1964    leading: Option<View>,
1965    trailing: Option<View>,
1966    config: ListItemConfig,
1967) -> View {
1968    let mut cfg = config.clone();
1969    cfg.selected = checked;
1970    let cb = Rc::new(on_checked_change);
1971    let cb2 = cb.clone();
1972    let mut m = Modifier::new().semantics(Semantics::new(Role::Checkbox));
1973    m = m.then(cfg.modifier);
1974    cfg.modifier = m;
1975    ListItem(
1976        headline,
1977        supporting_text,
1978        overline_text,
1979        leading,
1980        trailing,
1981        Some(Rc::new(move || (cb2)(!checked))),
1982        None,
1983        cfg,
1984    )
1985}
1986
1987/// Compute per-index corner radii `[BL, BR, TR, TL]` for a segmented list item.
1988fn segmented_item_radii(index: usize, count: usize, r: f32) -> [f32; 4] {
1989    if count <= 1 {
1990        [r, r, r, r]
1991    } else if index == 0 {
1992        [0.0, 0.0, r, r]
1993    } else if index == count - 1 {
1994        [r, r, 0.0, 0.0]
1995    } else {
1996        [0.0, 0.0, 0.0, 0.0]
1997    }
1998}
1999
2000/// M3 Segmented List Item -> clickable variant with segmented (per-index) corner radii.
2001pub fn SegmentedListItem(
2002    index: usize,
2003    count: usize,
2004    headline: impl Into<String>,
2005    supporting_text: Option<String>,
2006    overline_text: Option<String>,
2007    leading: Option<View>,
2008    trailing: Option<View>,
2009    on_click: Option<Rc<dyn Fn()>>,
2010    mut config: ListItemConfig,
2011) -> View {
2012    config.shape_radii = Some(segmented_item_radii(index, count, config.shape_radius));
2013    ListItem(
2014        headline,
2015        supporting_text,
2016        overline_text,
2017        leading,
2018        trailing,
2019        on_click,
2020        None,
2021        config,
2022    )
2023}
2024
2025/// M3 Segmented List Item -> single-selection variant.
2026pub fn SegmentedSelectableListItem(
2027    index: usize,
2028    count: usize,
2029    headline: impl Into<String>,
2030    selected: bool,
2031    supporting_text: Option<String>,
2032    overline_text: Option<String>,
2033    leading: Option<View>,
2034    trailing: Option<View>,
2035    on_click: Option<Rc<dyn Fn()>>,
2036    mut config: ListItemConfig,
2037) -> View {
2038    config.selected = selected;
2039    config.shape_radii = Some(segmented_item_radii(index, count, config.shape_radius));
2040    let mut m = Modifier::new().semantics(Semantics::new(Role::RadioButton));
2041    m = m.then(config.modifier);
2042    config.modifier = m;
2043    ListItem(
2044        headline,
2045        supporting_text,
2046        overline_text,
2047        leading,
2048        trailing,
2049        on_click,
2050        None,
2051        config,
2052    )
2053}
2054
2055/// M3 Segmented List Item -> multi-selection (toggleable) variant.
2056pub fn SegmentedToggleableListItem(
2057    index: usize,
2058    count: usize,
2059    headline: impl Into<String>,
2060    checked: bool,
2061    on_checked_change: impl Fn(bool) + 'static,
2062    supporting_text: Option<String>,
2063    overline_text: Option<String>,
2064    leading: Option<View>,
2065    trailing: Option<View>,
2066    config: ListItemConfig,
2067) -> View {
2068    let mut cfg = config.clone();
2069    cfg.selected = checked;
2070    cfg.shape_radii = Some(segmented_item_radii(index, count, cfg.shape_radius));
2071    let cb2 = Rc::new(on_checked_change);
2072    let mut m = Modifier::new().semantics(Semantics::new(Role::Checkbox));
2073    m = m.then(cfg.modifier);
2074    cfg.modifier = m;
2075    ListItem(
2076        headline,
2077        supporting_text,
2078        overline_text,
2079        leading,
2080        trailing,
2081        Some(Rc::new(move || (cb2)(!checked))),
2082        None,
2083        cfg,
2084    )
2085}
2086
2087/// A single tab definition for use with `TabRow`.
2088pub struct Tab {
2089    pub label: String,
2090    pub icon: Option<View>,
2091    pub on_click: Rc<dyn Fn()>,
2092    pub enabled: bool,
2093    pub interaction_source: Option<MutableInteractionSource>,
2094}
2095
2096/// Configuration for [`TabRow`].
2097#[derive(Clone, Debug)]
2098pub struct TabRowConfig {
2099    pub modifier: Modifier,
2100    pub container_color: Color,
2101    pub selected_content_color: Color,
2102    pub unselected_content_color: Color,
2103    pub indicator_color: Color,
2104    pub height: f32,
2105    pub indicator_height: f32,
2106}
2107
2108impl Default for TabRowConfig {
2109    fn default() -> Self {
2110        Self {
2111            modifier: Modifier::new(),
2112            container_color: TabDefaults::container_color(),
2113            selected_content_color: TabDefaults::selected_content_color(),
2114            unselected_content_color: TabDefaults::unselected_content_color(),
2115            indicator_color: TabDefaults::indicator_color(),
2116            height: TabDefaults::HEIGHT,
2117            indicator_height: TabDefaults::INDICATOR_HEIGHT,
2118        }
2119    }
2120}
2121
2122static TABROW_COUNTER: AtomicU64 = AtomicU64::new(0);
2123
2124/// M3 Tab Row -> a horizontal row of tabs with per-tab animated-height indicators.
2125/// Text colors animate with DefaultEffects (spring_crit 40.0).
2126/// Indicator height animates with DefaultEffects (spring_crit 40.0).
2127pub fn TabRow(selected_index: usize, tabs: Vec<Tab>, config: TabRowConfig) -> View {
2128    let th = theme();
2129    let id = remember(|| TABROW_COUNTER.fetch_add(1, Ordering::Relaxed));
2130    let default_effects = AnimationSpec::spring_crit(40.0);
2131    Column(Modifier::new().fill_max_width().then(config.modifier)).child((
2132        Row(Modifier::new()
2133            .fill_max_width()
2134            .height(config.height)
2135            .background(config.container_color)
2136            .semantics(Semantics::new(Role::Container).with_selectable_group()))
2137        .child(
2138            tabs.into_iter()
2139                .enumerate()
2140                .map(|(i, tab)| {
2141                    let selected = i == selected_index;
2142                    let is_enabled = tab.enabled;
2143                    let color = animate_color(
2144                        format!("tab_clr_{}_{}", id, i),
2145                        if selected {
2146                            config.selected_content_color
2147                        } else {
2148                            config.unselected_content_color
2149                        },
2150                        default_effects,
2151                    );
2152                    let indicator_h = animate_f32(
2153                        format!("tab_ind_h_{}_{}", id, i),
2154                        if selected {
2155                            config.indicator_height
2156                        } else {
2157                            0.0
2158                        },
2159                        default_effects,
2160                    );
2161                    let cb = tab.on_click.clone();
2162                    let tab_source: Rc<MutableInteractionSource> = tab
2163                        .interaction_source
2164                        .clone()
2165                        .map(Rc::new)
2166                        .unwrap_or_else(|| remember(MutableInteractionSource::new));
2167
2168                    let mut tab_m = Modifier::new()
2169                        .flex_grow(1.0)
2170                        .fill_max_height()
2171                        .interaction_source(&*tab_source)
2172                        .align_items(AlignItems::CENTER)
2173                        .justify_content(JustifyContent::CENTER)
2174                        .state_colors(StateColors {
2175                            default: Color::TRANSPARENT,
2176                            hovered: th.on_surface.with_alpha_f32(0.08),
2177                            pressed: th.on_surface.with_alpha_f32(0.12),
2178                            disabled: Color::TRANSPARENT,
2179                        })
2180                        .semantics(Semantics::new(Role::Tab).with_label(&tab.label));
2181
2182                    if is_enabled {
2183                        tab_m = tab_m.clickable().on_click(move || cb());
2184                    }
2185
2186                    Column(tab_m).child((
2187                        tab.icon.unwrap_or(Box(Modifier::new())),
2188                        Text(tab.label)
2189                            .color(color)
2190                            .size(th.typography.title_small)
2191                            .single_line(),
2192                        Box(Modifier::new()
2193                            .fill_max_width()
2194                            .height(indicator_h)
2195                            .background(config.indicator_color)
2196                            .clip_rounded(TabDefaults::INDICATOR_CORNER)),
2197                    ))
2198                })
2199                .collect::<Vec<_>>(),
2200        ),
2201        // Divider
2202        Box(Modifier::new()
2203            .fill_max_width()
2204            .height(1.0)
2205            .background(th.outline_variant)),
2206    ))
2207}
2208
2209/// Configuration for a single segment in [`SegmentedButton`].
2210#[derive(Clone)]
2211pub struct SegmentConfig {
2212    pub label: String,
2213    pub icon: Option<View>,
2214    pub on_click: Rc<dyn Fn()>,
2215    pub enabled: bool,
2216    pub interaction_source: Option<MutableInteractionSource>,
2217}
2218
2219impl Default for SegmentConfig {
2220    fn default() -> Self {
2221        Self {
2222            label: String::new(),
2223            icon: None,
2224            on_click: Rc::new(|| {}),
2225            enabled: true,
2226            interaction_source: None,
2227        }
2228    }
2229}
2230
2231/// Configuration for [`SegmentedButton`].
2232#[derive(Clone, Debug)]
2233pub struct SegmentedButtonConfig {
2234    pub modifier: Modifier,
2235    pub border_color: Color,
2236    pub selected_container_color: Color,
2237    pub selected_content_color: Color,
2238    pub unselected_content_color: Color,
2239    pub state_colors: StateColors,
2240    pub height: f32,
2241    pub shape_radius: f32,
2242    pub content_padding: PaddingValues,
2243}
2244
2245impl Default for SegmentedButtonConfig {
2246    fn default() -> Self {
2247        Self {
2248            modifier: Modifier::new(),
2249            border_color: SegmentedButtonDefaults::border_color(),
2250            selected_container_color: SegmentedButtonDefaults::selected_container_color(),
2251            selected_content_color: SegmentedButtonDefaults::selected_content_color(),
2252            unselected_content_color: SegmentedButtonDefaults::unselected_content_color(),
2253            state_colors: SegmentedButtonDefaults::state_colors_default(),
2254            height: SegmentedButtonDefaults::HEIGHT,
2255            shape_radius: SegmentedButtonDefaults::SHAPE_RADIUS,
2256            content_padding: SegmentedButtonDefaults::CONTENT_PADDING,
2257        }
2258    }
2259}
2260
2261static SEGBUTTON_COUNTER: AtomicU64 = AtomicU64::new(0);
2262
2263/// M3 Segmented Button - a row of toggle segments. `selected` contains the
2264/// indices of selected segments (single-select: pass a single-element set).
2265/// Each segment is shaped independently: first has rounded left corners,
2266/// last has rounded right corners, middle segments are rectangular.
2267pub fn SegmentedButton(
2268    selected: &[usize],
2269    segments: Vec<SegmentConfig>,
2270    config: SegmentedButtonConfig,
2271) -> View {
2272    let th = theme();
2273    let count = segments.len();
2274    let id = remember(|| SEGBUTTON_COUNTER.fetch_add(1, Ordering::Relaxed));
2275    let spec = th.motion.color;
2276    let shape_r = config.shape_radius;
2277
2278    // corner order: [BL, BR, TR, TL]
2279    let segment_radii = |i: usize| -> [f32; 4] {
2280        if count == 1 {
2281            [shape_r, shape_r, shape_r, shape_r]
2282        } else if i == 0 {
2283            [shape_r, 0.0, 0.0, shape_r]
2284        } else if i == count - 1 {
2285            [0.0, shape_r, shape_r, 0.0]
2286        } else {
2287            [0.0, 0.0, 0.0, 0.0]
2288        }
2289    };
2290
2291    // Outer border wraps the entire group. Internal dividers are inside each segment Row.
2292    Row(Modifier::new()
2293        .height(config.height)
2294        .border(1.0, config.border_color, shape_r)
2295        .then(config.modifier))
2296    .child(
2297        segments
2298            .into_iter()
2299            .enumerate()
2300            .map(|(i, seg)| {
2301                let is_selected = selected.contains(&i);
2302
2303                let bg = animate_color(
2304                    format!("sb_bg_{}_{}", id, i),
2305                    if is_selected {
2306                        config.selected_container_color
2307                    } else {
2308                        Color::TRANSPARENT
2309                    },
2310                    spec,
2311                );
2312                let fg = animate_color(
2313                    format!("sb_fg_{}_{}", id, i),
2314                    if is_selected {
2315                        config.selected_content_color
2316                    } else {
2317                        config.unselected_content_color
2318                    },
2319                    spec,
2320                );
2321
2322                let cb = seg.on_click.clone();
2323                let radii = segment_radii(i);
2324                let is_enabled = seg.enabled;
2325                let seg_source: Rc<MutableInteractionSource> = seg
2326                    .interaction_source
2327                    .clone()
2328                    .map(Rc::new)
2329                    .unwrap_or_else(|| remember(MutableInteractionSource::new));
2330
2331                let state_colors = config.state_colors;
2332                let content_modifier = Modifier::new()
2333                    .flex_grow(1.0)
2334                    .fill_max_height()
2335                    .clip_rounded_radii(radii)
2336                    .background(bg)
2337                    .state_colors(state_colors)
2338                    .interaction_source(&*seg_source)
2339                    .align_items(AlignItems::CENTER)
2340                    .justify_content(JustifyContent::CENTER)
2341                    .padding_values(config.content_padding);
2342
2343                let content_modifier = if is_enabled {
2344                    content_modifier.clickable().on_click(move || cb())
2345                } else {
2346                    content_modifier
2347                };
2348
2349                Row(Modifier::new().flex_grow(1.0).fill_max_height()).child((
2350                    Row(content_modifier).child((
2351                        seg.icon.unwrap_or(Box(Modifier::new())),
2352                        Text(seg.label)
2353                            .color(fg)
2354                            .size(th.typography.label_large)
2355                            .single_line(),
2356                    )),
2357                    if i < count - 1 {
2358                        Box(Modifier::new()
2359                            .width(1.0)
2360                            .fill_max_height()
2361                            .background(th.outline))
2362                    } else {
2363                        Box(Modifier::new())
2364                    },
2365                ))
2366            })
2367            .collect::<Vec<_>>(),
2368    )
2369}
2370
2371/// Configuration for [`CircularProgressIndicator`].
2372#[derive(Clone, Debug)]
2373pub struct CircularProgressIndicatorConfig {
2374    pub modifier: Modifier,
2375    pub color: Color,
2376    pub track_color: Color,
2377    pub stroke_width: f32,
2378    pub stroke_cap: StrokeCap,
2379    pub gap_size: f32,
2380}
2381
2382impl Default for CircularProgressIndicatorConfig {
2383    fn default() -> Self {
2384        Self {
2385            modifier: Modifier::new(),
2386            color: ProgressIndicatorDefaults::circular_color(),
2387            track_color: ProgressIndicatorDefaults::circular_track_color(),
2388            stroke_width: ProgressIndicatorDefaults::CIRCULAR_STROKE_WIDTH,
2389            stroke_cap: StrokeCap::Round,
2390            gap_size: 0.0,
2391        }
2392    }
2393}
2394
2395/// M3 Circular Progress Indicator.
2396///
2397/// Determinate (`Some(0..1)`): draws arc from 12 o'clock clockwise.
2398/// Indeterminate (`None`): animates a spinning 270° arc.
2399pub fn CircularProgressIndicator(
2400    value: Option<f32>,
2401    config: CircularProgressIndicatorConfig,
2402) -> View {
2403    let sz = dp_to_px(ProgressIndicatorDefaults::CIRCULAR_INDICATOR_SIZE);
2404    let stroke_px = dp_to_px(config.stroke_width);
2405    let val = value.map(|v| v.clamp(0.0, 1.0));
2406
2407    // Three concurrent animations matching Compose Material3 indeterminate spec:
2408    //   1. Global rotation -> 1080° linear over 6000ms
2409    //   2. Additional rotation -> 90° stepped jumps with EmphasizedDecelerate
2410    //   3. Sweep -> oscillates 0.1 → 0.87 → 0.1 over 6000ms
2411    let (global_rotation, additional_rotation, sweep_val) = if value.is_none() {
2412        let shared = remember_state_with_key("circ_ind_shared", || {
2413            let mut a = AnimatedValue::new(
2414                0.0f32,
2415                AnimationSpec::tween(Duration::from_millis(6000), Easing::Linear)
2416                    .repeated(RepeatableSpec::infinite()),
2417            );
2418            a.set_target(1.0);
2419            a
2420        });
2421        let mut s = shared.borrow_mut();
2422        s.update();
2423        let t = *s.get();
2424        drop(s);
2425
2426        let gv = t * 1080.0;
2427
2428        let emph = Easing::Custom(CubicBezier::new(0.05, 0.7, 0.1, 1.0));
2429        let add_kf = remember_state_with_key("circ_ind_add_kf", || KeyframesSpec {
2430            keyframes: vec![
2431                (0.0, 0.0, None),
2432                (0.05, 90.0, Some(emph)),
2433                (0.25, 90.0, None),
2434                (0.30, 180.0, None),
2435                (0.50, 180.0, None),
2436                (0.55, 270.0, None),
2437                (0.75, 270.0, None),
2438                (0.80, 360.0, None),
2439                (1.0, 360.0, None),
2440            ],
2441        });
2442        let av = add_kf.borrow().evaluate(t);
2443
2444        let std_dec = Easing::Custom(CubicBezier::new(0.2, 0.0, 0.0, 1.0));
2445        let sweep_kf = remember_state_with_key("circ_ind_sweep_kf", || KeyframesSpec {
2446            keyframes: vec![
2447                (0.0, 0.1, None),
2448                (0.5, 0.87, Some(std_dec)),
2449                (1.0, 0.1, None),
2450            ],
2451        });
2452        let sv = sweep_kf.borrow().evaluate(t);
2453
2454        (gv, av, sv)
2455    } else {
2456        (0.0, 0.0, 0.0)
2457    };
2458
2459    // Pre-compute gap angular size in radians
2460    let indicator_size_dp = ProgressIndicatorDefaults::CIRCULAR_INDICATOR_SIZE;
2461    let adjusted_gap_dp = if config.stroke_cap == StrokeCap::Butt {
2462        config.gap_size
2463    } else {
2464        config.gap_size + config.stroke_width
2465    };
2466    let circle_dia_dp = indicator_size_dp - config.stroke_width;
2467    let gap_sweep_rad = 2.0 * adjusted_gap_dp / circle_dia_dp;
2468
2469    Box(Modifier::new().size(sz, sz).then(config.modifier).painter(
2470        move |scene: &mut Scene, rect: Rect, alpha: f32| {
2471            let mul_c = |c: Color| {
2472                Color(
2473                    c.0,
2474                    c.1,
2475                    c.2,
2476                    ((c.3 as f32) * alpha).clamp(0.0, 255.0) as u8,
2477                )
2478            };
2479            let cx = rect.x + rect.w * 0.5;
2480            let cy = rect.y + rect.h * 0.5;
2481            let r = (rect.w.min(rect.h)) * 0.5 - stroke_px * 0.5;
2482            let circle = Rect {
2483                x: cx - r,
2484                y: cy - r,
2485                w: r * 2.0,
2486                h: r * 2.0,
2487            };
2488
2489            match val {
2490                Some(p) => {
2491                    let sweep_rad = p * std::f32::consts::TAU;
2492                    let start_angle = -std::f32::consts::FRAC_PI_2;
2493                    let effective_gap = gap_sweep_rad.min(sweep_rad);
2494
2495                    // Indicator arc
2496                    if p > 0.0 {
2497                        scene.nodes.push(SceneNode::Arc {
2498                            rect: circle,
2499                            start_angle,
2500                            sweep_angle: sweep_rad,
2501                            stroke_width: stroke_px,
2502                            color: mul_c(config.color),
2503                            cap: config.stroke_cap,
2504                        });
2505                    }
2506
2507                    // Track arc (with gap from indicator)
2508                    let track_start = start_angle + sweep_rad + effective_gap;
2509                    let track_sweep = std::f32::consts::TAU - sweep_rad - 2.0 * effective_gap;
2510                    if track_sweep > 0.0 {
2511                        scene.nodes.push(SceneNode::Arc {
2512                            rect: circle,
2513                            start_angle: track_start,
2514                            sweep_angle: track_sweep,
2515                            stroke_width: stroke_px,
2516                            color: mul_c(config.track_color),
2517                            cap: config.stroke_cap,
2518                        });
2519                    }
2520                }
2521                None => {
2522                    let radians =
2523                        (global_rotation + additional_rotation) * std::f32::consts::PI / 180.0;
2524                    let start_angle = -std::f32::consts::FRAC_PI_2 + radians;
2525                    let sweep_rad = sweep_val * std::f32::consts::TAU;
2526                    let effective_gap = gap_sweep_rad.min(sweep_rad);
2527
2528                    // Indicator arc
2529                    scene.nodes.push(SceneNode::Arc {
2530                        rect: circle,
2531                        start_angle,
2532                        sweep_angle: sweep_rad,
2533                        stroke_width: stroke_px,
2534                        color: mul_c(config.color),
2535                        cap: config.stroke_cap,
2536                    });
2537
2538                    // Track arc (with gap from indicator)
2539                    let track_start = start_angle + sweep_rad + effective_gap;
2540                    let track_sweep = std::f32::consts::TAU - sweep_rad - 2.0 * effective_gap;
2541                    if track_sweep > 0.0 {
2542                        scene.nodes.push(SceneNode::Arc {
2543                            rect: circle,
2544                            start_angle: track_start,
2545                            sweep_angle: track_sweep,
2546                            stroke_width: stroke_px,
2547                            color: mul_c(config.track_color),
2548                            cap: config.stroke_cap,
2549                        });
2550                    }
2551                }
2552            }
2553        },
2554    ))
2555    .semantics(Semantics {
2556        role: Role::ProgressBar,
2557        label: None,
2558        focused: false,
2559        enabled: true,
2560        selectable_group: false,
2561    })
2562}
2563
2564/// Configuration for [`LinearProgressIndicator`].
2565#[derive(Clone, Debug)]
2566pub struct LinearProgressIndicatorConfig {
2567    pub modifier: Modifier,
2568    pub color: Color,
2569    pub track_color: Color,
2570    /// Stroke cap style for the indicator ends. Default: `StrokeCap::Round`
2571    pub stroke_cap: StrokeCap,
2572    /// Gap between indicator and track, in dp.
2573    pub gap_size: f32,
2574    /// Diameter of the stop indicator dot, in dp.
2575    pub stop_size: f32,
2576}
2577
2578impl Default for LinearProgressIndicatorConfig {
2579    fn default() -> Self {
2580        Self {
2581            modifier: Modifier::new(),
2582            color: ProgressIndicatorDefaults::linear_color(),
2583            track_color: ProgressIndicatorDefaults::linear_track_color(),
2584            stroke_cap: StrokeCap::Round,
2585            gap_size: ProgressIndicatorDefaults::LINEAR_INDICATOR_GAP_SIZE,
2586            stop_size: ProgressIndicatorDefaults::LINEAR_TRACK_STOP_SIZE,
2587        }
2588    }
2589}
2590
2591/// M3 Linear Progress Indicator.
2592///
2593/// Pass `LinearProgressIndicatorConfig::default()` for standard M3 appearance,
2594/// or override individual fields via struct-update syntax.
2595pub fn LinearProgressIndicator(value: Option<f32>, config: LinearProgressIndicatorConfig) -> View {
2596    Box(Modifier::new()
2597        .fill_max_width()
2598        .height(ProgressIndicatorDefaults::LINEAR_INDICATOR_HEIGHT)
2599        .then(config.modifier)
2600        .painter(move |scene: &mut Scene, rect: Rect, alpha: f32| {
2601            let mul_c = |c: Color| {
2602                Color(
2603                    c.0,
2604                    c.1,
2605                    c.2,
2606                    ((c.3 as f32) * alpha).clamp(0.0, 255.0) as u8,
2607                )
2608            };
2609            let track_h = rect.h;
2610            let corner = track_h * 0.5;
2611            let dot_r = dp_to_px(config.stop_size) * 0.5;
2612            let cy = rect.y + rect.h * 0.5;
2613            let t = value.unwrap_or(0.0).clamp(0.0, 1.0);
2614
2615            let cap_radius = if config.stroke_cap == StrokeCap::Butt {
2616                0.0
2617            } else {
2618                corner
2619            };
2620
2621            let gap = dp_to_px(config.gap_size)
2622                - if config.stroke_cap == StrokeCap::Butt {
2623                    0.0
2624                } else {
2625                    cap_radius
2626                };
2627
2628            let cap_ofs = cap_radius;
2629            let ind_end = (t * rect.w).clamp(cap_ofs, rect.w - cap_ofs);
2630            let ind_w = (ind_end - cap_ofs).max(0.0);
2631
2632            // Indicator (active portion from left)
2633            if t > 0.0 && ind_w > 0.0 {
2634                scene.nodes.push(SceneNode::Rect {
2635                    rect: Rect {
2636                        x: rect.x + cap_ofs,
2637                        y: cy - corner,
2638                        w: ind_w,
2639                        h: track_h,
2640                    },
2641                    brush: Brush::Solid(mul_c(config.color)),
2642                    radius: [cap_radius; 4],
2643                });
2644            }
2645
2646            // Track (inactive portion after gap)
2647            let track_start = (rect.x + ind_end + gap).min(rect.x + rect.w);
2648            let track_w = (rect.x + rect.w - track_start).max(0.0);
2649            if t < 1.0 && track_w > 0.0 {
2650                let track_left = track_start + cap_ofs;
2651                let track_right = rect.x + rect.w;
2652                if track_right > track_left {
2653                    scene.nodes.push(SceneNode::Rect {
2654                        rect: Rect {
2655                            x: track_left,
2656                            y: cy - corner,
2657                            w: track_right - track_left,
2658                            h: track_h,
2659                        },
2660                        brush: Brush::Solid(mul_c(config.track_color)),
2661                        radius: [cap_radius; 4],
2662                    });
2663                }
2664            }
2665
2666            // Stop indicator at right end circle
2667            {
2668                let sx = rect.x + rect.w - dot_r;
2669                scene.nodes.push(SceneNode::Ellipse {
2670                    rect: Rect {
2671                        x: sx - dot_r,
2672                        y: cy - dot_r,
2673                        w: dot_r * 2.0,
2674                        h: dot_r * 2.0,
2675                    },
2676                    brush: Brush::Solid(mul_c(config.color)),
2677                });
2678            }
2679        }))
2680    .semantics(Semantics {
2681        role: Role::ProgressBar,
2682        label: None,
2683        focused: false,
2684        enabled: true,
2685        selectable_group: false,
2686    })
2687}
2688
2689/// Color slots for text fields -> matches Compose Material3 `TextFieldColors`.
2690/// All 42 color fields (focused/unfocused/disabled/error variants of each slot).
2691#[allow(dead_code)]
2692#[derive(Clone, Debug)]
2693pub struct TextFieldColors {
2694    pub focused_text_color: Color,
2695    pub unfocused_text_color: Color,
2696    pub disabled_text_color: Color,
2697    pub error_text_color: Color,
2698    pub focused_container_color: Color,
2699    pub unfocused_container_color: Color,
2700    pub disabled_container_color: Color,
2701    pub error_container_color: Color,
2702    pub cursor_color: Color,
2703    pub error_cursor_color: Color,
2704    pub focused_indicator_color: Color,
2705    pub unfocused_indicator_color: Color,
2706    pub disabled_indicator_color: Color,
2707    pub error_indicator_color: Color,
2708    pub focused_leading_icon_color: Color,
2709    pub unfocused_leading_icon_color: Color,
2710    pub disabled_leading_icon_color: Color,
2711    pub error_leading_icon_color: Color,
2712    pub focused_trailing_icon_color: Color,
2713    pub unfocused_trailing_icon_color: Color,
2714    pub disabled_trailing_icon_color: Color,
2715    pub error_trailing_icon_color: Color,
2716    pub focused_label_color: Color,
2717    pub unfocused_label_color: Color,
2718    pub disabled_label_color: Color,
2719    pub error_label_color: Color,
2720    pub focused_placeholder_color: Color,
2721    pub unfocused_placeholder_color: Color,
2722    pub disabled_placeholder_color: Color,
2723    pub error_placeholder_color: Color,
2724    pub focused_supporting_text_color: Color,
2725    pub unfocused_supporting_text_color: Color,
2726    pub disabled_supporting_text_color: Color,
2727    pub error_supporting_text_color: Color,
2728    pub focused_prefix_color: Color,
2729    pub unfocused_prefix_color: Color,
2730    pub disabled_prefix_color: Color,
2731    pub error_prefix_color: Color,
2732    pub focused_suffix_color: Color,
2733    pub unfocused_suffix_color: Color,
2734    pub disabled_suffix_color: Color,
2735    pub error_suffix_color: Color,
2736}
2737
2738#[allow(dead_code)]
2739impl TextFieldColors {
2740    pub fn text_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
2741        if !enabled {
2742            self.disabled_text_color
2743        } else if is_error {
2744            self.error_text_color
2745        } else if focused {
2746            self.focused_text_color
2747        } else {
2748            self.unfocused_text_color
2749        }
2750    }
2751    pub fn container_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
2752        if !enabled {
2753            self.disabled_container_color
2754        } else if is_error {
2755            self.error_container_color
2756        } else if focused {
2757            self.focused_container_color
2758        } else {
2759            self.unfocused_container_color
2760        }
2761    }
2762    pub fn cursor_color(&self, is_error: bool) -> Color {
2763        if is_error {
2764            self.error_cursor_color
2765        } else {
2766            self.cursor_color
2767        }
2768    }
2769    pub fn indicator_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
2770        if !enabled {
2771            self.disabled_indicator_color
2772        } else if is_error {
2773            self.error_indicator_color
2774        } else if focused {
2775            self.focused_indicator_color
2776        } else {
2777            self.unfocused_indicator_color
2778        }
2779    }
2780    pub fn leading_icon_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
2781        if !enabled {
2782            self.disabled_leading_icon_color
2783        } else if is_error {
2784            self.error_leading_icon_color
2785        } else if focused {
2786            self.focused_leading_icon_color
2787        } else {
2788            self.unfocused_leading_icon_color
2789        }
2790    }
2791    pub fn trailing_icon_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
2792        if !enabled {
2793            self.disabled_trailing_icon_color
2794        } else if is_error {
2795            self.error_trailing_icon_color
2796        } else if focused {
2797            self.focused_trailing_icon_color
2798        } else {
2799            self.unfocused_trailing_icon_color
2800        }
2801    }
2802    pub fn label_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
2803        if !enabled {
2804            self.disabled_label_color
2805        } else if is_error {
2806            self.error_label_color
2807        } else if focused {
2808            self.focused_label_color
2809        } else {
2810            self.unfocused_label_color
2811        }
2812    }
2813    pub fn placeholder_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
2814        if !enabled {
2815            self.disabled_placeholder_color
2816        } else if is_error {
2817            self.error_placeholder_color
2818        } else if focused {
2819            self.focused_placeholder_color
2820        } else {
2821            self.unfocused_placeholder_color
2822        }
2823    }
2824    pub fn supporting_text_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
2825        if !enabled {
2826            self.disabled_supporting_text_color
2827        } else if is_error {
2828            self.error_supporting_text_color
2829        } else if focused {
2830            self.focused_supporting_text_color
2831        } else {
2832            self.unfocused_supporting_text_color
2833        }
2834    }
2835}
2836
2837/// Default values for text field colors.
2838pub struct TextFieldDefaults;
2839
2840impl TextFieldDefaults {
2841    /// Default minimum height for a filled TextField (56dp matches M3 spec).
2842    pub const MIN_HEIGHT: f32 = 56.0;
2843    /// Default minimum width for a filled TextField (280dp matches M3 spec).
2844    pub const MIN_WIDTH: f32 = 280.0;
2845
2846    pub fn colors() -> TextFieldColors {
2847        let th = theme();
2848        TextFieldColors {
2849            focused_text_color: th.on_surface,
2850            unfocused_text_color: th.on_surface,
2851            disabled_text_color: th.on_surface.with_alpha_f32(0.38),
2852            error_text_color: th.on_surface,
2853            focused_container_color: th.surface_container_highest,
2854            unfocused_container_color: th.surface_container_highest,
2855            disabled_container_color: th.on_surface.with_alpha_f32(0.04),
2856            error_container_color: th.surface_container_highest,
2857            cursor_color: th.primary,
2858            error_cursor_color: th.error,
2859            focused_indicator_color: th.primary,
2860            unfocused_indicator_color: th.on_surface_variant,
2861            disabled_indicator_color: th.on_surface.with_alpha_f32(0.12),
2862            error_indicator_color: th.error,
2863            focused_leading_icon_color: th.on_surface_variant,
2864            unfocused_leading_icon_color: th.on_surface_variant,
2865            disabled_leading_icon_color: th.on_surface.with_alpha_f32(0.38),
2866            error_leading_icon_color: th.error,
2867            focused_trailing_icon_color: th.on_surface_variant,
2868            unfocused_trailing_icon_color: th.on_surface_variant,
2869            disabled_trailing_icon_color: th.on_surface.with_alpha_f32(0.38),
2870            error_trailing_icon_color: th.error,
2871            focused_label_color: th.primary,
2872            unfocused_label_color: th.on_surface_variant,
2873            disabled_label_color: th.on_surface.with_alpha_f32(0.38),
2874            error_label_color: th.error,
2875            focused_placeholder_color: th.on_surface_variant,
2876            unfocused_placeholder_color: th.on_surface_variant,
2877            disabled_placeholder_color: th.on_surface.with_alpha_f32(0.38),
2878            error_placeholder_color: th.error,
2879            focused_supporting_text_color: th.on_surface_variant,
2880            unfocused_supporting_text_color: th.on_surface_variant,
2881            disabled_supporting_text_color: th.on_surface.with_alpha_f32(0.38),
2882            error_supporting_text_color: th.error,
2883            focused_prefix_color: th.on_surface,
2884            unfocused_prefix_color: th.on_surface,
2885            disabled_prefix_color: th.on_surface.with_alpha_f32(0.38),
2886            error_prefix_color: th.on_surface,
2887            focused_suffix_color: th.on_surface,
2888            unfocused_suffix_color: th.on_surface,
2889            disabled_suffix_color: th.on_surface.with_alpha_f32(0.38),
2890            error_suffix_color: th.on_surface,
2891        }
2892    }
2893}
2894
2895/// Configuration for an `OutlinedTextField`.
2896#[derive(Clone)]
2897pub struct OutlinedTextFieldConfig {
2898    /// Floating label shown above the input when the field has text or is focused.
2899    /// When set, this acts as the visual placeholder (the TextField's own placeholder
2900    /// is suppressed). When the label floats, it animates to the top border.
2901    pub label: Option<String>,
2902    /// Placeholder text shown inside the TextField when empty and unfocused.
2903    /// Only shown when `label` is `None`; when a label is present the label
2904    /// itself serves as the visual placeholder.
2905    pub placeholder: Option<String>,
2906    /// Icon displayed at the start of the input.
2907    pub leading_icon: Option<View>,
2908    /// Icon displayed at the end of the input.
2909    pub trailing_icon: Option<View>,
2910    /// If true, Enter submits; if false, Enter inserts a newline.
2911    pub single_line: bool,
2912    /// If true, border and label color switch to error color.
2913    pub is_error: bool,
2914    /// If false, input is visually disabled and `on_value_change` won't fire.
2915    pub enabled: bool,
2916    /// Called when the user presses Enter on a single-line field.
2917    pub on_submit: Option<Rc<dyn Fn(String)>>,
2918    /// Colors for all text field UI elements.
2919    pub colors: Option<TextFieldColors>,
2920    /// Optional external focus tracker. When `None`, an internal focus tracker
2921    /// is created (keyed by label). Pass a tracker to synchronize focus state
2922    /// (e.g. to avoid overriding external text while the user is editing).
2923    pub focus_tracker: Option<Rc<Cell<bool>>>,
2924}
2925
2926impl Default for OutlinedTextFieldConfig {
2927    fn default() -> Self {
2928        Self {
2929            label: None,
2930            placeholder: None,
2931            leading_icon: None,
2932            trailing_icon: None,
2933            single_line: true,
2934            is_error: false,
2935            enabled: true,
2936            on_submit: None,
2937            colors: None,
2938            focus_tracker: None,
2939        }
2940    }
2941}
2942
2943/// M3 Outlined Text Field with floating label, leading/trailing icons, and error state.
2944///
2945/// The label floats up when `value` is non-empty or when the field is focused.
2946/// Note: focus-based floating is approximated via animated `float_t` - the label
2947/// begins floating once `on_value_change` fires (i.e. when the user types).
2948/// For strict focus-on-tap floating, pair with an external focus signal.
2949///
2950/// # Example
2951/// ```ignore
2952/// let text = remember(|| signal(String::new()));
2953/// OutlinedTextField(
2954///     Modifier::new().fill_max_width().padding(16.0),
2955///     text.get(),
2956///     { let t = text.clone(); move |v| t.set(v) },
2957///     OutlinedTextFieldConfig {
2958///         label: Some("Email".into()),
2959///         placeholder: Some("user@example.com".into()),
2960///         ..Default::default()
2961///     },
2962/// );
2963/// ```
2964pub fn OutlinedTextField(
2965    modifier: Modifier,
2966    value: String,
2967    on_value_change: impl Fn(String) + 'static,
2968    config: OutlinedTextFieldConfig,
2969) -> View {
2970    let label_str: Option<Rc<str>> = config.label.clone().map(Rc::from);
2971    let has_label = label_str.is_some();
2972
2973    // Unique animation key per label to avoid conflicts when multiple fields exist
2974    let anim_key = match &label_str {
2975        Some(l) => format!("otf_{}", &l[..l.len().min(32)]),
2976        None => "otf_nolabel".into(),
2977    };
2978
2979    // Persistent focus tracker - set by layout/paint when this field is focused,
2980    // read here on the next frame. This gives a one-frame delay on tap-to-float,
2981    // which is negligible at 60fps. An external tracker takes precedence.
2982    let focus_tracker: Rc<Cell<bool>> = match config.focus_tracker.clone() {
2983        Some(ft) => ft,
2984        None => remember_with_key(format!("otf_focus_{}", anim_key), || Cell::new(false)),
2985    };
2986    let is_focused = focus_tracker.get();
2987    let should_float = !value.is_empty() || is_focused;
2988
2989    let tf_placeholder = if has_label {
2990        if should_float {
2991            config.placeholder.clone().unwrap_or_default()
2992        } else {
2993            String::new()
2994        }
2995    } else {
2996        config.placeholder.clone().unwrap_or_default()
2997    };
2998
2999    let text_input = View::new(0, ViewKind::Box)
3000        .modifier(
3001            Modifier::new().flex_grow(1.0).text_input(TextInputConfig {
3002                hint: tf_placeholder,
3003                multiline: false,
3004                on_change: Some(Rc::new(on_value_change) as _),
3005                on_submit: config.on_submit.clone().map(|f| {
3006                    let f = f.clone();
3007                    Rc::new(move |s| f(s)) as Rc<dyn Fn(String)>
3008                }),
3009                focus_tracker: Some(focus_tracker),
3010                value: value.clone(),
3011                visual_transformation: None,
3012                keyboard_type: Default::default(),
3013                capitalization: Default::default(),
3014                ime_action: Default::default(),
3015                enabled: config.enabled,
3016                read_only: false,
3017                max_lines: None,
3018                min_lines: 1,
3019                cursor_color: config
3020                    .colors
3021                    .as_ref()
3022                    .map(|c| c.cursor_color(config.is_error)),
3023                on_text_layout: None,
3024                text_style: None,
3025                keyboard_actions: None,
3026                interaction_source: None,
3027                line_limits: None,
3028            }),
3029        )
3030        .semantics(Semantics {
3031            role: Role::TextField,
3032            label: None,
3033            focused: false,
3034            enabled: true,
3035            selectable_group: false,
3036        });
3037
3038    outlined_field_decoration(
3039        modifier,
3040        anim_key,
3041        label_str,
3042        &config,
3043        is_focused,
3044        !value.is_empty(),
3045        text_input,
3046    )
3047}
3048
3049/// State-based M3 Outlined Text Field.
3050pub fn OutlinedTextFieldState(
3051    modifier: Modifier,
3052    state: Rc<RefCell<TextFieldState>>,
3053    on_value_change: impl Fn(String) + 'static,
3054    config: OutlinedTextFieldConfig,
3055) -> View {
3056    let label_str: Option<Rc<str>> = config.label.clone().map(Rc::from);
3057    let has_label = label_str.is_some();
3058
3059    // Unique animation key per label to avoid conflicts when multiple fields exist
3060    let anim_key = match &label_str {
3061        Some(l) => format!("otf_{}", &l[..l.len().min(32)]),
3062        None => "otf_nolabel".into(),
3063    };
3064
3065    let focus_tracker: Rc<Cell<bool>> = match config.focus_tracker.clone() {
3066        Some(ft) => ft,
3067        None => remember_with_key(format!("otf_focus_{}", anim_key), || Cell::new(false)),
3068    };
3069    let is_focused = focus_tracker.get();
3070    let has_content = !state.borrow().text.is_empty();
3071    let should_float = has_content || is_focused;
3072
3073    // Placeholder shows when there's no label, or when label is floating (focused/has content)
3074    let tf_placeholder = if has_label {
3075        if should_float {
3076            config.placeholder.clone().unwrap_or_default()
3077        } else {
3078            String::new()
3079        }
3080    } else {
3081        config.placeholder.clone().unwrap_or_default()
3082    };
3083
3084    let text_input = BasicTextField(
3085        state,
3086        Modifier::new().flex_grow(1.0),
3087        tf_placeholder,
3088        BasicTextFieldConfig {
3089            line_limits: if config.single_line {
3090                TextFieldLineLimits::SingleLine
3091            } else {
3092                TextFieldLineLimits::MultiLine {
3093                    min_height_in_lines: 1,
3094                    max_height_in_lines: usize::MAX,
3095                }
3096            },
3097            on_change: Some(Rc::new(on_value_change)),
3098            on_submit: config.on_submit.clone(),
3099            focus_tracker: Some(focus_tracker),
3100            enabled: config.enabled,
3101            ..Default::default()
3102        },
3103    );
3104
3105    outlined_field_decoration(
3106        modifier,
3107        anim_key,
3108        label_str,
3109        &config,
3110        is_focused,
3111        has_content,
3112        text_input,
3113    )
3114}
3115
3116fn outlined_field_decoration(
3117    modifier: Modifier,
3118    anim_key: String,
3119    label_str: Option<Rc<str>>,
3120    config: &OutlinedTextFieldConfig,
3121    is_focused: bool,
3122    has_content: bool,
3123    text_input: View,
3124) -> View {
3125    let th = theme();
3126    let has_label = label_str.is_some();
3127
3128    let should_float = has_content || is_focused;
3129    let float_t = animate_f32(
3130        anim_key.clone(),
3131        if should_float { 1.0 } else { 0.0 },
3132        th.motion.color,
3133    );
3134
3135    let target_border_w = if config.is_error || should_float {
3136        OutlinedTextFieldDefaults::FOCUSED_BORDER_THICKNESS
3137    } else {
3138        OutlinedTextFieldDefaults::UNFOCUSED_BORDER_THICKNESS
3139    };
3140    let border_w = animate_f32(
3141        format!("otf_bw_{}", anim_key),
3142        target_border_w,
3143        th.motion.color,
3144    );
3145
3146    let (border_color, label_color, container_bg) = if let Some(ref tc) = config.colors {
3147        (
3148            tc.indicator_color(config.enabled, config.is_error, is_focused),
3149            tc.label_color(config.enabled, config.is_error, is_focused),
3150            tc.container_color(config.enabled, config.is_error, is_focused),
3151        )
3152    } else {
3153        (
3154            if config.is_error {
3155                th.error
3156            } else if is_focused {
3157                th.primary
3158            } else {
3159                th.outline
3160            },
3161            if config.is_error {
3162                th.error
3163            } else if is_focused {
3164                th.primary
3165            } else {
3166                th.on_surface_variant
3167            },
3168            th.surface,
3169        )
3170    };
3171
3172    // Label font size: 16dp (expanded, inside) → 12dp (minimized, at border)
3173    let label_size = 16.0 - 4.0 * float_t;
3174
3175    // Minimized label half-height matches bodySmall line height (~16dp) / 2
3176    let min_label_half_h: f32 = if has_label { 8.0 } else { 0.0 };
3177
3178    // Label Y: expanded centered within 56dp field → minimized overlapping top border (-labelHeight/2)
3179    let label_start_y = (56.0 - 16.0) / 2.0;
3180    let label_end_y = -min_label_half_h;
3181    let label_y = label_start_y - (label_start_y - label_end_y) * float_t;
3182
3183    // Label X: expanded at text-input start (~24dp) → minimized at border-start (~20dp)
3184    let label_start_x = if has_label { 24.0 } else { 0.0 };
3185    let label_end_x = if has_label { 20.0 } else { 0.0 };
3186    let label_x = label_start_x - (label_start_x - label_end_x) * float_t;
3187
3188    // Container padding matches reference: 8dp top/bottom with label, 16dp without
3189    let (top_pad, bottom_pad) = if has_label { (8.0, 8.0) } else { (16.0, 16.0) };
3190
3191    // Outer Stack holds both the clipped content and the unclipped label.
3192    // The label sits outside the clipped Box so it can extend above the border.
3193    let label_cutout = label_str.as_ref().map(|lbl| {
3194        let font_px = dp_to_px(label_size) * repose_core::locals::text_scale().0;
3195        let m = measure_text(lbl, font_px, TextMeasureConfig::default());
3196        let text_width_px = m.positions.last().copied().unwrap_or(0.0);
3197        let text_width_dp = px_to_dp(text_width_px);
3198        let pad = 1.0;
3199        let line_h = 16.0;
3200        (
3201            label_x - pad,
3202            label_y - pad,
3203            label_x + text_width_dp + pad,
3204            label_y + line_h + pad,
3205        )
3206    });
3207
3208    ZStack(
3209        modifier
3210            .min_height(OutlinedTextFieldDefaults::MIN_HEIGHT)
3211            .min_width(OutlinedTextFieldDefaults::MIN_WIDTH),
3212    )
3213    .child((
3214        // Background layer -> no border, full surface color (no notch)
3215        Box(Modifier::new()
3216            .fill_max_size()
3217            .clip_rounded(th.shapes.small)
3218            .background(container_bg)),
3219        // Border layer -> drawn on top of background, with notch for the label
3220        if has_label {
3221            let mut bm = Modifier::new()
3222                .fill_max_size()
3223                .clip_rounded(th.shapes.small)
3224                .border(border_w, border_color, th.shapes.small);
3225            if let Some((l, t, r, b)) = label_cutout {
3226                bm = bm.clip_rect(l, t, r, b, ClipOp::Difference);
3227            }
3228            Box(bm)
3229        } else {
3230            Box(Modifier::new()
3231                .fill_max_size()
3232                .clip_rounded(th.shapes.small)
3233                .border(border_w, border_color, th.shapes.small))
3234        },
3235        // Content layer -> text input with proper padding
3236        Row(Modifier::new()
3237            .fill_max_size()
3238            .padding_values(PaddingValues {
3239                left: 16.0,
3240                right: 16.0,
3241                top: top_pad,
3242                bottom: bottom_pad,
3243            })
3244            .align_items(AlignItems::CENTER))
3245        .child((
3246            config.leading_icon.clone().unwrap_or(Box(Modifier::new())),
3247            text_input,
3248            config.trailing_icon.clone().unwrap_or(Box(Modifier::new())),
3249        )),
3250        // Floating label -> plain text, no background chip (matches M3 reference)
3251        if let Some(lbl) = label_str {
3252            Box(Modifier::new()
3253                .min_width(200.0)
3254                .padding_values(PaddingValues {
3255                    left: label_x,
3256                    right: 20.0,
3257                    top: 0.0,
3258                    bottom: 0.0,
3259                })
3260                .absolute()
3261                .offset(Some(0.0), Some(label_y), None, None))
3262            .child(
3263                Text(lbl.as_ref().to_string())
3264                    .color(label_color)
3265                    .size(label_size),
3266            )
3267        } else {
3268            Box(Modifier::new())
3269        },
3270    ))
3271}
3272
3273/// Configuration for a filled M3 [`TextField`].
3274#[derive(Clone)]
3275pub struct TextFieldConfig {
3276    pub label: Option<String>,
3277    pub placeholder: Option<String>,
3278    pub leading_icon: Option<View>,
3279    pub trailing_icon: Option<View>,
3280    pub single_line: bool,
3281    pub is_error: bool,
3282    pub enabled: bool,
3283    pub on_submit: Option<Rc<dyn Fn(String)>>,
3284    pub colors: Option<TextFieldColors>,
3285}
3286
3287impl Default for TextFieldConfig {
3288    fn default() -> Self {
3289        Self {
3290            label: None,
3291            placeholder: None,
3292            leading_icon: None,
3293            trailing_icon: None,
3294            single_line: true,
3295            is_error: false,
3296            enabled: true,
3297            on_submit: None,
3298            colors: None,
3299        }
3300    }
3301}
3302
3303/// M3 Filled Text Field with floating label, leading/trailing icons, error state,
3304/// and a bottom indicator line. (Equivalent to Compose Material3's `TextField`.)
3305///
3306/// The label floats up when `value` is non-empty or when the field is focused.
3307/// Container: `SurfaceContainerHighest` bg, top-rounded corners (4dp), flat bottom.
3308/// Indicator: always visible, 1dp (unfocused) / 2dp (focused/error), animated color+thickness.
3309pub fn TextField(
3310    modifier: Modifier,
3311    value: String,
3312    on_value_change: impl Fn(String) + 'static,
3313    config: TextFieldConfig,
3314) -> View {
3315    let th = theme();
3316    let label_str: Option<Rc<str>> = config.label.map(Rc::from);
3317    let has_label = label_str.is_some();
3318
3319    let anim_key = match &label_str {
3320        Some(l) => format!("tf_{}", &l[..l.len().min(32)]),
3321        None => "tf_nolabel".into(),
3322    };
3323
3324    let focus_tracker: Rc<Cell<bool>> =
3325        remember_with_key(format!("tf_focus_{}", anim_key), || Cell::new(false));
3326    let is_focused = focus_tracker.get();
3327    let should_float = !value.is_empty() || is_focused;
3328
3329    let float_t = animate_f32(
3330        anim_key.clone(),
3331        if should_float { 1.0 } else { 0.0 },
3332        th.motion.color,
3333    );
3334
3335    let (indicator_color, label_color, container_bg) = if let Some(ref tc) = config.colors {
3336        let enf = config.enabled && is_focused;
3337        let ind = tc.indicator_color(config.enabled, config.is_error, enf);
3338        let lb = tc.label_color(config.enabled, config.is_error, enf);
3339        let bg = tc.container_color(config.enabled, config.is_error, enf);
3340        (ind, lb, bg)
3341    } else {
3342        let ind = if config.is_error {
3343            th.error
3344        } else if float_t > 0.5 {
3345            th.primary
3346        } else {
3347            th.on_surface_variant
3348        };
3349        let lb = if config.is_error {
3350            th.error
3351        } else if float_t > 0.5 {
3352            th.primary
3353        } else {
3354            th.on_surface_variant
3355        };
3356        let bg = if config.enabled {
3357            th.surface_container_highest
3358        } else {
3359            th.on_surface
3360                .with_alpha_f32(0.04)
3361                .composite_over(th.surface)
3362        };
3363        (ind, lb, bg)
3364    };
3365
3366    let label_size = 16.0 - 4.0 * float_t;
3367
3368    let label_start_y = (56.0 - 16.0) / 2.0;
3369    let label_end_y = if has_label { 8.0 } else { 0.0 };
3370    let label_y = label_start_y - (label_start_y - label_end_y) * float_t;
3371
3372    let label_start_x = if has_label { 24.0 } else { 0.0 };
3373    let label_end_x = if has_label { 20.0 } else { 0.0 };
3374    let label_x = label_start_x - (label_start_x - label_end_x) * float_t;
3375
3376    let tf_placeholder = if has_label {
3377        if should_float {
3378            config.placeholder.unwrap_or_default()
3379        } else {
3380            String::new()
3381        }
3382    } else {
3383        config.placeholder.unwrap_or_default()
3384    };
3385
3386    let indicator_active = config.is_error || (config.enabled && is_focused);
3387    let indicator_target_w = if indicator_active { 2.0 } else { 1.0 };
3388    let indicator_w = animate_f32(
3389        format!("tf_ind_w_{}", anim_key),
3390        indicator_target_w,
3391        th.motion.color,
3392    );
3393
3394    let (top_pad, bottom_pad) = if has_label { (8.0, 8.0) } else { (16.0, 16.0) };
3395
3396    Column(
3397        modifier
3398            .min_height(TextFieldDefaults::MIN_HEIGHT)
3399            .min_width(TextFieldDefaults::MIN_WIDTH),
3400    )
3401    .child((
3402        // Clipped background and input content
3403        Box(Modifier::new()
3404            .fill_max_size()
3405            .clip_rounded(th.shapes.extra_small)
3406            .background(container_bg))
3407        .child(
3408            Column(Modifier::new().fill_max_size()).child((
3409                // Input row
3410                Row(Modifier::new()
3411                    .fill_max_size()
3412                    .padding_values(PaddingValues {
3413                        left: 16.0,
3414                        right: 16.0,
3415                        top: top_pad,
3416                        bottom: bottom_pad,
3417                    })
3418                    .align_items(AlignItems::CENTER))
3419                .child((
3420                    config.leading_icon.unwrap_or(Box(Modifier::new())),
3421                    View::new(0, ViewKind::Box)
3422                        .modifier(
3423                            Modifier::new().flex_grow(1.0).text_input(TextInputConfig {
3424                                hint: tf_placeholder,
3425                                multiline: !config.single_line,
3426                                on_change: Some(Rc::new(on_value_change) as _),
3427                                on_submit: config.on_submit.clone().map(|f| {
3428                                    let f = f.clone();
3429                                    Rc::new(move |s| f(s)) as Rc<dyn Fn(String)>
3430                                }),
3431                                focus_tracker: Some(focus_tracker.clone()),
3432                                value: value.clone(),
3433                                visual_transformation: None,
3434                                keyboard_type: Default::default(),
3435                                capitalization: Default::default(),
3436                                ime_action: Default::default(),
3437                                enabled: config.enabled,
3438                                read_only: false,
3439                                max_lines: None,
3440                                min_lines: 1,
3441                                cursor_color: config
3442                                    .colors
3443                                    .as_ref()
3444                                    .map(|c| c.cursor_color(config.is_error)),
3445                                on_text_layout: None,
3446                                text_style: None,
3447                                keyboard_actions: None,
3448                                interaction_source: None,
3449                                line_limits: None,
3450                            }),
3451                        )
3452                        .semantics(Semantics {
3453                            role: Role::TextField,
3454                            label: None,
3455                            focused: false,
3456                            enabled: true,
3457                            selectable_group: false,
3458                        }),
3459                    config.trailing_icon.unwrap_or(Box(Modifier::new())),
3460                )),
3461                // Bottom indicator line
3462                Box(Modifier::new()
3463                    .fill_max_width()
3464                    .height(indicator_w)
3465                    .absolute()
3466                    .offset(None, None, None, Some(0.0))
3467                    .background(indicator_color)),
3468            )),
3469        ),
3470        // Floating label
3471        if let Some(lbl) = label_str {
3472            Box(Modifier::new()
3473                .min_width(200.0)
3474                .padding_values(PaddingValues {
3475                    left: label_x,
3476                    right: 20.0,
3477                    top: 0.0,
3478                    bottom: 0.0,
3479                })
3480                .absolute()
3481                .offset(Some(0.0), Some(label_y), None, None))
3482            .child(
3483                Text(lbl.as_ref().to_string())
3484                    .color(label_color)
3485                    .size(label_size),
3486            )
3487        } else {
3488            Box(Modifier::new())
3489        },
3490    ))
3491}
3492
3493/// Configuration for [`Checkbox`].
3494#[derive(Clone, Debug)]
3495pub struct CheckboxConfig {
3496    pub modifier: Modifier,
3497    /// When false, the checkbox renders disabled colors and does not respond to clicks.
3498    pub enabled: bool,
3499    pub checked_color: Color,
3500    pub unchecked_color: Color,
3501    pub checkmark_color: Color,
3502    /// Border color when checked. Default: same as `checked_color`.
3503    pub checked_border_color: Color,
3504    /// Border color when unchecked. Default: same as `unchecked_color`.
3505    pub unchecked_border_color: Color,
3506    pub disabled_checked_box_color: Color,
3507    pub disabled_unchecked_box_color: Color,
3508    pub disabled_indeterminate_box_color: Color,
3509    pub disabled_checkmark_color: Color,
3510    pub disabled_checked_border_color: Color,
3511    pub disabled_unchecked_border_color: Color,
3512    pub disabled_indeterminate_border_color: Color,
3513    pub state_colors: StateColors,
3514    pub interaction_source: Option<MutableInteractionSource>,
3515}
3516
3517impl Default for CheckboxConfig {
3518    fn default() -> Self {
3519        Self {
3520            modifier: Modifier::new(),
3521            enabled: true,
3522            checked_color: CheckboxDefaults::checked_color(),
3523            unchecked_color: CheckboxDefaults::unchecked_color(),
3524            checkmark_color: CheckboxDefaults::checkmark_color(),
3525            checked_border_color: CheckboxDefaults::checked_color(),
3526            unchecked_border_color: CheckboxDefaults::unchecked_color(),
3527            disabled_checked_box_color: CheckboxDefaults::disabled_checked_box_color(),
3528            disabled_unchecked_box_color: Color::TRANSPARENT,
3529            disabled_indeterminate_box_color: CheckboxDefaults::disabled_checked_box_color(),
3530            disabled_checkmark_color: CheckboxDefaults::disabled_checkmark_color(),
3531            disabled_checked_border_color: CheckboxDefaults::disabled_checked_box_color(),
3532            disabled_unchecked_border_color: CheckboxDefaults::disabled_unchecked_border_color(),
3533            disabled_indeterminate_border_color: CheckboxDefaults::disabled_checked_box_color(),
3534            state_colors: CheckboxDefaults::state_colors_default(),
3535            interaction_source: None,
3536        }
3537    }
3538}
3539
3540/// M3 Checkbox.
3541/// Renders a 40dp touch-target with an 18dp check box inside.
3542/// Fill, border, and check mark animate with 100ms FastOutSlowIn.
3543static CHECKBOX_COUNTER: AtomicU64 = AtomicU64::new(0);
3544pub fn Checkbox(checked: bool, on_change: impl Fn(bool) + 'static, config: CheckboxConfig) -> View {
3545    let th = theme();
3546    let sz = CheckboxDefaults::BOX_SIZE;
3547
3548    let id = remember(|| CHECKBOX_COUNTER.fetch_add(1, Ordering::Relaxed));
3549    let spec = th.motion.color_fast;
3550
3551    let is_enabled = config.enabled;
3552
3553    let fill = animate_color(
3554        format!("cb_fill_{}", id),
3555        if !is_enabled {
3556            if checked {
3557                config.disabled_checked_box_color
3558            } else {
3559                config.disabled_unchecked_box_color
3560            }
3561        } else if checked {
3562            config.checked_color
3563        } else {
3564            Color::TRANSPARENT
3565        },
3566        spec,
3567    );
3568    let bd_w = animate_f32(
3569        format!("cb_bw_{}", id),
3570        if !is_enabled && checked {
3571            0.0
3572        } else if !is_enabled {
3573            CheckboxDefaults::STROKE_WIDTH
3574        } else if checked {
3575            0.0
3576        } else {
3577            CheckboxDefaults::STROKE_WIDTH
3578        },
3579        spec,
3580    );
3581    let bd = animate_color(
3582        format!("cb_bd_{}", id),
3583        if !is_enabled {
3584            if checked {
3585                config.disabled_checked_border_color
3586            } else {
3587                config.disabled_unchecked_border_color
3588            }
3589        } else if checked {
3590            Color::TRANSPARENT
3591        } else {
3592            config.unchecked_border_color
3593        },
3594        spec,
3595    );
3596    let check_alpha = animate_f32(
3597        format!("cb_ca_{}", id),
3598        if checked { 1.0 } else { 0.0 },
3599        spec,
3600    );
3601    let check_col = if !is_enabled {
3602        config.disabled_checkmark_color
3603    } else {
3604        config.checkmark_color
3605    };
3606
3607    let cb = move || {
3608        if config.enabled {
3609            on_change(!checked)
3610        }
3611    };
3612
3613    let cb_source: Rc<MutableInteractionSource> = config
3614        .interaction_source
3615        .clone()
3616        .map(Rc::new)
3617        .unwrap_or_else(|| remember(MutableInteractionSource::new));
3618    Box(Modifier::new()
3619        .width(CheckboxDefaults::TOUCH_TARGET_SIZE)
3620        .height(CheckboxDefaults::TOUCH_TARGET_SIZE)
3621        .padding(0.0)
3622        .clip_rounded(20.0)
3623        .background(Color::TRANSPARENT)
3624        .state_colors(config.state_colors)
3625        .interaction_source(&*cb_source)
3626        .clickable()
3627        .align_items(AlignItems::CENTER)
3628        .justify_content(JustifyContent::CENTER)
3629        .on_click(cb)
3630        .then(config.modifier))
3631    .child(
3632        Box(Modifier::new()
3633            .size(sz, sz)
3634            .background(fill)
3635            .border(bd_w, bd, CheckboxDefaults::CORNER_RADIUS)
3636            .clip_rounded(CheckboxDefaults::CORNER_RADIUS)
3637            .align_items(AlignItems::CENTER)
3638            .justify_content(JustifyContent::CENTER))
3639        .child(if check_alpha > 0.01 {
3640            Box(Modifier::new().alpha(check_alpha)).child(
3641                Icon(Symbol::new("done", '\u{E876}'))
3642                    .color(check_col)
3643                    .size(CheckboxDefaults::CHECK_ICON_SIZE),
3644            )
3645        } else {
3646            Box(Modifier::new())
3647        }),
3648    )
3649}
3650
3651/// Three-state value for [`TriStateCheckbox`].
3652#[derive(Clone, Copy, Debug, PartialEq)]
3653pub enum TriState {
3654    Checked,
3655    Unchecked,
3656    Indeterminate,
3657}
3658
3659/// M3 Tri-State Checkbox - cycles through Checked → Indeterminate → Unchecked.
3660/// Indeterminate shows a dash instead of a checkmark.
3661pub fn TriStateCheckbox(
3662    state: TriState,
3663    on_change: impl Fn(TriState) + 'static,
3664    config: CheckboxConfig,
3665) -> View {
3666    let th = theme();
3667    let sz = CheckboxDefaults::BOX_SIZE;
3668
3669    let id = remember(|| CHECKBOX_COUNTER.fetch_add(1, Ordering::Relaxed));
3670    let spec = th.motion.color_fast;
3671
3672    let is_checked = state == TriState::Checked;
3673    let is_indeterminate = state == TriState::Indeterminate;
3674    let has_fill = is_checked || is_indeterminate;
3675    let is_enabled = config.enabled;
3676
3677    let fill = animate_color(
3678        format!("tc_fill_{}", id),
3679        if !is_enabled {
3680            if has_fill {
3681                config.disabled_indeterminate_box_color
3682            } else {
3683                config.disabled_unchecked_box_color
3684            }
3685        } else if has_fill {
3686            config.checked_color
3687        } else {
3688            Color::TRANSPARENT
3689        },
3690        spec,
3691    );
3692    let bd_w = animate_f32(
3693        format!("tc_bw_{}", id),
3694        if !is_enabled {
3695            if has_fill {
3696                0.0
3697            } else {
3698                CheckboxDefaults::STROKE_WIDTH
3699            }
3700        } else if has_fill {
3701            0.0
3702        } else {
3703            CheckboxDefaults::STROKE_WIDTH
3704        },
3705        spec,
3706    );
3707    let bd = animate_color(
3708        format!("tc_bd_{}", id),
3709        if !is_enabled {
3710            if has_fill {
3711                config.disabled_indeterminate_border_color
3712            } else {
3713                config.disabled_unchecked_border_color
3714            }
3715        } else if has_fill {
3716            Color::TRANSPARENT
3717        } else {
3718            config.unchecked_border_color
3719        },
3720        spec,
3721    );
3722    let symbol_alpha = animate_f32(
3723        format!("tc_sa_{}", id),
3724        if has_fill { 1.0 } else { 0.0 },
3725        spec,
3726    );
3727    let symbol_col = if !is_enabled {
3728        config.disabled_checkmark_color
3729    } else {
3730        config.checkmark_color
3731    };
3732
3733    Box(Modifier::new()
3734        .width(CheckboxDefaults::TOUCH_TARGET_SIZE)
3735        .height(CheckboxDefaults::TOUCH_TARGET_SIZE)
3736        .padding(0.0)
3737        .clip_rounded(20.0)
3738        .background(Color::TRANSPARENT)
3739        .clickable()
3740        .align_items(AlignItems::CENTER)
3741        .justify_content(JustifyContent::CENTER)
3742        .on_click(move || {
3743            if is_enabled {
3744                on_change(match state {
3745                    TriState::Checked => TriState::Unchecked,
3746                    TriState::Indeterminate => TriState::Checked,
3747                    TriState::Unchecked => TriState::Checked,
3748                })
3749            }
3750        })
3751        .then(config.modifier))
3752    .child(
3753        Box(Modifier::new()
3754            .size(sz, sz)
3755            .background(fill)
3756            .border(bd_w, bd, CheckboxDefaults::CORNER_RADIUS)
3757            .clip_rounded(CheckboxDefaults::CORNER_RADIUS)
3758            .align_items(AlignItems::CENTER)
3759            .justify_content(JustifyContent::CENTER))
3760        .child(if symbol_alpha > 0.01 {
3761            Box(Modifier::new().alpha(symbol_alpha)).child(if is_indeterminate {
3762                // Dash for indeterminate
3763                Box(Modifier::new()
3764                    .width(10.0)
3765                    .height(2.0)
3766                    .background(symbol_col)
3767                    .clip_rounded(1.0))
3768            } else {
3769                Icon(Symbol::new("done", '\u{E876}'))
3770                    .color(symbol_col)
3771                    .size(CheckboxDefaults::CHECK_ICON_SIZE)
3772            })
3773        } else {
3774            Box(Modifier::new())
3775        }),
3776    )
3777}
3778
3779/// Configuration for [`RadioButton`].
3780#[derive(Clone, Debug)]
3781pub struct RadioButtonConfig {
3782    pub modifier: Modifier,
3783    /// When false, renders disabled colors and does not respond to clicks.
3784    pub enabled: bool,
3785    pub selected_color: Color,
3786    pub unselected_color: Color,
3787    pub disabled_selected_color: Color,
3788    pub disabled_unselected_color: Color,
3789    pub state_colors: StateColors,
3790    pub interaction_source: Option<MutableInteractionSource>,
3791}
3792
3793impl Default for RadioButtonConfig {
3794    fn default() -> Self {
3795        Self {
3796            modifier: Modifier::new(),
3797            enabled: true,
3798            selected_color: RadioButtonDefaults::selected_color(),
3799            unselected_color: RadioButtonDefaults::unselected_color(),
3800            disabled_selected_color: RadioButtonDefaults::disabled_selected_color(),
3801            disabled_unselected_color: RadioButtonDefaults::disabled_unselected_color(),
3802            state_colors: RadioButtonDefaults::state_colors_default(),
3803            interaction_source: None,
3804        }
3805    }
3806}
3807
3808/// M3 RadioButton.
3809/// Renders a 40dp touch-target with a 20dp outer circle + inner dot.
3810/// Ring color animates with 100ms FastOutSlowIn; dot size animates with spring.
3811static RADIO_COUNTER: AtomicU64 = AtomicU64::new(0);
3812pub fn RadioButton(
3813    selected: bool,
3814    on_select: impl Fn() + 'static,
3815    config: RadioButtonConfig,
3816) -> View {
3817    let th = theme();
3818    let d = RadioButtonDefaults::OUTER_RADIUS * 2.0;
3819
3820    let id = remember(|| RADIO_COUNTER.fetch_add(1, Ordering::Relaxed));
3821    let color_spec = th.motion.color_fast;
3822    let spring = th.motion.spring;
3823
3824    let ring_col = animate_color(
3825        format!("rb_ring_{}", id),
3826        if !config.enabled {
3827            if selected {
3828                config.disabled_selected_color
3829            } else {
3830                config.disabled_unselected_color
3831            }
3832        } else if selected {
3833            config.selected_color
3834        } else {
3835            config.unselected_color
3836        },
3837        color_spec,
3838    );
3839    let dot_size = animate_f32(
3840        format!("rb_dot_{}", id),
3841        if selected {
3842            RadioButtonDefaults::DOT_RADIUS * 2.0
3843        } else {
3844            0.0
3845        },
3846        spring,
3847    );
3848    let dot_col = if !config.enabled {
3849        config.disabled_selected_color
3850    } else {
3851        config.selected_color
3852    };
3853
3854    let cb = move || {
3855        if config.enabled {
3856            on_select()
3857        }
3858    };
3859
3860    let rb_source: Rc<MutableInteractionSource> = config
3861        .interaction_source
3862        .clone()
3863        .map(Rc::new)
3864        .unwrap_or_else(|| remember(MutableInteractionSource::new));
3865    Box(Modifier::new()
3866        .width(RadioButtonDefaults::TOUCH_TARGET_SIZE)
3867        .height(RadioButtonDefaults::TOUCH_TARGET_SIZE)
3868        .padding(0.0)
3869        .clip_rounded(20.0)
3870        .background(Color::TRANSPARENT)
3871        .state_colors(config.state_colors)
3872        .interaction_source(&*rb_source)
3873        .clickable()
3874        .align_items(AlignItems::CENTER)
3875        .justify_content(JustifyContent::CENTER)
3876        .on_click(cb)
3877        .then(config.modifier))
3878    .child(
3879        Box(Modifier::new()
3880            .size(d, d)
3881            .border(RadioButtonDefaults::STROKE_WIDTH, ring_col, d * 0.5)
3882            .clip_rounded(d * 0.5)
3883            .align_items(AlignItems::CENTER)
3884            .justify_content(JustifyContent::CENTER))
3885        .child(if dot_size > 0.5 {
3886            Box(Modifier::new()
3887                .size(dot_size, dot_size)
3888                .background(dot_col)
3889                .clip_rounded(dot_size * 0.5))
3890        } else {
3891            Box(Modifier::new())
3892        }),
3893    )
3894}
3895
3896/// Configuration for [`Switch`].
3897#[derive(Clone, Debug)]
3898pub struct SwitchConfig {
3899    pub modifier: Modifier,
3900    /// When false, renders disabled colors and does not respond to clicks.
3901    pub enabled: bool,
3902    pub checked_track_color: Color,
3903    pub unchecked_track_color: Color,
3904    pub checked_thumb_color: Color,
3905    pub unchecked_thumb_color: Color,
3906    /// Icon color for the thumb content when checked. Default: `on_primary`.
3907    pub checked_icon_color: Color,
3908    /// Icon color for the thumb content when unchecked. Default: `outline`.
3909    pub unchecked_icon_color: Color,
3910    /// Border color when checked. Default: transparent.
3911    pub checked_border_color: Color,
3912    /// Border color when unchecked.
3913    pub unchecked_border_color: Color,
3914    pub disabled_checked_thumb_color: Color,
3915    pub disabled_checked_track_color: Color,
3916    pub disabled_checked_border_color: Color,
3917    pub disabled_checked_icon_color: Color,
3918    pub disabled_unchecked_thumb_color: Color,
3919    pub disabled_unchecked_track_color: Color,
3920    pub disabled_unchecked_border_color: Color,
3921    pub disabled_unchecked_icon_color: Color,
3922    pub state_colors: StateColors,
3923    pub thumb_content: Option<View>,
3924    pub interaction_source: Option<MutableInteractionSource>,
3925}
3926
3927impl Default for SwitchConfig {
3928    fn default() -> Self {
3929        Self {
3930            modifier: Modifier::new(),
3931            enabled: true,
3932            checked_track_color: SwitchDefaults::checked_track_color(),
3933            unchecked_track_color: SwitchDefaults::unchecked_track_color(),
3934            checked_thumb_color: SwitchDefaults::checked_thumb_color(),
3935            unchecked_thumb_color: SwitchDefaults::unchecked_thumb_color(),
3936            checked_icon_color: SwitchDefaults::checked_icon_color(),
3937            unchecked_icon_color: SwitchDefaults::unchecked_icon_color(),
3938            checked_border_color: Color::TRANSPARENT,
3939            unchecked_border_color: SwitchDefaults::unchecked_border_color(),
3940            disabled_checked_thumb_color: SwitchDefaults::disabled_checked_thumb_color(),
3941            disabled_checked_track_color: SwitchDefaults::disabled_checked_track_color(),
3942            disabled_checked_border_color: Color::TRANSPARENT,
3943            disabled_checked_icon_color: SwitchDefaults::disabled_checked_icon_color(),
3944            disabled_unchecked_thumb_color: SwitchDefaults::disabled_unchecked_thumb_color(),
3945            disabled_unchecked_track_color: SwitchDefaults::disabled_unchecked_track_color(),
3946            disabled_unchecked_border_color: SwitchDefaults::disabled_unchecked_border_color(),
3947            disabled_unchecked_icon_color: SwitchDefaults::disabled_unchecked_icon_color(),
3948            state_colors: SwitchDefaults::state_colors_default(),
3949            thumb_content: None,
3950            interaction_source: None,
3951        }
3952    }
3953}
3954
3955/// M3 Switch.
3956/// Renders a pill track with an animated thumb knob.
3957/// Thumb position, size, and colors animate with spring/tween physics.
3958static SWITCH_COUNTER: AtomicU64 = AtomicU64::new(0);
3959pub fn Switch(checked: bool, on_change: impl Fn(bool) + 'static, config: SwitchConfig) -> View {
3960    let th = theme();
3961    let track_w = SwitchDefaults::TRACK_WIDTH;
3962    let track_h = SwitchDefaults::TRACK_HEIGHT;
3963
3964    let id = remember(|| SWITCH_COUNTER.fetch_add(1, Ordering::Relaxed));
3965
3966    let hovered = remember(|| Signal::new(false));
3967    let pressed = remember(|| Signal::new(false));
3968
3969    // Thumb: spring-animated position and size
3970    let thumb_target_pos = if checked {
3971        track_w - SwitchDefaults::THUMB_CHECKED_SIZE - 4.0
3972    } else {
3973        8.0
3974    };
3975    let thumb_target_d = if checked {
3976        SwitchDefaults::THUMB_CHECKED_SIZE
3977    } else {
3978        SwitchDefaults::THUMB_UNCHECKED_SIZE
3979    };
3980    let spring = th.motion.spring;
3981
3982    let thumb_left = animate_f32(format!("sw_pos_{}", id), thumb_target_pos, spring);
3983    let thumb_d = animate_f32(format!("sw_d_{}", id), thumb_target_d, spring);
3984    let thumb_top = (track_h - thumb_d) * 0.5;
3985
3986    let color_spec = th.motion.color_fast;
3987    let is_enabled = config.enabled;
3988
3989    let track_bg = animate_color(
3990        format!("sw_tbg_{}", id),
3991        if !is_enabled {
3992            if checked {
3993                config.disabled_checked_track_color
3994            } else {
3995                config.disabled_unchecked_track_color
3996            }
3997        } else if checked {
3998            config.checked_track_color
3999        } else {
4000            config.unchecked_track_color
4001        },
4002        color_spec,
4003    );
4004    let thumb_bg = animate_color(
4005        format!("sw_tmbg_{}", id),
4006        if !is_enabled {
4007            if checked {
4008                config.disabled_checked_thumb_color
4009            } else {
4010                config.disabled_unchecked_thumb_color
4011            }
4012        } else if checked {
4013            config.checked_thumb_color
4014        } else {
4015            config.unchecked_thumb_color
4016        },
4017        color_spec,
4018    );
4019    let track_border = animate_f32(
4020        format!("sw_tb_{}", id),
4021        if !is_enabled {
4022            if checked { 0.0 } else { 2.0 }
4023        } else if checked {
4024            0.0
4025        } else {
4026            2.0
4027        },
4028        color_spec,
4029    );
4030    let border_color = animate_color(
4031        format!("sw_bc_{}", id),
4032        if !is_enabled {
4033            if checked {
4034                config.disabled_checked_border_color
4035            } else {
4036                config.disabled_unchecked_border_color
4037            }
4038        } else if checked {
4039            config.checked_border_color
4040        } else {
4041            config.unchecked_border_color
4042        },
4043        color_spec,
4044    );
4045
4046    let state_overlay = animate_color(
4047        format!("sw_ol_{}", id),
4048        if !is_enabled {
4049            Color::TRANSPARENT
4050        } else if pressed.get() {
4051            config.state_colors.pressed
4052        } else if hovered.get() {
4053            config.state_colors.hovered
4054        } else {
4055            config.state_colors.default
4056        },
4057        color_spec,
4058    );
4059
4060    let sw_source: Rc<MutableInteractionSource> = config
4061        .interaction_source
4062        .clone()
4063        .map(Rc::new)
4064        .unwrap_or_else(|| remember(MutableInteractionSource::new));
4065    Box(Modifier::new()
4066        .size(track_w, track_h)
4067        .padding(0.0)
4068        .clip_rounded(track_h * 0.5)
4069        .background(track_bg)
4070        .border(track_border, border_color, track_h * 0.5)
4071        .interaction_source(&*sw_source)
4072        .clickable()
4073        .on_pointer_enter({
4074            let h = hovered.clone();
4075            move |_| h.set(true)
4076        })
4077        .on_pointer_leave({
4078            let h = hovered.clone();
4079            let p = pressed.clone();
4080            move |_| {
4081                h.set(false);
4082                p.set(false);
4083            }
4084        })
4085        .on_pointer_down({
4086            let p = pressed.clone();
4087            move |_| p.set(true)
4088        })
4089        .on_click({
4090            let cb = on_change;
4091            move || cb(!checked)
4092        })
4093        .on_pointer_up({
4094            let p = pressed.clone();
4095            move |_| p.set(false)
4096        })
4097        .then(config.modifier))
4098    .child((
4099        Box(Modifier::new()
4100            .size(thumb_d, thumb_d)
4101            .background(thumb_bg)
4102            .clip_rounded(thumb_d * 0.5)
4103            .hit_passthrough()
4104            .absolute()
4105            .offset(Some(thumb_left), Some(thumb_top), None, None)),
4106        Box(Modifier::new()
4107            .size(40.0, 40.0)
4108            .clip_rounded(20.0)
4109            .background(state_overlay)
4110            .hit_passthrough()
4111            .absolute()
4112            .offset(
4113                Some(thumb_left + thumb_d * 0.5 - 20.0),
4114                Some(track_h * 0.5 - 20.0),
4115                None,
4116                None,
4117            )),
4118    ))
4119}
4120
4121/// Configuration for [`Slider`] and [`RangeSlider`].
4122#[derive(Clone)]
4123pub struct SliderConfig {
4124    // Debug impl is manual because on_value_change_finished contains a closure
4125    pub modifier: Modifier,
4126    /// When false, renders disabled colors and does not respond to input.
4127    pub enabled: bool,
4128    pub active_track_color: Color,
4129    pub inactive_track_color: Color,
4130    pub thumb_color: Color,
4131    pub active_tick_color: Color,
4132    pub inactive_tick_color: Color,
4133    pub disabled_thumb_color: Color,
4134    pub disabled_active_track_color: Color,
4135    pub disabled_inactive_track_color: Color,
4136    pub disabled_active_tick_color: Color,
4137    pub disabled_inactive_tick_color: Color,
4138    pub state_colors: StateColors,
4139    pub on_value_change_finished: Option<Rc<dyn Fn()>>,
4140    pub interaction_source: Option<MutableInteractionSource>,
4141}
4142
4143impl std::fmt::Debug for SliderConfig {
4144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4145        f.debug_struct("SliderConfig")
4146            .field("modifier", &self.modifier)
4147            .field("enabled", &self.enabled)
4148            .field("active_track_color", &self.active_track_color)
4149            .field("inactive_track_color", &self.inactive_track_color)
4150            .field("thumb_color", &self.thumb_color)
4151            .field("active_tick_color", &self.active_tick_color)
4152            .field("inactive_tick_color", &self.inactive_tick_color)
4153            .field("disabled_thumb_color", &self.disabled_thumb_color)
4154            .field(
4155                "disabled_active_track_color",
4156                &self.disabled_active_track_color,
4157            )
4158            .field(
4159                "disabled_inactive_track_color",
4160                &self.disabled_inactive_track_color,
4161            )
4162            .field(
4163                "disabled_active_tick_color",
4164                &self.disabled_active_tick_color,
4165            )
4166            .field(
4167                "disabled_inactive_tick_color",
4168                &self.disabled_inactive_tick_color,
4169            )
4170            .field("state_colors", &self.state_colors)
4171            .field(
4172                "on_value_change_finished",
4173                &self.on_value_change_finished.as_ref().map(|_| ".."),
4174            )
4175            .field(
4176                "interaction_source",
4177                &self.interaction_source.as_ref().map(|_| ".."),
4178            )
4179            .finish()
4180    }
4181}
4182
4183impl Default for SliderConfig {
4184    fn default() -> Self {
4185        Self {
4186            modifier: Modifier::new(),
4187            enabled: true,
4188            active_track_color: SliderDefaults::active_track_color(),
4189            inactive_track_color: SliderDefaults::inactive_track_color(),
4190            thumb_color: SliderDefaults::thumb_color(),
4191            active_tick_color: SliderDefaults::active_tick_color(),
4192            inactive_tick_color: SliderDefaults::inactive_tick_color(),
4193            disabled_thumb_color: SliderDefaults::disabled_thumb_color(),
4194            disabled_active_track_color: SliderDefaults::disabled_active_track_color(),
4195            disabled_inactive_track_color: SliderDefaults::disabled_inactive_track_color(),
4196            disabled_active_tick_color: SliderDefaults::disabled_active_tick_color(),
4197            disabled_inactive_tick_color: SliderDefaults::disabled_inactive_tick_color(),
4198            state_colors: SliderDefaults::state_colors_default(),
4199            on_value_change_finished: None,
4200            interaction_source: None,
4201        }
4202    }
4203}
4204
4205static SLIDER_COUNTER: AtomicU64 = AtomicU64::new(0);
4206
4207fn snap_step(v: f32, min: f32, max: f32, step: Option<f32>) -> f32 {
4208    let v = v.clamp(min, max);
4209    if let Some(s) = step.filter(|s| *s > 0.0) {
4210        let t = ((v - min) / s).round();
4211        (min + t * s).clamp(min, max)
4212    } else {
4213        v
4214    }
4215}
4216
4217fn value_from_x(x: f32, rect: Rect, min: f32, max: f32, step: Option<f32>) -> f32 {
4218    let w = rect.w.max(1.0);
4219    let t = ((x - rect.x) / w).clamp(0.0, 1.0);
4220    let v = min + t * (max - min);
4221    snap_step(v, min, max, step)
4222}
4223
4224pub fn Slider(
4225    value: f32,
4226    range: (f32, f32),
4227    step: Option<f32>,
4228    on_change: impl Fn(f32) + 'static,
4229    config: SliderConfig,
4230) -> View {
4231    assert!(range.0 <= range.1, "Slider range start must be <= end");
4232    if let Some(s) = step {
4233        assert!(s > 0.0, "Slider step must be positive");
4234    }
4235    let id = *remember(|| SLIDER_COUNTER.fetch_add(1, Ordering::Relaxed));
4236    let track_rect = remember_state_with_key(format!("ms_rect_{}", id), || Rect::default());
4237    let drag_active = remember_mutable_with_key(format!("ms_da_{}", id), || false);
4238    let hovered = remember(|| Signal::new(false));
4239
4240    let track_rect_p = track_rect.clone();
4241    let drag_active_p = drag_active.clone();
4242    let hovered_sig = hovered.clone();
4243    let sc = config.state_colors;
4244
4245    let min = range.0;
4246    let max = range.1;
4247    let oc = Rc::new(on_change);
4248    let range_size = (max - min).max(1e-6);
4249    let t = ((value - min) / range_size).clamp(0.0, 1.0);
4250
4251    let tick_frac: Vec<f32> = if let Some(s) = step {
4252        let n = ((max - min) / s.max(1e-6)).round() as usize;
4253        (0..=n).map(|i| i as f32 / n as f32).collect()
4254    } else {
4255        Vec::new()
4256    };
4257
4258    let sl_source: Rc<MutableInteractionSource> = config
4259        .interaction_source
4260        .clone()
4261        .map(Rc::new)
4262        .unwrap_or_else(|| remember(MutableInteractionSource::new));
4263    Box(Modifier::new()
4264        .min_width(200.0)
4265        .height(44.0)
4266        .interaction_source(&*sl_source)
4267        .painter(move |scene: &mut Scene, rect: Rect, alpha: f32| {
4268            let mul_c = |c: Color| {
4269                Color(
4270                    c.0,
4271                    c.1,
4272                    c.2,
4273                    ((c.3 as f32) * alpha).clamp(0.0, 255.0) as u8,
4274                )
4275            };
4276            let track_h = dp_to_px(16.0);
4277            let thumb_w = dp_to_px(4.0);
4278            let thumb_h = dp_to_px(44.0);
4279            let dot_r = dp_to_px(2.0);
4280            let corner = track_h * 0.5;
4281            let gap = thumb_w * 0.5 + dp_to_px(ProgressIndicatorDefaults::SLIDER_THUMB_TRACK_GAP);
4282            let pad = thumb_w * 0.5;
4283            let track_x = rect.x + pad;
4284            let track_w = (rect.w - thumb_w).max(0.0);
4285            let cy = rect.y + rect.h * 0.5;
4286
4287            let kx = if step.is_some() && !tick_frac.is_empty() {
4288                let is_first = (t - tick_frac[0]).abs() < 1e-6;
4289                let is_last = (t - tick_frac[tick_frac.len() - 1]).abs() < 1e-6;
4290                if is_first || is_last {
4291                    track_x + t * track_w
4292                } else {
4293                    track_x + (track_w - track_h) * t + corner
4294                }
4295            } else {
4296                track_x + t * track_w
4297            };
4298
4299            *track_rect_p.borrow_mut() = Rect {
4300                x: track_x,
4301                y: rect.y,
4302                w: track_w,
4303                h: rect.h,
4304            };
4305
4306            let inactive_x = track_x.max(kx + gap);
4307            let inactive_w = (track_x + track_w - inactive_x).max(0.0);
4308            if inactive_w > 0.0 {
4309                scene.nodes.push(SceneNode::Rect {
4310                    rect: Rect {
4311                        x: inactive_x,
4312                        y: cy - track_h * 0.5,
4313                        w: inactive_w,
4314                        h: track_h,
4315                    },
4316                    brush: Brush::Solid(mul_c(config.inactive_track_color)),
4317                    radius: [corner; 4],
4318                });
4319                let sx = track_x + track_w - corner;
4320                scene.nodes.push(SceneNode::Ellipse {
4321                    rect: Rect {
4322                        x: sx - dot_r,
4323                        y: cy - dot_r,
4324                        w: dot_r * 2.0,
4325                        h: dot_r * 2.0,
4326                    },
4327                    brush: Brush::Solid(mul_c(config.inactive_tick_color)),
4328                });
4329            }
4330            let fill_w = (kx - gap - track_x).max(0.0);
4331            if fill_w > 0.0 {
4332                scene.nodes.push(SceneNode::Rect {
4333                    rect: Rect {
4334                        x: track_x,
4335                        y: cy - track_h * 0.5,
4336                        w: fill_w,
4337                        h: track_h,
4338                    },
4339                    brush: Brush::Solid(mul_c(config.active_track_color)),
4340                    radius: [corner; 4],
4341                });
4342            }
4343            let tick_start = track_x + corner;
4344            let tick_end = track_x + track_w - corner;
4345            for (i, &tf) in tick_frac.iter().enumerate() {
4346                let tx = tick_start + tf * (tick_end - tick_start);
4347                // skip ticks that fall on the stop indicator (last)
4348                if i == tick_frac.len() - 1 {
4349                    continue;
4350                }
4351                if tx >= kx - gap && tx <= kx + gap {
4352                    continue;
4353                }
4354                let on_active = tx <= kx - gap;
4355                scene.nodes.push(SceneNode::Ellipse {
4356                    rect: Rect {
4357                        x: tx - dot_r,
4358                        y: cy - dot_r,
4359                        w: dot_r * 2.0,
4360                        h: dot_r * 2.0,
4361                    },
4362                    brush: Brush::Solid(mul_c(if on_active {
4363                        config.active_tick_color
4364                    } else {
4365                        config.inactive_tick_color
4366                    })),
4367                });
4368            }
4369            let da = *drag_active_p.get();
4370            let hv = hovered_sig.get();
4371            let tw = if da { thumb_w * 0.5 } else { thumb_w };
4372            scene.nodes.push(SceneNode::Rect {
4373                rect: Rect {
4374                    x: kx - tw * 0.5,
4375                    y: cy - thumb_h * 0.5,
4376                    w: tw,
4377                    h: thumb_h,
4378                },
4379                brush: Brush::Solid(mul_c(config.thumb_color)),
4380                radius: [tw * 0.5; 4],
4381            });
4382            let sc_target = if da {
4383                sc.pressed
4384            } else if hv {
4385                sc.hovered
4386            } else {
4387                sc.default
4388            };
4389            if sc_target.3 > 0 {
4390                scene.nodes.push(SceneNode::Rect {
4391                    rect: Rect {
4392                        x: kx - tw * 0.5,
4393                        y: cy - thumb_h * 0.5,
4394                        w: tw,
4395                        h: thumb_h,
4396                    },
4397                    brush: Brush::Solid(mul_c(sc_target)),
4398                    radius: [tw * 0.5; 4],
4399                });
4400            }
4401        })
4402        .on_pointer_enter({
4403            let h = hovered.clone();
4404            move |_pe: PointerEvent| h.set(true)
4405        })
4406        .on_pointer_leave({
4407            let h = hovered.clone();
4408            move |_pe: PointerEvent| h.set(false)
4409        })
4410        .on_pointer_down({
4411            let oc = oc.clone();
4412            let track_rect = track_rect.clone();
4413            let drag_active = drag_active.clone();
4414            move |pe: PointerEvent| {
4415                drag_active.set(true);
4416                let r = *track_rect.borrow();
4417                (oc)(value_from_x(pe.position.x, r, min, max, step));
4418            }
4419        })
4420        .on_pointer_move({
4421            let oc = oc.clone();
4422            let track_rect = track_rect.clone();
4423            let drag_active = drag_active.clone();
4424            move |pe: PointerEvent| {
4425                if !*drag_active.get() {
4426                    return;
4427                }
4428                let r = *track_rect.borrow();
4429                (oc)(value_from_x(pe.position.x, r, min, max, step));
4430            }
4431        })
4432        .on_pointer_up({
4433            let on_finished = config.on_value_change_finished.clone();
4434            move |_pe: PointerEvent| {
4435                drag_active.set(false);
4436                if let Some(ref cb) = on_finished {
4437                    (cb)();
4438                }
4439            }
4440        })
4441        .on_scroll({
4442            let oc = oc.clone();
4443            move |d: Vec2| -> Vec2 {
4444                let dir = if d.y < -0.5 {
4445                    1
4446                } else if d.y > 0.5 {
4447                    -1
4448                } else {
4449                    0
4450                };
4451                if dir == 0 {
4452                    return d;
4453                }
4454                let step_val = step.unwrap_or(1.0).max(1e-6);
4455                let new_val = snap_step(value + (dir as f32) * step_val, min, max, step);
4456                if (new_val - value).abs() > 1e-6 {
4457                    (oc)(new_val);
4458                    Vec2 { x: d.x, y: 0.0 }
4459                } else {
4460                    d
4461                }
4462            }
4463        })
4464        .then(config.modifier))
4465    .semantics(Semantics {
4466        role: Role::Slider,
4467        label: None,
4468        focused: false,
4469        enabled: true,
4470        selectable_group: false,
4471    })
4472}
4473
4474pub fn RangeSlider(
4475    start: f32,
4476    end: f32,
4477    range: (f32, f32),
4478    step: Option<f32>,
4479    on_change: impl Fn(f32, f32) + 'static,
4480    config: SliderConfig,
4481) -> View {
4482    assert!(range.0 <= range.1, "Slider range start must be <= end");
4483    if let Some(s) = step {
4484        assert!(s > 0.0, "Slider step must be positive");
4485    }
4486    let id = *remember(|| SLIDER_COUNTER.fetch_add(1, Ordering::Relaxed));
4487    let track_rect = remember_state_with_key(format!("mrs_rect_{}", id), || Rect::default());
4488    let drag_active = remember_mutable_with_key(format!("mrs_da_{}", id), || false);
4489    let active_thumb = remember_mutable_with_key(format!("mrs_at_{}", id), || false);
4490    let hovered = remember(|| Signal::new(false));
4491
4492    let min = range.0;
4493    let max = range.1;
4494    let oc = Rc::new(on_change);
4495    let range_size = (max - min).max(1e-6);
4496    let t0 = ((start - min) / range_size).clamp(0.0, 1.0);
4497    let t1 = ((end - min) / range_size).clamp(0.0, 1.0);
4498    let sc = config.state_colors;
4499    let is_enabled = config.enabled;
4500
4501    let act_trk = if !is_enabled {
4502        config.disabled_active_track_color
4503    } else {
4504        config.active_track_color
4505    };
4506    let inact_trk = if !is_enabled {
4507        config.disabled_inactive_track_color
4508    } else {
4509        config.inactive_track_color
4510    };
4511    let act_tick = if !is_enabled {
4512        config.disabled_active_tick_color
4513    } else {
4514        config.active_tick_color
4515    };
4516    let inact_tick = if !is_enabled {
4517        config.disabled_inactive_tick_color
4518    } else {
4519        config.inactive_tick_color
4520    };
4521    let thumb_col = if !is_enabled {
4522        config.disabled_thumb_color
4523    } else {
4524        config.thumb_color
4525    };
4526
4527    let tick_frac: Vec<f32> = if let Some(s) = step {
4528        let n = ((max - min) / s.max(1e-6)).round() as usize;
4529        (0..=n).map(|i| i as f32 / n as f32).collect()
4530    } else {
4531        Vec::new()
4532    };
4533
4534    let track_rect_p = track_rect.clone();
4535    let drag_active_p = drag_active.clone();
4536    let active_thumb_p = active_thumb.clone();
4537    let hovered_sig = hovered.clone();
4538
4539    Box(Modifier::new()
4540        .min_width(200.0)
4541        .height(44.0)
4542        .painter(move |scene: &mut Scene, rect: Rect, alpha: f32| {
4543            let mul_c = |c: Color| {
4544                Color(
4545                    c.0,
4546                    c.1,
4547                    c.2,
4548                    ((c.3 as f32) * alpha).clamp(0.0, 255.0) as u8,
4549                )
4550            };
4551            let track_h = dp_to_px(16.0);
4552            let thumb_w = dp_to_px(4.0);
4553            let thumb_h = dp_to_px(44.0);
4554            let dot_r = dp_to_px(2.0);
4555            let corner = track_h * 0.5;
4556            let gap = thumb_w * 0.5 + dp_to_px(ProgressIndicatorDefaults::SLIDER_THUMB_TRACK_GAP);
4557            let pad = thumb_w * 0.5;
4558            let track_x = rect.x + pad;
4559            let track_w = (rect.w - thumb_w).max(0.0);
4560            let cy = rect.y + rect.h * 0.5;
4561
4562            let thumb_pos = |tf: f32, fracs: &[f32]| {
4563                if step.is_some() && !fracs.is_empty() {
4564                    let is_first = (tf - fracs[0]).abs() < 1e-6;
4565                    let is_last = (tf - fracs[fracs.len() - 1]).abs() < 1e-6;
4566                    if is_first || is_last {
4567                        track_x + tf * track_w
4568                    } else {
4569                        track_x + (track_w - track_h) * tf + corner
4570                    }
4571                } else {
4572                    track_x + tf * track_w
4573                }
4574            };
4575            let k0 = thumb_pos(t0, &tick_frac);
4576            let k1 = thumb_pos(t1, &tick_frac);
4577            let active_l = k0.min(k1);
4578            let active_r = k0.max(k1);
4579
4580            *track_rect_p.borrow_mut() = Rect {
4581                x: track_x,
4582                y: rect.y,
4583                w: track_w,
4584                h: rect.h,
4585            };
4586
4587            let linactive_w = (active_l - gap - track_x).max(0.0);
4588            if linactive_w > 0.0 {
4589                scene.nodes.push(SceneNode::Rect {
4590                    rect: Rect {
4591                        x: track_x,
4592                        y: cy - track_h * 0.5,
4593                        w: linactive_w,
4594                        h: track_h,
4595                    },
4596                    brush: Brush::Solid(mul_c(inact_trk)),
4597                    radius: [corner; 4],
4598                });
4599                let sx0 = track_x + corner;
4600                scene.nodes.push(SceneNode::Ellipse {
4601                    rect: Rect {
4602                        x: sx0 - dot_r,
4603                        y: cy - dot_r,
4604                        w: dot_r * 2.0,
4605                        h: dot_r * 2.0,
4606                    },
4607                    brush: Brush::Solid(mul_c(inact_tick)),
4608                });
4609            }
4610            let rinactive_x = (active_r + gap).min(track_x + track_w);
4611            let rinactive_w = (track_x + track_w - rinactive_x).max(0.0);
4612            if rinactive_w > 0.0 {
4613                scene.nodes.push(SceneNode::Rect {
4614                    rect: Rect {
4615                        x: rinactive_x,
4616                        y: cy - track_h * 0.5,
4617                        w: rinactive_w,
4618                        h: track_h,
4619                    },
4620                    brush: Brush::Solid(mul_c(inact_trk)),
4621                    radius: [corner; 4],
4622                });
4623                let sx = track_x + track_w - corner;
4624                scene.nodes.push(SceneNode::Ellipse {
4625                    rect: Rect {
4626                        x: sx - dot_r,
4627                        y: cy - dot_r,
4628                        w: dot_r * 2.0,
4629                        h: dot_r * 2.0,
4630                    },
4631                    brush: Brush::Solid(mul_c(inact_tick)),
4632                });
4633            }
4634            let active_w = (active_r - gap - (active_l + gap)).max(0.0);
4635            if active_w > 0.0 {
4636                scene.nodes.push(SceneNode::Rect {
4637                    rect: Rect {
4638                        x: active_l + gap,
4639                        y: cy - track_h * 0.5,
4640                        w: active_w,
4641                        h: track_h,
4642                    },
4643                    brush: Brush::Solid(mul_c(act_trk)),
4644                    radius: [corner; 4],
4645                });
4646            }
4647            let tick_start = track_x + corner;
4648            let tick_end = track_x + track_w - corner;
4649            for (i, &tf) in tick_frac.iter().enumerate() {
4650                let tx = tick_start + tf * (tick_end - tick_start);
4651                // skip ticks that fall on the stop indicators (first and last)
4652                if i == 0 || i == tick_frac.len() - 1 {
4653                    continue;
4654                }
4655                let in_lgap = tx >= active_l - gap && tx <= active_l + gap;
4656                let in_rgap = tx >= active_r - gap && tx <= active_r + gap;
4657                if in_lgap || in_rgap {
4658                    continue;
4659                }
4660                let on_active = tx >= active_l + gap && tx <= active_r - gap;
4661                scene.nodes.push(SceneNode::Ellipse {
4662                    rect: Rect {
4663                        x: tx - dot_r,
4664                        y: cy - dot_r,
4665                        w: dot_r * 2.0,
4666                        h: dot_r * 2.0,
4667                    },
4668                    brush: Brush::Solid(mul_c(if on_active { act_tick } else { inact_tick })),
4669                });
4670            }
4671            let da = *drag_active_p.get();
4672            let at = *active_thumb_p.get();
4673            let hv = hovered_sig.get();
4674            let thumbs = [k0, k1];
4675            for (idx, &kx) in thumbs.iter().enumerate() {
4676                let is_active = da && (if idx == 0 { !at } else { at });
4677                let tw = if is_active { thumb_w * 0.5 } else { thumb_w };
4678                scene.nodes.push(SceneNode::Rect {
4679                    rect: Rect {
4680                        x: kx - tw * 0.5,
4681                        y: cy - thumb_h * 0.5,
4682                        w: tw,
4683                        h: thumb_h,
4684                    },
4685                    brush: Brush::Solid(mul_c(thumb_col)),
4686                    radius: [tw * 0.5; 4],
4687                });
4688                let sc_target = if !is_enabled {
4689                    Color::TRANSPARENT
4690                } else if is_active {
4691                    sc.pressed
4692                } else if hv {
4693                    sc.hovered
4694                } else {
4695                    sc.default
4696                };
4697                if sc_target.3 > 0 {
4698                    scene.nodes.push(SceneNode::Rect {
4699                        rect: Rect {
4700                            x: kx - tw * 0.5,
4701                            y: cy - thumb_h * 0.5,
4702                            w: tw,
4703                            h: thumb_h,
4704                        },
4705                        brush: Brush::Solid(mul_c(sc_target)),
4706                        radius: [tw * 0.5; 4],
4707                    });
4708                }
4709            }
4710        })
4711        .on_pointer_enter({
4712            let h = hovered.clone();
4713            let en = is_enabled;
4714            move |_pe: PointerEvent| {
4715                if en {
4716                    h.set(true);
4717                }
4718            }
4719        })
4720        .on_pointer_leave({
4721            let h = hovered.clone();
4722            move |_pe: PointerEvent| h.set(false)
4723        })
4724        .on_pointer_down({
4725            let oc = oc.clone();
4726            let track_rect = track_rect.clone();
4727            let drag_active = drag_active.clone();
4728            let active_thumb = active_thumb.clone();
4729            let en = is_enabled;
4730            move |pe: PointerEvent| {
4731                if !en {
4732                    return;
4733                }
4734                drag_active.set(true);
4735                let r = *track_rect.borrow();
4736                let v = value_from_x(pe.position.x, r, min, max, step);
4737                let use_end = (v - end).abs() < (v - start).abs();
4738                active_thumb.set(use_end);
4739                let (a, b) = if use_end {
4740                    (start, v.max(start))
4741                } else {
4742                    (v.min(end), end)
4743                };
4744                (oc)(a, b);
4745            }
4746        })
4747        .on_pointer_move({
4748            let oc = oc.clone();
4749            let track_rect = track_rect.clone();
4750            let drag_active = drag_active.clone();
4751            let active_thumb = active_thumb.clone();
4752            move |pe: PointerEvent| {
4753                if !*drag_active.get() {
4754                    return;
4755                }
4756                let r = *track_rect.borrow();
4757                let v = value_from_x(pe.position.x, r, min, max, step);
4758                let use_end = *active_thumb.get();
4759                let (a, b) = if use_end {
4760                    (start, v.max(start))
4761                } else {
4762                    (v.min(end), end)
4763                };
4764                (oc)(a, b);
4765            }
4766        })
4767        .on_pointer_up({
4768            let drag_active = drag_active.clone();
4769            let active_thumb = active_thumb.clone();
4770            move |_pe: PointerEvent| {
4771                drag_active.set(false);
4772                active_thumb.set(false);
4773            }
4774        })
4775        .on_scroll({
4776            let oc = oc.clone();
4777            let active_thumb = active_thumb.clone();
4778            let en = is_enabled;
4779            move |d: Vec2| -> Vec2 {
4780                if !en {
4781                    return d;
4782                }
4783                let dir = if d.y < -0.5 {
4784                    1
4785                } else if d.y > 0.5 {
4786                    -1
4787                } else {
4788                    0
4789                };
4790                if dir == 0 {
4791                    return d;
4792                }
4793                let step_val = step.unwrap_or(1.0).max(1e-6);
4794                let use_end = *active_thumb.get();
4795                let (mut a, mut b) = (start, end);
4796                if use_end {
4797                    b = snap_step(end + (dir as f32) * step_val, min, max, step).max(a);
4798                } else {
4799                    a = snap_step(start + (dir as f32) * step_val, min, max, step).min(b);
4800                }
4801                if (a - start).abs() > 1e-6 || (b - end).abs() > 1e-6 {
4802                    (oc)(a, b);
4803                    Vec2 { x: d.x, y: 0.0 }
4804                } else {
4805                    d
4806                }
4807            }
4808        })
4809        .then(config.modifier))
4810    .semantics(Semantics {
4811        role: Role::Slider,
4812        label: None,
4813        focused: false,
4814        enabled: is_enabled,
4815        selectable_group: false,
4816    })
4817}
4818
4819/// Configuration for [`Card`].
4820#[derive(Clone, Debug)]
4821pub struct CardConfig {
4822    pub modifier: Modifier,
4823    /// When false, renders disabled colors and does not respond to clicks.
4824    pub enabled: bool,
4825    pub container_color: Color,
4826    pub content_color: Color,
4827    pub disabled_container_color: Color,
4828    pub disabled_content_color: Color,
4829    pub shape_radius: f32,
4830    pub tonal_elevation: f32,
4831    pub state_elevation: Option<StateElevation>,
4832    pub border: Option<(f32, Color)>,
4833    pub interaction_source: Option<MutableInteractionSource>,
4834}
4835
4836impl Default for CardConfig {
4837    fn default() -> Self {
4838        Self {
4839            modifier: Modifier::new(),
4840            enabled: true,
4841            container_color: CardDefaults::filled_container_color(),
4842            content_color: CardDefaults::filled_content_color(),
4843            disabled_container_color: CardDefaults::disabled_container_color(),
4844            disabled_content_color: CardDefaults::disabled_content_color(),
4845            shape_radius: CardDefaults::SHAPE_RADIUS,
4846            tonal_elevation: CardDefaults::ELEVATION,
4847            state_elevation: None,
4848            border: None,
4849            interaction_source: None,
4850        }
4851    }
4852}
4853
4854/// M3 Card - a configurable container surface.
4855pub fn Card(config: CardConfig, content: impl FnOnce() -> View) -> View {
4856    let bg = if !config.enabled {
4857        config.disabled_container_color
4858    } else {
4859        config.container_color
4860    };
4861    let fg = if !config.enabled {
4862        config.disabled_content_color
4863    } else {
4864        config.content_color
4865    };
4866    let source: Rc<MutableInteractionSource> = config
4867        .interaction_source
4868        .clone()
4869        .map(Rc::new)
4870        .unwrap_or_else(|| remember(MutableInteractionSource::new));
4871    let mut m = Modifier::new()
4872        .background(bg)
4873        .clip_rounded(config.shape_radius)
4874        .interaction_source(&*source)
4875        .then(config.modifier);
4876    if let Some((w, c)) = config.border {
4877        m = m.border(w, c, config.shape_radius);
4878    }
4879    if let Some(se) = config.state_elevation {
4880        m = m.state_elevation(se);
4881    } else if config.tonal_elevation > 0.0 {
4882        m = m.state_elevation(StateElevation {
4883            default: config.tonal_elevation,
4884            hovered: config.tonal_elevation,
4885            pressed: config.tonal_elevation,
4886            disabled: 0.0,
4887        });
4888    }
4889    Box(m).color(fg).child(content())
4890}
4891
4892/// M3 Elevated Card - card with elevation.
4893pub fn ElevatedCard(config: CardConfig, content: impl FnOnce() -> View) -> View {
4894    let th = theme();
4895    Card(
4896        CardConfig {
4897            container_color: CardDefaults::elevated_container_color(),
4898            state_elevation: Some(StateElevation {
4899                default: th.elevation.level1,
4900                hovered: th.elevation.level2,
4901                pressed: th.elevation.level3,
4902                disabled: 0.0,
4903            }),
4904            ..config
4905        },
4906        content,
4907    )
4908}
4909
4910/// M3 Outlined Card - card with border outline.
4911pub fn OutlinedCard(config: CardConfig, content: impl FnOnce() -> View) -> View {
4912    Card(
4913        CardConfig {
4914            container_color: CardDefaults::outlined_container_color(),
4915            border: Some((1.0, CardDefaults::outlined_border_color())),
4916            ..config
4917        },
4918        content,
4919    )
4920}
4921
4922fn card_state_colors(bg: Color) -> StateColors {
4923    let th = theme();
4924    StateColors {
4925        default: Color::TRANSPARENT,
4926        hovered: th.on_surface.with_alpha_f32(0.08).composite_over(bg),
4927        pressed: th.on_surface.with_alpha_f32(0.12).composite_over(bg),
4928        disabled: th.on_surface.with_alpha_f32(0.12).composite_over(bg),
4929    }
4930}
4931
4932fn clickable_card_impl(
4933    on_click: impl Fn() + 'static,
4934    modifier: Modifier,
4935    bg: Color,
4936    shape_radius: f32,
4937    config: CardConfig,
4938    content: impl FnOnce() -> View,
4939) -> View {
4940    let m = modifier
4941        .state_colors(card_state_colors(bg))
4942        .clickable()
4943        .on_pointer_down({
4944            let cb = on_click;
4945            let en = config.enabled;
4946            move |_| {
4947                if en {
4948                    cb();
4949                }
4950            }
4951        });
4952    Card(
4953        CardConfig {
4954            modifier: m,
4955            enabled: config.enabled,
4956            container_color: bg,
4957            content_color: config.content_color,
4958            disabled_container_color: config.disabled_container_color,
4959            disabled_content_color: config.disabled_content_color,
4960            shape_radius,
4961            border: config.border,
4962            state_elevation: config.state_elevation,
4963            tonal_elevation: config.tonal_elevation,
4964            interaction_source: config.interaction_source.clone(),
4965        },
4966        || Column(Modifier::new().fill_max_size()).child(content()),
4967    )
4968}
4969
4970/// M3 Clickable Filled Card - interactive card with state coloring.
4971pub fn ClickableCard(
4972    on_click: impl Fn() + 'static,
4973    modifier: Modifier,
4974    config: CardConfig,
4975    content: impl FnOnce() -> View,
4976) -> View {
4977    let th = theme();
4978    clickable_card_impl(
4979        on_click,
4980        modifier,
4981        th.surface_container_highest,
4982        th.shapes.medium,
4983        config,
4984        content,
4985    )
4986}
4987
4988/// M3 Clickable Elevated Card - interactive card with elevation.
4989pub fn ClickableElevatedCard(
4990    on_click: impl Fn() + 'static,
4991    modifier: Modifier,
4992    config: CardConfig,
4993    content: impl FnOnce() -> View,
4994) -> View {
4995    let th = theme();
4996    let cfg = CardConfig {
4997        state_elevation: Some(StateElevation {
4998            default: th.elevation.level1,
4999            hovered: th.elevation.level2,
5000            pressed: th.elevation.level3,
5001            disabled: 0.0,
5002        }),
5003        ..config
5004    };
5005    clickable_card_impl(
5006        on_click,
5007        modifier,
5008        th.surface,
5009        th.shapes.medium,
5010        cfg,
5011        content,
5012    )
5013}
5014
5015/// M3 Clickable Outlined Card - interactive card with border.
5016pub fn ClickableOutlinedCard(
5017    on_click: impl Fn() + 'static,
5018    modifier: Modifier,
5019    config: CardConfig,
5020    content: impl FnOnce() -> View,
5021) -> View {
5022    let th = theme();
5023    let cfg = CardConfig {
5024        border: Some((1.0, th.outline_variant)),
5025        ..config
5026    };
5027    clickable_card_impl(
5028        on_click,
5029        modifier,
5030        th.surface,
5031        th.shapes.medium,
5032        cfg,
5033        content,
5034    )
5035}
5036
5037/// Configuration for [`Snackbar`].
5038#[derive(Clone, Debug)]
5039pub struct SnackbarConfig {
5040    pub modifier: Modifier,
5041    pub container_color: Color,
5042    pub content_color: Color,
5043    pub action_color: Color,
5044    pub dismiss_action_content_color: Color,
5045    pub action_on_new_line: bool,
5046    pub shape_radius: f32,
5047    pub min_height: f32,
5048    pub min_width: f32,
5049    pub max_width: f32,
5050}
5051
5052impl Default for SnackbarConfig {
5053    fn default() -> Self {
5054        Self {
5055            modifier: Modifier::new(),
5056            container_color: SnackbarDefaults::container_color(),
5057            content_color: SnackbarDefaults::content_color(),
5058            action_color: SnackbarDefaults::action_color(),
5059            dismiss_action_content_color: SnackbarDefaults::dismiss_action_content_color(),
5060            action_on_new_line: false,
5061            shape_radius: SnackbarDefaults::SHAPE_RADIUS,
5062            min_height: SnackbarDefaults::MIN_HEIGHT,
5063            min_width: SnackbarDefaults::MIN_WIDTH,
5064            max_width: SnackbarDefaults::MAX_WIDTH,
5065        }
5066    }
5067}
5068
5069/// Color slots for chips (both non-selectable and selectable).
5070#[derive(Clone, Copy, Debug)]
5071pub struct ChipColors {
5072    pub container_color: Color,
5073    pub label_color: Color,
5074    pub leading_icon_color: Color,
5075    pub trailing_icon_color: Color,
5076    pub disabled_container_color: Color,
5077    pub disabled_label_color: Color,
5078    pub disabled_leading_icon_color: Color,
5079    pub disabled_trailing_icon_color: Color,
5080    pub selected_container_color: Color,
5081    pub selected_label_color: Color,
5082    pub selected_leading_icon_color: Color,
5083    pub selected_trailing_icon_color: Color,
5084    pub disabled_selected_container_color: Color,
5085}
5086
5087impl ChipColors {
5088    pub fn container(&self, enabled: bool, selected: bool) -> Color {
5089        match (enabled, selected) {
5090            (true, true) => self.selected_container_color,
5091            (true, false) => self.container_color,
5092            (false, true) => self.disabled_selected_container_color,
5093            (false, false) => self.disabled_container_color,
5094        }
5095    }
5096    pub fn label(&self, enabled: bool, selected: bool) -> Color {
5097        if !enabled {
5098            self.disabled_label_color
5099        } else if selected {
5100            self.selected_label_color
5101        } else {
5102            self.label_color
5103        }
5104    }
5105    pub fn leading_icon(&self, enabled: bool, selected: bool) -> Color {
5106        if !enabled {
5107            self.disabled_leading_icon_color
5108        } else if selected {
5109            self.selected_leading_icon_color
5110        } else {
5111            self.leading_icon_color
5112        }
5113    }
5114    pub fn trailing_icon(&self, enabled: bool, selected: bool) -> Color {
5115        if !enabled {
5116            self.disabled_trailing_icon_color
5117        } else if selected {
5118            self.selected_trailing_icon_color
5119        } else {
5120            self.trailing_icon_color
5121        }
5122    }
5123}
5124
5125/// Elevation levels for chips.
5126#[derive(Clone, Copy, Debug)]
5127pub struct ChipElevation {
5128    pub default: f32,
5129    pub hovered: f32,
5130    pub focused: f32,
5131    pub pressed: f32,
5132    pub dragged: f32,
5133    pub disabled: f32,
5134}
5135
5136impl ChipElevation {
5137    pub fn to_state_elevation(&self) -> StateElevation {
5138        StateElevation {
5139            default: self.default,
5140            hovered: self.hovered,
5141            pressed: self.pressed,
5142            disabled: self.disabled,
5143        }
5144    }
5145}
5146
5147impl Default for ChipElevation {
5148    fn default() -> Self {
5149        Self {
5150            default: ChipDefaults::elevation_default(),
5151            hovered: ChipDefaults::elevation_hovered(),
5152            focused: ChipDefaults::elevation_focused(),
5153            pressed: ChipDefaults::elevation_pressed(),
5154            dragged: ChipDefaults::elevation_dragged(),
5155            disabled: ChipDefaults::elevation_disabled(),
5156        }
5157    }
5158}
5159
5160/// Configuration for chips.
5161#[derive(Clone, Debug)]
5162pub struct ChipConfig {
5163    pub modifier: Modifier,
5164    pub enabled: bool,
5165    pub colors: ChipColors,
5166    pub elevation: ChipElevation,
5167    pub border_width: f32,
5168    pub border_color: Color,
5169    pub selected_border_color: Color,
5170    pub disabled_border_color: Color,
5171    pub disabled_selected_border_color: Color,
5172    pub shape_radius: f32,
5173    pub horizontal_padding: f32,
5174    pub interaction_source: Option<MutableInteractionSource>,
5175}
5176
5177impl Default for ChipConfig {
5178    fn default() -> Self {
5179        Self {
5180            modifier: Modifier::new(),
5181            enabled: true,
5182            colors: ChipColors {
5183                container_color: ChipDefaults::container_color(),
5184                label_color: ChipDefaults::label_color(),
5185                leading_icon_color: ChipDefaults::leading_icon_color(),
5186                trailing_icon_color: ChipDefaults::trailing_icon_color(),
5187                disabled_container_color: ChipDefaults::disabled_container_color(),
5188                disabled_label_color: ChipDefaults::disabled_label_color(),
5189                disabled_leading_icon_color: ChipDefaults::disabled_leading_icon_color(),
5190                disabled_trailing_icon_color: ChipDefaults::disabled_trailing_icon_color(),
5191                selected_container_color: ChipDefaults::selected_container_color(),
5192                selected_label_color: ChipDefaults::selected_label_color(),
5193                selected_leading_icon_color: ChipDefaults::selected_leading_icon_color(),
5194                selected_trailing_icon_color: ChipDefaults::selected_trailing_icon_color(),
5195                disabled_selected_container_color: ChipDefaults::disabled_selected_container_color(
5196                ),
5197            },
5198            elevation: ChipElevation::default(),
5199            border_width: ChipDefaults::BORDER_WIDTH,
5200            border_color: ChipDefaults::border_color(),
5201            selected_border_color: ChipDefaults::selected_border_color(),
5202            disabled_border_color: ChipDefaults::disabled_border_color(),
5203            disabled_selected_border_color: ChipDefaults::disabled_selected_border_color(),
5204            shape_radius: ChipDefaults::SHAPE_RADIUS,
5205            horizontal_padding: ChipDefaults::HORIZONTAL_PADDING,
5206            interaction_source: None,
5207        }
5208    }
5209}
5210
5211/// M3 Assist Chip - a chip for triggering actions.
5212pub fn AssistChip(
5213    on_click: impl Fn() + 'static,
5214    label: View,
5215    leading_icon: Option<View>,
5216    trailing_icon: Option<View>,
5217    config: ChipConfig,
5218) -> View {
5219    let th = theme();
5220    let is_enabled = config.enabled;
5221    let colors = &config.colors;
5222    let bg = colors.container(is_enabled, false);
5223    let label_color = colors.label(is_enabled, false);
5224    let leading_color = colors.leading_icon(is_enabled, false);
5225    let trailing_color = colors.trailing_icon(is_enabled, false);
5226    let border = if is_enabled {
5227        config.border_color
5228    } else {
5229        config.disabled_border_color
5230    };
5231    let shape = config.shape_radius;
5232    let ch_source: Rc<MutableInteractionSource> = config
5233        .interaction_source
5234        .clone()
5235        .map(Rc::new)
5236        .unwrap_or_else(|| remember(MutableInteractionSource::new));
5237
5238    let mut m = Modifier::new()
5239        .state_colors(StateColors {
5240            default: Color::TRANSPARENT,
5241            hovered: th.on_surface.with_alpha_f32(0.08),
5242            pressed: th.on_surface.with_alpha_f32(0.12),
5243            disabled: Color::TRANSPARENT,
5244        })
5245        .padding_values(PaddingValues {
5246            left: config.horizontal_padding,
5247            right: config.horizontal_padding,
5248            top: 8.0,
5249            bottom: 8.0,
5250        })
5251        .background(bg)
5252        .clip_rounded(shape)
5253        .interaction_source(&*ch_source)
5254        .then(config.modifier);
5255
5256    if config.border_width > 0.0 && border != Color::TRANSPARENT {
5257        m = m.border(config.border_width, border, shape);
5258    }
5259
5260    if is_enabled {
5261        m = m.clickable().on_click(move || on_click());
5262    }
5263
5264    Box(m).child(
5265        Row(Modifier::new().align_items(AlignItems::CENTER)).child((
5266            leading_icon
5267                .map(|v| {
5268                    Box(Modifier::new().padding_values(PaddingValues {
5269                        left: 0.0,
5270                        right: 8.0,
5271                        top: 0.0,
5272                        bottom: 0.0,
5273                    }))
5274                    .child(with_content_color(leading_color, move || v))
5275                })
5276                .unwrap_or(Box(Modifier::new())),
5277            with_content_color(label_color, move || label),
5278            trailing_icon
5279                .map(|v| {
5280                    Box(Modifier::new().padding_values(PaddingValues {
5281                        left: 8.0,
5282                        right: 0.0,
5283                        top: 0.0,
5284                        bottom: 0.0,
5285                    }))
5286                    .child(with_content_color(trailing_color, move || v))
5287                })
5288                .unwrap_or(Box(Modifier::new())),
5289        )),
5290    )
5291}
5292
5293/// M3 Elevated Assist Chip - like [`AssistChip`] but with elevated container.
5294pub fn ElevatedAssistChip(
5295    on_click: impl Fn() + 'static,
5296    label: View,
5297    leading_icon: Option<View>,
5298    trailing_icon: Option<View>,
5299    config: ChipConfig,
5300) -> View {
5301    let th = theme();
5302    let is_enabled = config.enabled;
5303    let colors = &config.colors;
5304    let bg = colors.container(is_enabled, false);
5305    let label_color = colors.label(is_enabled, false);
5306    let leading_color = colors.leading_icon(is_enabled, false);
5307    let trailing_color = colors.trailing_icon(is_enabled, false);
5308    let shape = config.shape_radius;
5309    let ch_source: Rc<MutableInteractionSource> = config
5310        .interaction_source
5311        .clone()
5312        .map(Rc::new)
5313        .unwrap_or_else(|| remember(MutableInteractionSource::new));
5314
5315    let mut m = Modifier::new()
5316        .state_colors(StateColors {
5317            default: Color::TRANSPARENT,
5318            hovered: th.on_surface.with_alpha_f32(0.08),
5319            pressed: th.on_surface.with_alpha_f32(0.12),
5320            disabled: Color::TRANSPARENT,
5321        })
5322        .state_elevation(config.elevation.to_state_elevation())
5323        .padding_values(PaddingValues {
5324            left: config.horizontal_padding,
5325            right: config.horizontal_padding,
5326            top: 8.0,
5327            bottom: 8.0,
5328        })
5329        .background(bg)
5330        .clip_rounded(shape)
5331        .interaction_source(&*ch_source)
5332        .then(config.modifier);
5333
5334    if is_enabled {
5335        m = m.clickable().on_click(move || on_click());
5336    }
5337
5338    Box(m).child(
5339        Row(Modifier::new().align_items(AlignItems::CENTER)).child((
5340            leading_icon
5341                .map(|v| {
5342                    Box(Modifier::new().padding_values(PaddingValues {
5343                        left: 0.0,
5344                        right: 8.0,
5345                        top: 0.0,
5346                        bottom: 0.0,
5347                    }))
5348                    .child(with_content_color(leading_color, move || v))
5349                })
5350                .unwrap_or(Box(Modifier::new())),
5351            with_content_color(label_color, move || label),
5352            trailing_icon
5353                .map(|v| {
5354                    Box(Modifier::new().padding_values(PaddingValues {
5355                        left: 8.0,
5356                        right: 0.0,
5357                        top: 0.0,
5358                        bottom: 0.0,
5359                    }))
5360                    .child(with_content_color(trailing_color, move || v))
5361                })
5362                .unwrap_or(Box(Modifier::new())),
5363        )),
5364    )
5365}
5366
5367/// Configuration for [`NavigationBar`].
5368#[derive(Clone, Debug)]
5369pub struct NavigationBarConfig {
5370    pub modifier: Modifier,
5371    pub container_color: Color,
5372    pub content_color: Color,
5373    pub selected_icon_color: Color,
5374    pub selected_text_color: Color,
5375    pub unselected_icon_color: Color,
5376    pub unselected_text_color: Color,
5377    pub indicator_color: Color,
5378    pub height: f32,
5379    pub tonal_elevation: f32,
5380    pub indicator_opacity: f32,
5381    pub indicator_radius: f32,
5382    pub item_spacing: f32,
5383    pub indicator_width: f32,
5384    pub indicator_height: f32,
5385}
5386
5387impl Default for NavigationBarConfig {
5388    fn default() -> Self {
5389        Self {
5390            modifier: Modifier::new(),
5391            container_color: NavigationBarDefaults::container_color(),
5392            content_color: NavigationBarDefaults::content_color(),
5393            selected_icon_color: NavigationBarDefaults::selected_icon_color(),
5394            selected_text_color: NavigationBarDefaults::selected_text_color(),
5395            unselected_icon_color: NavigationBarDefaults::unselected_icon_color(),
5396            unselected_text_color: NavigationBarDefaults::unselected_text_color(),
5397            indicator_color: NavigationBarDefaults::indicator_color(),
5398            height: NavigationBarDefaults::HEIGHT,
5399            tonal_elevation: NavigationBarDefaults::TONAL_ELEVATION,
5400            indicator_opacity: NavigationBarDefaults::ITEM_ACTIVE_INDICATOR_OPACITY,
5401            indicator_radius: NavigationBarDefaults::INDICATOR_RADIUS,
5402            item_spacing: NavigationBarDefaults::ITEM_SPACING,
5403            indicator_width: NavigationBarDefaults::ACTIVE_INDICATOR_WIDTH,
5404            indicator_height: NavigationBarDefaults::ACTIVE_INDICATOR_HEIGHT,
5405        }
5406    }
5407}
5408
5409/// Configuration for [`NavigationRail`].
5410#[derive(Clone, Debug)]
5411pub struct NavigationRailConfig {
5412    pub modifier: Modifier,
5413    pub container_color: Color,
5414    pub selected_icon_color: Color,
5415    pub selected_text_color: Color,
5416    pub unselected_icon_color: Color,
5417    pub unselected_text_color: Color,
5418    pub indicator_color: Color,
5419    pub width: f32,
5420    pub item_radius: f32,
5421    pub indicator_opacity: f32,
5422    pub item_spacing: f32,
5423    pub indicator_width: f32,
5424    pub indicator_height: f32,
5425}
5426
5427impl Default for NavigationRailConfig {
5428    fn default() -> Self {
5429        Self {
5430            modifier: Modifier::new(),
5431            container_color: NavigationRailDefaults::container_color(),
5432            selected_icon_color: NavigationRailDefaults::selected_icon_color(),
5433            selected_text_color: NavigationRailDefaults::selected_text_color(),
5434            unselected_icon_color: NavigationRailDefaults::unselected_icon_color(),
5435            unselected_text_color: NavigationRailDefaults::unselected_text_color(),
5436            indicator_color: NavigationRailDefaults::indicator_color(),
5437            width: NavigationRailDefaults::WIDTH,
5438            item_radius: NavigationRailDefaults::ITEM_RADIUS,
5439            indicator_opacity: NavigationRailDefaults::ITEM_ACTIVE_INDICATOR_OPACITY,
5440            item_spacing: NavigationRailDefaults::ITEM_SPACING,
5441            indicator_width: NavigationRailDefaults::ACTIVE_INDICATOR_WIDTH,
5442            indicator_height: NavigationRailDefaults::ACTIVE_INDICATOR_HEIGHT,
5443        }
5444    }
5445}
5446
5447/// Configuration for [`Scaffold`].
5448#[derive(Clone, Debug)]
5449pub struct ScaffoldConfig {
5450    pub modifier: Modifier,
5451    pub container_color: Color,
5452    pub top_bar_height: f32,
5453    pub bottom_bar_height: f32,
5454    pub fab_margin: f32,
5455}
5456
5457impl Default for ScaffoldConfig {
5458    fn default() -> Self {
5459        Self {
5460            modifier: Modifier::new(),
5461            container_color: ScaffoldDefaults::container_color(),
5462            top_bar_height: ScaffoldDefaults::TOP_BAR_HEIGHT,
5463            bottom_bar_height: ScaffoldDefaults::BOTTOM_BAR_HEIGHT,
5464            fab_margin: ScaffoldDefaults::FAB_MARGIN,
5465        }
5466    }
5467}
5468
5469/// Configuration for [`NavigationDrawer`].
5470#[derive(Clone, Debug)]
5471pub struct NavigationDrawerConfig {
5472    pub modifier: Modifier,
5473    pub container_color: Color,
5474    pub content_color: Color,
5475    pub scrim_color: Color,
5476    pub tonal_elevation: f32,
5477    pub width: f32,
5478    pub shape_radius: f32,
5479}
5480
5481impl Default for NavigationDrawerConfig {
5482    fn default() -> Self {
5483        Self {
5484            modifier: Modifier::new(),
5485            container_color: NavigationDrawerDefaults::container_color(),
5486            content_color: NavigationDrawerDefaults::content_color(),
5487            scrim_color: NavigationDrawerDefaults::scrim_color(),
5488            tonal_elevation: NavigationDrawerDefaults::TONAL_ELEVATION,
5489            width: NavigationDrawerDefaults::WIDTH,
5490            shape_radius: NavigationDrawerDefaults::SHAPE_RADIUS,
5491        }
5492    }
5493}
5494
5495/// Configuration for [`BottomSheet`] / `ModalBottomSheet`.
5496#[derive(Clone, Debug)]
5497pub struct BottomSheetConfig {
5498    pub modifier: Modifier,
5499    pub container_color: Color,
5500    pub content_color: Color,
5501    pub scrim_color: Color,
5502    pub tonal_elevation: f32,
5503    pub shadow_elevation: f32,
5504    pub drag_handle_color: Color,
5505    pub shape_radius: f32,
5506    pub max_width: f32,
5507    pub drag_handle_width: f32,
5508    pub drag_handle_height: f32,
5509    pub peek_height: f32,
5510    pub gestures_enabled: bool,
5511}
5512
5513impl Default for BottomSheetConfig {
5514    fn default() -> Self {
5515        Self {
5516            modifier: Modifier::new(),
5517            container_color: BottomSheetDefaults::container_color(),
5518            content_color: BottomSheetDefaults::content_color(),
5519            scrim_color: BottomSheetDefaults::scrim_color(),
5520            tonal_elevation: BottomSheetDefaults::TONAL_ELEVATION,
5521            shadow_elevation: 0.0,
5522            drag_handle_color: BottomSheetDefaults::drag_handle_color(),
5523            shape_radius: BottomSheetDefaults::SHAPE_RADIUS,
5524            max_width: BottomSheetDefaults::MAX_WIDTH,
5525            drag_handle_width: BottomSheetDefaults::DRAG_HANDLE_WIDTH,
5526            drag_handle_height: BottomSheetDefaults::DRAG_HANDLE_HEIGHT,
5527            peek_height: BottomSheetDefaults::PEEK_HEIGHT,
5528            gestures_enabled: true,
5529        }
5530    }
5531}
5532
5533/// Color slots for [`SearchBar`]. Matches Compose Material3 `SearchBarColors`.
5534#[derive(Clone, Copy, Debug)]
5535pub struct SearchBarColors {
5536    pub container_color: Color,
5537    pub active_container_color: Color,
5538    pub divider_color: Color,
5539    pub content_color: Color,
5540    pub placeholder_color: Color,
5541    pub scrim_color: Color,
5542}
5543
5544impl SearchBarColors {
5545    pub fn container(&self, active: bool) -> Color {
5546        if active {
5547            self.active_container_color
5548        } else {
5549            self.container_color
5550        }
5551    }
5552}
5553
5554impl Default for SearchBarColors {
5555    fn default() -> Self {
5556        Self {
5557            container_color: SearchBarDefaults::container_color(),
5558            active_container_color: SearchBarDefaults::active_container_color(),
5559            divider_color: SearchBarDefaults::divider_color(),
5560            content_color: SearchBarDefaults::content_color(),
5561            placeholder_color: SearchBarDefaults::placeholder_color(),
5562            scrim_color: SearchBarDefaults::scrim_color(),
5563        }
5564    }
5565}
5566
5567/// Color slots for [`AppBarWithSearch`]. Scrolled/not-scrolled pairs.
5568#[derive(Clone, Copy, Debug)]
5569pub struct AppBarWithSearchColors {
5570    pub search_bar_colors: SearchBarColors,
5571    pub scrolled_search_bar_container_color: Color,
5572    pub app_bar_container_color: Color,
5573    pub scrolled_app_bar_container_color: Color,
5574    pub navigation_icon_content_color: Color,
5575    pub action_icon_content_color: Color,
5576}
5577
5578impl AppBarWithSearchColors {
5579    pub fn search_bar_container(&self, scroll_fraction: f32) -> Color {
5580        lerp_color(
5581            self.search_bar_colors.container_color,
5582            self.scrolled_search_bar_container_color,
5583            scroll_fraction.clamp(0.0, 1.0),
5584        )
5585    }
5586    pub fn app_bar_container(&self, scroll_fraction: f32) -> Color {
5587        lerp_color(
5588            self.app_bar_container_color,
5589            self.scrolled_app_bar_container_color,
5590            scroll_fraction.clamp(0.0, 1.0),
5591        )
5592    }
5593}
5594
5595impl Default for AppBarWithSearchColors {
5596    fn default() -> Self {
5597        Self {
5598            search_bar_colors: SearchBarColors::default(),
5599            scrolled_search_bar_container_color: SearchBarDefaults::scrolled_container_color(),
5600            app_bar_container_color: SearchBarDefaults::app_bar_container_color(),
5601            scrolled_app_bar_container_color: SearchBarDefaults::scrolled_app_bar_container_color(),
5602            navigation_icon_content_color: SearchBarDefaults::navigation_icon_content_color(),
5603            action_icon_content_color: SearchBarDefaults::action_icon_content_color(),
5604        }
5605    }
5606}
5607
5608/// Configuration for [`SearchBar`].
5609#[derive(Clone, Debug)]
5610pub struct SearchBarConfig {
5611    pub modifier: Modifier,
5612    pub colors: SearchBarColors,
5613    pub height: f32,
5614    pub shape_radius: f32,
5615    pub active_shape_radius: f32,
5616    pub expanded_width: f32,
5617    pub collapsed_width: f32,
5618    pub tonal_elevation: f32,
5619    pub shadow_elevation: f32,
5620    pub window_insets: WindowInsets,
5621    pub content_padding: PaddingValues,
5622    pub min_width: f32,
5623    pub max_width: f32,
5624}
5625
5626impl Default for SearchBarConfig {
5627    fn default() -> Self {
5628        Self {
5629            modifier: Modifier::new(),
5630            colors: SearchBarColors::default(),
5631            height: SearchBarDefaults::HEIGHT,
5632            shape_radius: SearchBarDefaults::SHAPE_RADIUS,
5633            active_shape_radius: SearchBarDefaults::ACTIVE_SHAPE_RADIUS,
5634            expanded_width: SearchBarDefaults::EXPANDED_WIDTH,
5635            collapsed_width: SearchBarDefaults::COLLAPSED_WIDTH,
5636            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
5637            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
5638            window_insets: WindowInsets::default(),
5639            content_padding: SearchBarDefaults::CONTENT_PADDING,
5640            min_width: SearchBarDefaults::MIN_WIDTH,
5641            max_width: SearchBarDefaults::MAX_WIDTH,
5642        }
5643    }
5644}
5645
5646/// Configuration for [`ExpandedFullScreenSearchBar`].
5647#[derive(Clone, Debug)]
5648pub struct ExpandedFullScreenSearchBarConfig {
5649    pub modifier: Modifier,
5650    pub colors: SearchBarColors,
5651    pub collapsed_shape_radius: f32,
5652    pub tonal_elevation: f32,
5653    pub shadow_elevation: f32,
5654    pub window_insets: WindowInsets,
5655    pub scrim_color: Color,
5656}
5657
5658impl Default for ExpandedFullScreenSearchBarConfig {
5659    fn default() -> Self {
5660        Self {
5661            modifier: Modifier::new(),
5662            colors: SearchBarColors::default(),
5663            collapsed_shape_radius: SearchBarDefaults::SHAPE_RADIUS,
5664            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
5665            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
5666            window_insets: WindowInsets::default(),
5667            scrim_color: SearchBarDefaults::scrim_color(),
5668        }
5669    }
5670}
5671
5672/// Configuration for [`ExpandedDockedSearchBar`].
5673#[derive(Clone, Debug)]
5674pub struct ExpandedDockedSearchBarConfig {
5675    pub modifier: Modifier,
5676    pub colors: SearchBarColors,
5677    pub shape_radius: f32,
5678    pub dropdown_shape_radius: f32,
5679    pub dropdown_gap_size: f32,
5680    pub dropdown_scrim_color: Color,
5681    pub tonal_elevation: f32,
5682    pub shadow_elevation: f32,
5683}
5684
5685impl Default for ExpandedDockedSearchBarConfig {
5686    fn default() -> Self {
5687        Self {
5688            modifier: Modifier::new(),
5689            colors: SearchBarColors::default(),
5690            shape_radius: SearchBarDefaults::DOCKED_SHAPE_RADIUS,
5691            dropdown_shape_radius: SearchBarDefaults::DROPDOWN_SHAPE_RADIUS,
5692            dropdown_gap_size: SearchBarDefaults::DROPDOWN_GAP_SIZE,
5693            dropdown_scrim_color: SearchBarDefaults::dropdown_scrim_color(),
5694            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
5695            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
5696        }
5697    }
5698}
5699
5700/// Configuration for [`AppBarWithSearch`].
5701#[derive(Clone, Debug)]
5702pub struct AppBarWithSearchConfig {
5703    pub modifier: Modifier,
5704    pub colors: AppBarWithSearchColors,
5705    pub height: f32,
5706    pub shape_radius: f32,
5707    pub tonal_elevation: f32,
5708    pub shadow_elevation: f32,
5709    pub content_padding: PaddingValues,
5710    pub window_insets: WindowInsets,
5711    pub scroll_fraction: f32,
5712    pub scroll_offset: f32,
5713}
5714
5715impl Default for AppBarWithSearchConfig {
5716    fn default() -> Self {
5717        Self {
5718            modifier: Modifier::new(),
5719            colors: AppBarWithSearchColors::default(),
5720            height: SearchBarDefaults::HEIGHT,
5721            shape_radius: SearchBarDefaults::SHAPE_RADIUS,
5722            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
5723            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
5724            content_padding: SearchBarDefaults::CONTENT_PADDING,
5725            window_insets: WindowInsets::default(),
5726            scroll_fraction: 0.0,
5727            scroll_offset: 0.0,
5728        }
5729    }
5730}
5731
5732/// Scroll behavior for [`AppBarWithSearch`] -> collapses/expands on scroll.
5733pub struct SearchBarScrollBehavior {
5734    pub collapsed_offset: Signal<f32>,
5735    pub height: f32,
5736    pub collapsed_height: f32,
5737    _pending: Rc<Cell<f32>>,
5738}
5739
5740impl SearchBarScrollBehavior {
5741    pub fn new(height: f32, collapsed_height: f32) -> Self {
5742        Self {
5743            collapsed_offset: signal(0.0),
5744            height,
5745            collapsed_height,
5746            _pending: Rc::new(Cell::new(0.0)),
5747        }
5748    }
5749
5750    pub fn offset(&self) -> f32 {
5751        self.collapsed_offset.get()
5752    }
5753
5754    pub fn nested_scroll_connection(&self) -> NestedScrollConnection {
5755        let offset = self.collapsed_offset.clone();
5756        let max_offset = self.height - self.collapsed_height;
5757        NestedScrollConnection::new().on_pre_scroll(move |delta: Vec2, _source| {
5758            let cur = offset.get();
5759            let new = (cur - delta.y).clamp(-max_offset, 0.0);
5760            let consumed = cur - new;
5761            offset.set(new);
5762            request_frame();
5763            Vec2 {
5764                x: 0.0,
5765                y: consumed,
5766            }
5767        })
5768    }
5769}
5770
5771/// Configuration for [`DropdownMenu`].
5772#[derive(Clone, Debug)]
5773pub struct DropdownMenuConfig {
5774    pub modifier: Modifier,
5775    pub container_color: Color,
5776    pub item_text_color: Color,
5777    pub disabled_item_text_color: Color,
5778    pub divider_color: Color,
5779    pub min_width: f32,
5780    pub item_height: f32,
5781    pub max_width: f32,
5782    pub shadow_elevation: Option<f32>,
5783    pub tonal_elevation: f32,
5784    pub border: Option<(f32, Color, f32)>,
5785    pub shape_radius: Option<f32>,
5786    pub offset_x: f32,
5787    pub offset_y: f32,
5788    pub vertical_margin: f32,
5789}
5790
5791impl Default for DropdownMenuConfig {
5792    fn default() -> Self {
5793        Self {
5794            modifier: Modifier::new(),
5795            container_color: DropdownMenuDefaults::container_color(),
5796            item_text_color: DropdownMenuDefaults::item_text_color(),
5797            disabled_item_text_color: DropdownMenuDefaults::disabled_item_text_color(),
5798            divider_color: DropdownMenuDefaults::divider_color(),
5799            min_width: DropdownMenuDefaults::MIN_WIDTH,
5800            item_height: DropdownMenuDefaults::ITEM_HEIGHT,
5801            max_width: DropdownMenuDefaults::MAX_WIDTH,
5802            shadow_elevation: None,
5803            tonal_elevation: 0.0,
5804            border: None,
5805            shape_radius: None,
5806            offset_x: 0.0,
5807            offset_y: 0.0,
5808            vertical_margin: DropdownMenuDefaults::VERTICAL_MARGIN,
5809        }
5810    }
5811}
5812
5813/// Configuration for tooltip.
5814#[derive(Clone, Debug)]
5815pub struct TooltipConfig {
5816    pub modifier: Modifier,
5817    pub container_color: Color,
5818    pub content_color: Color,
5819    pub offset_y: f32,
5820    pub horizontal_padding: f32,
5821    pub vertical_padding: f32,
5822    pub has_action: bool,
5823    pub enable_user_input: bool,
5824    pub focusable: bool,
5825    pub max_width: f32,
5826    pub tonal_elevation: f32,
5827    pub shadow_elevation: f32,
5828}
5829
5830impl Default for TooltipConfig {
5831    fn default() -> Self {
5832        Self {
5833            modifier: Modifier::new(),
5834            container_color: TooltipDefaults::container_color(),
5835            content_color: TooltipDefaults::content_color(),
5836            offset_y: TooltipDefaults::OFFSET_Y,
5837            horizontal_padding: TooltipDefaults::HORIZONTAL_PADDING,
5838            vertical_padding: TooltipDefaults::VERTICAL_PADDING,
5839            has_action: false,
5840            enable_user_input: true,
5841            focusable: false,
5842            max_width: TooltipDefaults::MAX_WIDTH,
5843            tonal_elevation: 0.0,
5844            shadow_elevation: 0.0,
5845        }
5846    }
5847}
5848
5849/// Configuration for swipe-to-dismiss.
5850#[derive(Clone, Debug)]
5851pub struct SwipeToDismissConfig {
5852    pub modifier: Modifier,
5853    pub dismiss_threshold: f32,
5854    pub dismissed_offset: f32,
5855    pub animation_spec: AnimationSpec,
5856    pub gestures_enabled: bool,
5857    pub enable_dismiss_from_start_to_end: bool,
5858    pub enable_dismiss_from_end_to_start: bool,
5859}
5860
5861impl Default for SwipeToDismissConfig {
5862    fn default() -> Self {
5863        Self {
5864            modifier: Modifier::new(),
5865            dismiss_threshold: SwipeToDismissDefaults::DISMISS_THRESHOLD,
5866            dismissed_offset: SwipeToDismissDefaults::DISMISSED_OFFSET,
5867            animation_spec: AnimationSpec::spring_gentle(),
5868            gestures_enabled: true,
5869            enable_dismiss_from_start_to_end: true,
5870            enable_dismiss_from_end_to_start: true,
5871        }
5872    }
5873}
5874
5875/// Configuration for pull-to-refresh.
5876#[derive(Clone, Debug)]
5877pub struct PullToRefreshConfig {
5878    pub modifier: Modifier,
5879    pub indicator_color: Color,
5880    pub threshold: f32,
5881    pub content_alignment: AlignItems,
5882}
5883
5884impl Default for PullToRefreshConfig {
5885    fn default() -> Self {
5886        Self {
5887            modifier: Modifier::new(),
5888            indicator_color: PullToRefreshDefaults::indicator_color(),
5889            threshold: PullToRefreshDefaults::THRESHOLD,
5890            content_alignment: AlignItems::FLEX_START,
5891        }
5892    }
5893}