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 mut m = Modifier::new()
1379        .height(56.0)
1380        .min_width(80.0)
1381        .background(bg)
1382        .state_colors(StateColors {
1383            default: Color::TRANSPARENT,
1384            hovered: config.content_color.with_alpha_f32(0.08),
1385            pressed: config.content_color.with_alpha_f32(0.12),
1386            disabled: theme().on_surface.with_alpha_f32(0.12),
1387        })
1388        .state_elevation(config.state_elevation)
1389        .clip_rounded(FABDefaults::SHAPE_RADIUS)
1390        .padding_values(PaddingValues {
1391            left: 16.0,
1392            right: 20.0,
1393            top: 0.0,
1394            bottom: 0.0,
1395        })
1396        .align_items(AlignItems::CENTER);
1397
1398    if is_enabled {
1399        m = m.clickable().on_click(move || on_click());
1400    }
1401    m = m.then(config.modifier);
1402    Row(m).child((
1403        icon.unwrap_or(Box(Modifier::new())),
1404        Box(Modifier::new()
1405            .width(if has_icon { 12.0 } else { 0.0 })
1406            .fill_max_height()),
1407        Text(label)
1408            .color(content_color)
1409            .size(th.typography.label_large)
1410            .single_line(),
1411    ))
1412}
1413
1414/// Configuration for divider components.
1415#[derive(Clone, Debug)]
1416pub struct DividerConfig {
1417    pub modifier: Modifier,
1418    pub thickness: f32,
1419    pub color: Color,
1420}
1421
1422impl Default for DividerConfig {
1423    fn default() -> Self {
1424        Self {
1425            modifier: Modifier::new(),
1426            thickness: DividerDefaults::THICKNESS,
1427            color: DividerDefaults::color(),
1428        }
1429    }
1430}
1431
1432/// M3 Horizontal Divider - a thin 1dp line.
1433/// (Equivalent to Compose Material3's `HorizontalDivider`.)
1434pub fn HorizontalDivider(config: DividerConfig) -> View {
1435    Box(Modifier::new()
1436        .min_width(200.0)
1437        .height(config.thickness)
1438        .background(config.color)
1439        .then(config.modifier))
1440}
1441
1442#[deprecated(since = "0.19.5", note = "renamed to HorizontalDivider")]
1443pub fn Divider(config: DividerConfig) -> View {
1444    HorizontalDivider(config)
1445}
1446
1447/// M3 Vertical Divider - a thin 1dp vertical line.
1448pub fn VerticalDivider(config: DividerConfig) -> View {
1449    Box(Modifier::new()
1450        .width(config.thickness)
1451        .fill_max_height()
1452        .background(config.color)
1453        .then(config.modifier))
1454}
1455
1456/// Configuration for [`Badge`].
1457#[derive(Clone, Debug)]
1458pub struct BadgeConfig {
1459    pub modifier: Modifier,
1460    pub container_color: Color,
1461    pub content_color: Color,
1462}
1463
1464impl Default for BadgeConfig {
1465    fn default() -> Self {
1466        Self {
1467            modifier: Modifier::new(),
1468            container_color: BadgeDefaults::container_color(),
1469            content_color: BadgeDefaults::content_color(),
1470        }
1471    }
1472}
1473
1474/// M3 Badge - a small notification indicator. If `content` is `None`, shows a
1475/// small 6dp dot; otherwise shows the content inside a 16dp pill.
1476pub fn Badge(content: Option<View>, config: BadgeConfig) -> View {
1477    match content {
1478        None => Box(Modifier::new()
1479            .size(BadgeDefaults::DOT_SIZE, BadgeDefaults::DOT_SIZE)
1480            .background(config.container_color)
1481            .clip_rounded(BadgeDefaults::DOT_SIZE * 0.5)
1482            .flex_shrink(0.0)
1483            .then(config.modifier)),
1484        Some(view) => Box(Modifier::new()
1485            .min_width(BadgeDefaults::LABEL_MIN_WIDTH)
1486            .height(BadgeDefaults::LABEL_HEIGHT)
1487            .background(config.container_color)
1488            .clip_rounded(BadgeDefaults::LABEL_HEIGHT * 0.5)
1489            .padding_values(PaddingValues {
1490                left: 4.0,
1491                right: 4.0,
1492                top: 0.0,
1493                bottom: 0.0,
1494            })
1495            .align_items(AlignItems::CENTER)
1496            .justify_content(JustifyContent::CENTER)
1497            .flex_shrink(0.0)
1498            .then(config.modifier))
1499        .child(with_content_color(config.content_color, move || view)),
1500    }
1501}
1502
1503/// Configuration for [`BadgedBox`].
1504#[derive(Clone, Debug)]
1505pub struct BadgedBoxConfig {
1506    pub modifier: Modifier,
1507    /// Horizontal offset for the badge when it's a small dot.
1508    pub dot_offset_x: f32,
1509    /// Vertical offset for the badge when it's a small dot.
1510    pub dot_offset_y: f32,
1511    /// Horizontal offset for the badge when it has content.
1512    pub content_offset_x: f32,
1513    /// Vertical offset for the badge when it has content.
1514    pub content_offset_y: f32,
1515    /// When true, use `content_offset_*` (labeled badge). When false, use `dot_offset_*`.
1516    pub has_content: bool,
1517}
1518
1519impl Default for BadgedBoxConfig {
1520    fn default() -> Self {
1521        Self {
1522            modifier: Modifier::new(),
1523            dot_offset_x: BadgeDefaults::DOT_OFFSET_X,
1524            dot_offset_y: BadgeDefaults::DOT_OFFSET_Y,
1525            content_offset_x: BadgeDefaults::CONTENT_OFFSET_X,
1526            content_offset_y: BadgeDefaults::CONTENT_OFFSET_Y,
1527            has_content: false,
1528        }
1529    }
1530}
1531
1532/// Wraps `content` and shows a `badge` anchored to the top-end corner.
1533pub fn BadgedBox(badge: View, content: View, config: BadgedBoxConfig) -> View {
1534    let (top, right) = if config.has_content {
1535        (
1536            config.content_offset_y - BadgeDefaults::LABEL_HEIGHT, // 14 - 16 = -2
1537            config.content_offset_x - BadgeDefaults::LABEL_MIN_WIDTH, // 12 - 16 = -4
1538        )
1539    } else {
1540        (
1541            config.dot_offset_y - BadgeDefaults::DOT_SIZE, // 6 - 6 = 0
1542            config.dot_offset_x - BadgeDefaults::DOT_SIZE, // 6 - 6 = 0
1543        )
1544    };
1545
1546    Box(config.modifier.flex_shrink(0.0)).child((
1547        content,
1548        Box(Modifier::new()
1549            .absolute()
1550            .offset(None, Some(top), Some(right), None)
1551            .flex_shrink(0.0)
1552            .hit_passthrough())
1553        .child(badge),
1554    ))
1555}
1556
1557/// Colors for [`ListItem`] -> matches Compose Material3 `ListItemColors` with
1558/// 4 state groups (default, disabled, selected, dragged) × 6 slots each.
1559#[derive(Clone, Debug)]
1560pub struct ListItemColors {
1561    pub container_color: Color,
1562    pub headline_color: Color,
1563    pub supporting_color: Color,
1564    pub overline_color: Color,
1565    pub leading_icon_color: Color,
1566    pub trailing_icon_color: Color,
1567
1568    pub disabled_container_color: Color,
1569    pub disabled_headline_color: Color,
1570    pub disabled_supporting_color: Color,
1571    pub disabled_overline_color: Color,
1572    pub disabled_leading_icon_color: Color,
1573    pub disabled_trailing_icon_color: Color,
1574
1575    pub selected_container_color: Color,
1576    pub selected_headline_color: Color,
1577    pub selected_supporting_color: Color,
1578    pub selected_overline_color: Color,
1579    pub selected_leading_icon_color: Color,
1580    pub selected_trailing_icon_color: Color,
1581
1582    pub dragged_container_color: Color,
1583    pub dragged_headline_color: Color,
1584    pub dragged_supporting_color: Color,
1585    pub dragged_overline_color: Color,
1586    pub dragged_leading_icon_color: Color,
1587    pub dragged_trailing_icon_color: Color,
1588}
1589
1590impl ListItemColors {
1591    pub fn container(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
1592        if !enabled {
1593            self.disabled_container_color
1594        } else if dragged {
1595            self.dragged_container_color
1596        } else if selected {
1597            self.selected_container_color
1598        } else {
1599            self.container_color
1600        }
1601    }
1602    pub fn headline(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
1603        if !enabled {
1604            self.disabled_headline_color
1605        } else if dragged {
1606            self.dragged_headline_color
1607        } else if selected {
1608            self.selected_headline_color
1609        } else {
1610            self.headline_color
1611        }
1612    }
1613    pub fn supporting(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
1614        if !enabled {
1615            self.disabled_supporting_color
1616        } else if dragged {
1617            self.dragged_supporting_color
1618        } else if selected {
1619            self.selected_supporting_color
1620        } else {
1621            self.supporting_color
1622        }
1623    }
1624    pub fn overline(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
1625        if !enabled {
1626            self.disabled_overline_color
1627        } else if dragged {
1628            self.dragged_overline_color
1629        } else if selected {
1630            self.selected_overline_color
1631        } else {
1632            self.overline_color
1633        }
1634    }
1635    pub fn leading_icon(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
1636        if !enabled {
1637            self.disabled_leading_icon_color
1638        } else if dragged {
1639            self.dragged_leading_icon_color
1640        } else if selected {
1641            self.selected_leading_icon_color
1642        } else {
1643            self.leading_icon_color
1644        }
1645    }
1646    pub fn trailing_icon(&self, enabled: bool, selected: bool, dragged: bool) -> Color {
1647        if !enabled {
1648            self.disabled_trailing_icon_color
1649        } else if dragged {
1650            self.dragged_trailing_icon_color
1651        } else if selected {
1652            self.selected_trailing_icon_color
1653        } else {
1654            self.trailing_icon_color
1655        }
1656    }
1657}
1658
1659impl Default for ListItemColors {
1660    fn default() -> Self {
1661        Self {
1662            container_color: Color::TRANSPARENT,
1663            headline_color: ListItemDefaults::headline_color(),
1664            supporting_color: ListItemDefaults::supporting_color(),
1665            overline_color: ListItemDefaults::overline_color(),
1666            leading_icon_color: ListItemDefaults::leading_icon_color(),
1667            trailing_icon_color: ListItemDefaults::trailing_icon_color(),
1668            disabled_container_color: ListItemDefaults::disabled_container_color(),
1669            disabled_headline_color: ListItemDefaults::disabled_headline_color(),
1670            disabled_supporting_color: ListItemDefaults::disabled_supporting_color(),
1671            disabled_overline_color: ListItemDefaults::disabled_overline_color(),
1672            disabled_leading_icon_color: ListItemDefaults::disabled_leading_icon_color(),
1673            disabled_trailing_icon_color: ListItemDefaults::disabled_trailing_icon_color(),
1674            selected_container_color: ListItemDefaults::selected_container_color(),
1675            selected_headline_color: ListItemDefaults::selected_headline_color(),
1676            selected_supporting_color: ListItemDefaults::selected_supporting_color(),
1677            selected_overline_color: ListItemDefaults::selected_overline_color(),
1678            selected_leading_icon_color: ListItemDefaults::selected_leading_icon_color(),
1679            selected_trailing_icon_color: ListItemDefaults::selected_trailing_icon_color(),
1680            dragged_container_color: ListItemDefaults::dragged_container_color(),
1681            dragged_headline_color: ListItemDefaults::dragged_headline_color(),
1682            dragged_supporting_color: ListItemDefaults::dragged_supporting_color(),
1683            dragged_overline_color: ListItemDefaults::dragged_overline_color(),
1684            dragged_leading_icon_color: ListItemDefaults::dragged_leading_icon_color(),
1685            dragged_trailing_icon_color: ListItemDefaults::dragged_trailing_icon_color(),
1686        }
1687    }
1688}
1689
1690/// Configuration for [`ListItem`].
1691#[derive(Clone, Debug)]
1692pub struct ListItemConfig {
1693    pub modifier: Modifier,
1694    /// When false, renders disabled colors and suppresses clicks.
1695    pub enabled: bool,
1696    pub selected: bool,
1697    pub colors: ListItemColors,
1698    pub state_colors: StateColors,
1699    pub tonal_elevation: f32,
1700    pub shadow_elevation: f32,
1701    pub shape_radius: f32,
1702    /// Per-corner radii `[BL, BR, TR, TL]`. When set, overrides `shape_radius`.
1703    pub shape_radii: Option<[f32; 4]>,
1704    pub horizontal_padding: f32,
1705    pub trailing_padding: f32,
1706    pub one_line_height: f32,
1707    pub two_line_height: f32,
1708    pub three_line_height: f32,
1709    pub interaction_source: Option<MutableInteractionSource>,
1710}
1711
1712impl Default for ListItemConfig {
1713    fn default() -> Self {
1714        Self {
1715            modifier: Modifier::new(),
1716            enabled: true,
1717            selected: false,
1718            colors: ListItemColors::default(),
1719            state_colors: ListItemDefaults::state_colors_default(),
1720            tonal_elevation: 0.0,
1721            shadow_elevation: 0.0,
1722            shape_radius: 0.0,
1723            shape_radii: None,
1724            horizontal_padding: ListItemDefaults::HORIZONTAL_PADDING,
1725            trailing_padding: ListItemDefaults::TRAILING_PADDING,
1726            one_line_height: ListItemDefaults::ONE_LINE_HEIGHT,
1727            two_line_height: ListItemDefaults::TWO_LINE_HEIGHT,
1728            three_line_height: ListItemDefaults::THREE_LINE_HEIGHT,
1729            interaction_source: None,
1730        }
1731    }
1732}
1733
1734static LISTITEM_COUNTER: AtomicU64 = AtomicU64::new(0);
1735
1736/// M3 List Item - a single row in a list with optional leading/trailing content,
1737/// overline text, and click handling.
1738pub fn ListItem(
1739    headline: impl Into<String>,
1740    supporting_text: Option<String>,
1741    overline_text: Option<String>,
1742    leading: Option<View>,
1743    trailing: Option<View>,
1744    on_click: Option<Rc<dyn Fn()>>,
1745    on_long_click: Option<Rc<dyn Fn()>>,
1746    config: ListItemConfig,
1747) -> View {
1748    let th = theme();
1749    let is_enabled = config.enabled;
1750    let is_selected = config.selected;
1751    let c = &config.colors;
1752    let id = remember(|| LISTITEM_COUNTER.fetch_add(1, Ordering::Relaxed));
1753    let spec = th.motion.color;
1754
1755    let hd_col = animate_color(
1756        format!("li_hd_{}", id),
1757        c.headline(is_enabled, is_selected, false),
1758        spec,
1759    );
1760    let sp_col = animate_color(
1761        format!("li_sp_{}", id),
1762        c.supporting(is_enabled, is_selected, false),
1763        spec,
1764    );
1765    let ol_col = animate_color(
1766        format!("li_ol_{}", id),
1767        c.overline(is_enabled, is_selected, false),
1768        spec,
1769    );
1770    let ld_col = animate_color(
1771        format!("li_ld_{}", id),
1772        c.leading_icon(is_enabled, is_selected, false),
1773        spec,
1774    );
1775    let tr_col = animate_color(
1776        format!("li_tr_{}", id),
1777        c.trailing_icon(is_enabled, is_selected, false),
1778        spec,
1779    );
1780    let bg = animate_color(
1781        format!("li_bg_{}", id),
1782        c.container(is_enabled, is_selected, false),
1783        spec,
1784    );
1785
1786    let line_count = match (overline_text.is_some(), supporting_text.is_some()) {
1787        (true, true) => 3,
1788        (true, false) | (false, true) => 2,
1789        (false, false) => 1,
1790    };
1791    let min_h = match line_count {
1792        3 => config.three_line_height,
1793        2 => config.two_line_height,
1794        _ => config.one_line_height,
1795    };
1796    let top_bottom_padding = match line_count {
1797        3 => 12.0,
1798        _ => 8.0,
1799    };
1800
1801    let vert_align = if min_h >= config.three_line_height {
1802        AlignItems::START
1803    } else {
1804        AlignItems::CENTER
1805    };
1806
1807    let li_source: Rc<MutableInteractionSource> = config
1808        .interaction_source
1809        .clone()
1810        .map(Rc::new)
1811        .unwrap_or_else(|| remember(MutableInteractionSource::new));
1812
1813    let mut modifier = Modifier::new()
1814        .min_width(200.0)
1815        .min_height(min_h)
1816        .background(bg);
1817    match config.shape_radii {
1818        Some(r) => modifier = modifier.clip_rounded_radii(r),
1819        None => modifier = modifier.clip_rounded(config.shape_radius),
1820    }
1821    modifier = modifier
1822        .state_colors(config.state_colors)
1823        .padding_values(PaddingValues {
1824            left: config.horizontal_padding,
1825            right: config.trailing_padding,
1826            top: top_bottom_padding,
1827            bottom: top_bottom_padding,
1828        })
1829        .align_items(vert_align)
1830        .interaction_source(&*li_source)
1831        .then(config.modifier);
1832
1833    if config.tonal_elevation > 0.0 {
1834        modifier = modifier.state_elevation(StateElevation {
1835            default: config.tonal_elevation,
1836            hovered: config.tonal_elevation,
1837            pressed: config.tonal_elevation,
1838            disabled: 0.0,
1839        });
1840    }
1841    if config.shadow_elevation > 0.0 {
1842        modifier = modifier.shadow(config.shadow_elevation, 0.0);
1843    }
1844
1845    if on_click.is_some() || on_long_click.is_some() {
1846        modifier = modifier.clickable();
1847        if let Some(cb) = on_click {
1848            let cb = cb.clone();
1849            modifier = modifier.on_click(move || {
1850                if is_enabled {
1851                    cb();
1852                }
1853            });
1854        }
1855        if let Some(cb) = &on_long_click {
1856            let cb = cb.clone();
1857            modifier = modifier.on_long_click(move || {
1858                if is_enabled {
1859                    cb();
1860                }
1861            });
1862        }
1863    }
1864
1865    let wrap_icon = |color: Color, v: View| -> View { with_content_color(color, move || v) };
1866
1867    Row(modifier).child((
1868        leading
1869            .map(|v| {
1870                Box(Modifier::new().padding_values(PaddingValues {
1871                    left: 0.0,
1872                    right: 16.0,
1873                    top: 0.0,
1874                    bottom: 0.0,
1875                }))
1876                .child(wrap_icon(ld_col, v))
1877            })
1878            .unwrap_or(Box(Modifier::new())),
1879        Column(
1880            Modifier::new()
1881                .flex_grow(1.0)
1882                .justify_content(JustifyContent::CENTER),
1883        )
1884        .child((
1885            overline_text
1886                .map(|ot| {
1887                    Text(ot)
1888                        .color(ol_col)
1889                        .size(th.typography.label_small)
1890                        .single_line()
1891                })
1892                .unwrap_or(Box(Modifier::new())),
1893            Text(headline)
1894                .color(hd_col)
1895                .size(th.typography.body_large)
1896                .single_line(),
1897            supporting_text
1898                .map(|st| {
1899                    Text(st)
1900                        .color(sp_col)
1901                        .size(th.typography.body_medium)
1902                        .max_lines(2)
1903                        .overflow_ellipsize()
1904                })
1905                .unwrap_or(Box(Modifier::new())),
1906        )),
1907        trailing
1908            .map(|v| {
1909                Box(Modifier::new().padding_values(PaddingValues {
1910                    left: 16.0,
1911                    right: 0.0,
1912                    top: 0.0,
1913                    bottom: 0.0,
1914                }))
1915                .child(wrap_icon(tr_col, v))
1916            })
1917            .unwrap_or(Box(Modifier::new())),
1918    ))
1919}
1920
1921/// M3 Selectable List Item -> single-selection variant with `selected` state and
1922/// `Role::RadioButton` semantics.
1923pub fn SelectableListItem(
1924    headline: impl Into<String>,
1925    selected: bool,
1926    supporting_text: Option<String>,
1927    overline_text: Option<String>,
1928    leading: Option<View>,
1929    trailing: Option<View>,
1930    on_click: Option<Rc<dyn Fn()>>,
1931    mut config: ListItemConfig,
1932) -> View {
1933    config.selected = selected;
1934    let mut m = Modifier::new().semantics(Semantics::new(Role::RadioButton));
1935    m = m.then(config.modifier);
1936    config.modifier = m;
1937    ListItem(
1938        headline,
1939        supporting_text,
1940        overline_text,
1941        leading,
1942        trailing,
1943        on_click,
1944        None,
1945        config,
1946    )
1947}
1948
1949/// M3 Toggleable List Item -> multi-selection variant with `checked` state and
1950/// `Role::Checkbox` semantics. Clicking toggles the checked state.
1951pub fn ToggleableListItem(
1952    headline: impl Into<String>,
1953    checked: bool,
1954    on_checked_change: impl Fn(bool) + 'static,
1955    supporting_text: Option<String>,
1956    overline_text: Option<String>,
1957    leading: Option<View>,
1958    trailing: Option<View>,
1959    config: ListItemConfig,
1960) -> View {
1961    let mut cfg = config.clone();
1962    cfg.selected = checked;
1963    let cb = Rc::new(on_checked_change);
1964    let cb2 = cb.clone();
1965    let mut m = Modifier::new().semantics(Semantics::new(Role::Checkbox));
1966    m = m.then(cfg.modifier);
1967    cfg.modifier = m;
1968    ListItem(
1969        headline,
1970        supporting_text,
1971        overline_text,
1972        leading,
1973        trailing,
1974        Some(Rc::new(move || (cb2)(!checked))),
1975        None,
1976        cfg,
1977    )
1978}
1979
1980/// Compute per-index corner radii `[BL, BR, TR, TL]` for a segmented list item.
1981fn segmented_item_radii(index: usize, count: usize, r: f32) -> [f32; 4] {
1982    if count <= 1 {
1983        [r, r, r, r]
1984    } else if index == 0 {
1985        [0.0, 0.0, r, r]
1986    } else if index == count - 1 {
1987        [r, r, 0.0, 0.0]
1988    } else {
1989        [0.0, 0.0, 0.0, 0.0]
1990    }
1991}
1992
1993/// M3 Segmented List Item -> clickable variant with segmented (per-index) corner radii.
1994pub fn SegmentedListItem(
1995    index: usize,
1996    count: usize,
1997    headline: impl Into<String>,
1998    supporting_text: Option<String>,
1999    overline_text: Option<String>,
2000    leading: Option<View>,
2001    trailing: Option<View>,
2002    on_click: Option<Rc<dyn Fn()>>,
2003    mut config: ListItemConfig,
2004) -> View {
2005    config.shape_radii = Some(segmented_item_radii(index, count, config.shape_radius));
2006    ListItem(
2007        headline,
2008        supporting_text,
2009        overline_text,
2010        leading,
2011        trailing,
2012        on_click,
2013        None,
2014        config,
2015    )
2016}
2017
2018/// M3 Segmented List Item -> single-selection variant.
2019pub fn SegmentedSelectableListItem(
2020    index: usize,
2021    count: usize,
2022    headline: impl Into<String>,
2023    selected: bool,
2024    supporting_text: Option<String>,
2025    overline_text: Option<String>,
2026    leading: Option<View>,
2027    trailing: Option<View>,
2028    on_click: Option<Rc<dyn Fn()>>,
2029    mut config: ListItemConfig,
2030) -> View {
2031    config.selected = selected;
2032    config.shape_radii = Some(segmented_item_radii(index, count, config.shape_radius));
2033    let mut m = Modifier::new().semantics(Semantics::new(Role::RadioButton));
2034    m = m.then(config.modifier);
2035    config.modifier = m;
2036    ListItem(
2037        headline,
2038        supporting_text,
2039        overline_text,
2040        leading,
2041        trailing,
2042        on_click,
2043        None,
2044        config,
2045    )
2046}
2047
2048/// M3 Segmented List Item -> multi-selection (toggleable) variant.
2049pub fn SegmentedToggleableListItem(
2050    index: usize,
2051    count: usize,
2052    headline: impl Into<String>,
2053    checked: bool,
2054    on_checked_change: impl Fn(bool) + 'static,
2055    supporting_text: Option<String>,
2056    overline_text: Option<String>,
2057    leading: Option<View>,
2058    trailing: Option<View>,
2059    config: ListItemConfig,
2060) -> View {
2061    let mut cfg = config.clone();
2062    cfg.selected = checked;
2063    cfg.shape_radii = Some(segmented_item_radii(index, count, cfg.shape_radius));
2064    let cb2 = Rc::new(on_checked_change);
2065    let mut m = Modifier::new().semantics(Semantics::new(Role::Checkbox));
2066    m = m.then(cfg.modifier);
2067    cfg.modifier = m;
2068    ListItem(
2069        headline,
2070        supporting_text,
2071        overline_text,
2072        leading,
2073        trailing,
2074        Some(Rc::new(move || (cb2)(!checked))),
2075        None,
2076        cfg,
2077    )
2078}
2079
2080/// A single tab definition for use with `TabRow`.
2081pub struct Tab {
2082    pub label: String,
2083    pub icon: Option<View>,
2084    pub on_click: Rc<dyn Fn()>,
2085    pub enabled: bool,
2086    pub interaction_source: Option<MutableInteractionSource>,
2087}
2088
2089/// Configuration for [`TabRow`].
2090#[derive(Clone, Debug)]
2091pub struct TabRowConfig {
2092    pub modifier: Modifier,
2093    pub container_color: Color,
2094    pub selected_content_color: Color,
2095    pub unselected_content_color: Color,
2096    pub indicator_color: Color,
2097    pub height: f32,
2098    pub indicator_height: f32,
2099}
2100
2101impl Default for TabRowConfig {
2102    fn default() -> Self {
2103        Self {
2104            modifier: Modifier::new(),
2105            container_color: TabDefaults::container_color(),
2106            selected_content_color: TabDefaults::selected_content_color(),
2107            unselected_content_color: TabDefaults::unselected_content_color(),
2108            indicator_color: TabDefaults::indicator_color(),
2109            height: TabDefaults::HEIGHT,
2110            indicator_height: TabDefaults::INDICATOR_HEIGHT,
2111        }
2112    }
2113}
2114
2115static TABROW_COUNTER: AtomicU64 = AtomicU64::new(0);
2116
2117/// M3 Tab Row -> a horizontal row of tabs with per-tab animated-height indicators.
2118/// Text colors animate with DefaultEffects (spring_crit 40.0).
2119/// Indicator height animates with DefaultEffects (spring_crit 40.0).
2120pub fn TabRow(selected_index: usize, tabs: Vec<Tab>, config: TabRowConfig) -> View {
2121    let th = theme();
2122    let id = remember(|| TABROW_COUNTER.fetch_add(1, Ordering::Relaxed));
2123    let default_effects = AnimationSpec::spring_crit(40.0);
2124    Column(Modifier::new().fill_max_width().then(config.modifier)).child((
2125        Row(Modifier::new()
2126            .fill_max_width()
2127            .height(config.height)
2128            .background(config.container_color)
2129            .semantics(Semantics::new(Role::Container).with_selectable_group()))
2130        .child(
2131            tabs.into_iter()
2132                .enumerate()
2133                .map(|(i, tab)| {
2134                    let selected = i == selected_index;
2135                    let is_enabled = tab.enabled;
2136                    let color = animate_color(
2137                        format!("tab_clr_{}_{}", id, i),
2138                        if selected {
2139                            config.selected_content_color
2140                        } else {
2141                            config.unselected_content_color
2142                        },
2143                        default_effects,
2144                    );
2145                    let indicator_h = animate_f32(
2146                        format!("tab_ind_h_{}_{}", id, i),
2147                        if selected {
2148                            config.indicator_height
2149                        } else {
2150                            0.0
2151                        },
2152                        default_effects,
2153                    );
2154                    let cb = tab.on_click.clone();
2155                    let tab_source: Rc<MutableInteractionSource> = tab
2156                        .interaction_source
2157                        .clone()
2158                        .map(Rc::new)
2159                        .unwrap_or_else(|| remember(MutableInteractionSource::new));
2160
2161                    let mut tab_m = Modifier::new()
2162                        .flex_grow(1.0)
2163                        .fill_max_height()
2164                        .interaction_source(&*tab_source)
2165                        .align_items(AlignItems::CENTER)
2166                        .justify_content(JustifyContent::CENTER)
2167                        .state_colors(StateColors {
2168                            default: Color::TRANSPARENT,
2169                            hovered: th.on_surface.with_alpha_f32(0.08),
2170                            pressed: th.on_surface.with_alpha_f32(0.12),
2171                            disabled: Color::TRANSPARENT,
2172                        })
2173                        .semantics(Semantics::new(Role::Tab).with_label(&tab.label));
2174
2175                    if is_enabled {
2176                        tab_m = tab_m.clickable().on_click(move || cb());
2177                    }
2178
2179                    Column(tab_m).child((
2180                        tab.icon.unwrap_or(Box(Modifier::new())),
2181                        Text(tab.label)
2182                            .color(color)
2183                            .size(th.typography.title_small)
2184                            .single_line(),
2185                        Box(Modifier::new()
2186                            .fill_max_width()
2187                            .height(indicator_h)
2188                            .background(config.indicator_color)
2189                            .clip_rounded(TabDefaults::INDICATOR_CORNER)),
2190                    ))
2191                })
2192                .collect::<Vec<_>>(),
2193        ),
2194        // Divider
2195        Box(Modifier::new()
2196            .fill_max_width()
2197            .height(1.0)
2198            .background(th.outline_variant)),
2199    ))
2200}
2201
2202/// Configuration for a single segment in [`SegmentedButton`].
2203#[derive(Clone)]
2204pub struct SegmentConfig {
2205    pub label: String,
2206    pub icon: Option<View>,
2207    pub on_click: Rc<dyn Fn()>,
2208    pub enabled: bool,
2209    pub interaction_source: Option<MutableInteractionSource>,
2210}
2211
2212impl Default for SegmentConfig {
2213    fn default() -> Self {
2214        Self {
2215            label: String::new(),
2216            icon: None,
2217            on_click: Rc::new(|| {}),
2218            enabled: true,
2219            interaction_source: None,
2220        }
2221    }
2222}
2223
2224/// Configuration for [`SegmentedButton`].
2225#[derive(Clone, Debug)]
2226pub struct SegmentedButtonConfig {
2227    pub modifier: Modifier,
2228    pub border_color: Color,
2229    pub selected_container_color: Color,
2230    pub selected_content_color: Color,
2231    pub unselected_content_color: Color,
2232    pub state_colors: StateColors,
2233    pub height: f32,
2234    pub shape_radius: f32,
2235    pub content_padding: PaddingValues,
2236}
2237
2238impl Default for SegmentedButtonConfig {
2239    fn default() -> Self {
2240        Self {
2241            modifier: Modifier::new(),
2242            border_color: SegmentedButtonDefaults::border_color(),
2243            selected_container_color: SegmentedButtonDefaults::selected_container_color(),
2244            selected_content_color: SegmentedButtonDefaults::selected_content_color(),
2245            unselected_content_color: SegmentedButtonDefaults::unselected_content_color(),
2246            state_colors: SegmentedButtonDefaults::state_colors_default(),
2247            height: SegmentedButtonDefaults::HEIGHT,
2248            shape_radius: SegmentedButtonDefaults::SHAPE_RADIUS,
2249            content_padding: SegmentedButtonDefaults::CONTENT_PADDING,
2250        }
2251    }
2252}
2253
2254static SEGBUTTON_COUNTER: AtomicU64 = AtomicU64::new(0);
2255
2256/// M3 Segmented Button - a row of toggle segments. `selected` contains the
2257/// indices of selected segments (single-select: pass a single-element set).
2258/// Each segment is shaped independently: first has rounded left corners,
2259/// last has rounded right corners, middle segments are rectangular.
2260pub fn SegmentedButton(
2261    selected: &[usize],
2262    segments: Vec<SegmentConfig>,
2263    config: SegmentedButtonConfig,
2264) -> View {
2265    let th = theme();
2266    let count = segments.len();
2267    let id = remember(|| SEGBUTTON_COUNTER.fetch_add(1, Ordering::Relaxed));
2268    let spec = th.motion.color;
2269    let shape_r = config.shape_radius;
2270
2271    // corner order: [BL, BR, TR, TL]
2272    let segment_radii = |i: usize| -> [f32; 4] {
2273        if count == 1 {
2274            [shape_r, shape_r, shape_r, shape_r]
2275        } else if i == 0 {
2276            [shape_r, 0.0, 0.0, shape_r]
2277        } else if i == count - 1 {
2278            [0.0, shape_r, shape_r, 0.0]
2279        } else {
2280            [0.0, 0.0, 0.0, 0.0]
2281        }
2282    };
2283
2284    // Outer border wraps the entire group. Internal dividers are inside each segment Row.
2285    Row(Modifier::new()
2286        .height(config.height)
2287        .border(1.0, config.border_color, shape_r)
2288        .then(config.modifier))
2289    .child(
2290        segments
2291            .into_iter()
2292            .enumerate()
2293            .map(|(i, seg)| {
2294                let is_selected = selected.contains(&i);
2295
2296                let bg = animate_color(
2297                    format!("sb_bg_{}_{}", id, i),
2298                    if is_selected {
2299                        config.selected_container_color
2300                    } else {
2301                        Color::TRANSPARENT
2302                    },
2303                    spec,
2304                );
2305                let fg = animate_color(
2306                    format!("sb_fg_{}_{}", id, i),
2307                    if is_selected {
2308                        config.selected_content_color
2309                    } else {
2310                        config.unselected_content_color
2311                    },
2312                    spec,
2313                );
2314
2315                let cb = seg.on_click.clone();
2316                let radii = segment_radii(i);
2317                let is_enabled = seg.enabled;
2318                let seg_source: Rc<MutableInteractionSource> = seg
2319                    .interaction_source
2320                    .clone()
2321                    .map(Rc::new)
2322                    .unwrap_or_else(|| remember(MutableInteractionSource::new));
2323
2324                let state_colors = config.state_colors;
2325                let content_modifier = Modifier::new()
2326                    .flex_grow(1.0)
2327                    .fill_max_height()
2328                    .clip_rounded_radii(radii)
2329                    .background(bg)
2330                    .state_colors(state_colors)
2331                    .interaction_source(&*seg_source)
2332                    .align_items(AlignItems::CENTER)
2333                    .justify_content(JustifyContent::CENTER)
2334                    .padding_values(config.content_padding);
2335
2336                let content_modifier = if is_enabled {
2337                    content_modifier.clickable().on_click(move || cb())
2338                } else {
2339                    content_modifier
2340                };
2341
2342                Row(Modifier::new().flex_grow(1.0).fill_max_height()).child((
2343                    Row(content_modifier).child((
2344                        seg.icon.unwrap_or(Box(Modifier::new())),
2345                        Text(seg.label)
2346                            .color(fg)
2347                            .size(th.typography.label_large)
2348                            .single_line(),
2349                    )),
2350                    if i < count - 1 {
2351                        Box(Modifier::new()
2352                            .width(1.0)
2353                            .fill_max_height()
2354                            .background(th.outline))
2355                    } else {
2356                        Box(Modifier::new())
2357                    },
2358                ))
2359            })
2360            .collect::<Vec<_>>(),
2361    )
2362}
2363
2364/// Configuration for [`CircularProgressIndicator`].
2365#[derive(Clone, Debug)]
2366pub struct CircularProgressIndicatorConfig {
2367    pub modifier: Modifier,
2368    pub color: Color,
2369    pub track_color: Color,
2370    pub stroke_width: f32,
2371    pub stroke_cap: StrokeCap,
2372    pub gap_size: f32,
2373}
2374
2375impl Default for CircularProgressIndicatorConfig {
2376    fn default() -> Self {
2377        Self {
2378            modifier: Modifier::new(),
2379            color: ProgressIndicatorDefaults::circular_color(),
2380            track_color: ProgressIndicatorDefaults::circular_track_color(),
2381            stroke_width: ProgressIndicatorDefaults::CIRCULAR_STROKE_WIDTH,
2382            stroke_cap: StrokeCap::Round,
2383            gap_size: 0.0,
2384        }
2385    }
2386}
2387
2388/// M3 Circular Progress Indicator.
2389///
2390/// Determinate (`Some(0..1)`): draws arc from 12 o'clock clockwise.
2391/// Indeterminate (`None`): animates a spinning 270° arc.
2392pub fn CircularProgressIndicator(
2393    value: Option<f32>,
2394    config: CircularProgressIndicatorConfig,
2395) -> View {
2396    let sz = dp_to_px(ProgressIndicatorDefaults::CIRCULAR_INDICATOR_SIZE);
2397    let stroke_px = dp_to_px(config.stroke_width);
2398    let val = value.map(|v| v.clamp(0.0, 1.0));
2399
2400    // Three concurrent animations matching Compose Material3 indeterminate spec:
2401    //   1. Global rotation -> 1080° linear over 6000ms
2402    //   2. Additional rotation -> 90° stepped jumps with EmphasizedDecelerate
2403    //   3. Sweep -> oscillates 0.1 → 0.87 → 0.1 over 6000ms
2404    let (global_rotation, additional_rotation, sweep_val) = if value.is_none() {
2405        let shared = remember_state_with_key("circ_ind_shared", || {
2406            let mut a = AnimatedValue::new(
2407                0.0f32,
2408                AnimationSpec::tween(Duration::from_millis(6000), Easing::Linear)
2409                    .repeated(RepeatableSpec::infinite()),
2410            );
2411            a.set_target(1.0);
2412            a
2413        });
2414        let mut s = shared.borrow_mut();
2415        s.update();
2416        let t = *s.get();
2417        drop(s);
2418
2419        let gv = t * 1080.0;
2420
2421        let emph = Easing::Custom(CubicBezier::new(0.05, 0.7, 0.1, 1.0));
2422        let add_kf = remember_state_with_key("circ_ind_add_kf", || KeyframesSpec {
2423            keyframes: vec![
2424                (0.0, 0.0, None),
2425                (0.05, 90.0, Some(emph)),
2426                (0.25, 90.0, None),
2427                (0.30, 180.0, None),
2428                (0.50, 180.0, None),
2429                (0.55, 270.0, None),
2430                (0.75, 270.0, None),
2431                (0.80, 360.0, None),
2432                (1.0, 360.0, None),
2433            ],
2434        });
2435        let av = add_kf.borrow().evaluate(t);
2436
2437        let std_dec = Easing::Custom(CubicBezier::new(0.2, 0.0, 0.0, 1.0));
2438        let sweep_kf = remember_state_with_key("circ_ind_sweep_kf", || KeyframesSpec {
2439            keyframes: vec![
2440                (0.0, 0.1, None),
2441                (0.5, 0.87, Some(std_dec)),
2442                (1.0, 0.1, None),
2443            ],
2444        });
2445        let sv = sweep_kf.borrow().evaluate(t);
2446
2447        (gv, av, sv)
2448    } else {
2449        (0.0, 0.0, 0.0)
2450    };
2451
2452    // Pre-compute gap angular size in radians
2453    let indicator_size_dp = ProgressIndicatorDefaults::CIRCULAR_INDICATOR_SIZE;
2454    let adjusted_gap_dp = if config.stroke_cap == StrokeCap::Butt {
2455        config.gap_size
2456    } else {
2457        config.gap_size + config.stroke_width
2458    };
2459    let circle_dia_dp = indicator_size_dp - config.stroke_width;
2460    let gap_sweep_rad = 2.0 * adjusted_gap_dp / circle_dia_dp;
2461
2462    Box(Modifier::new().size(sz, sz).then(config.modifier).painter(
2463        move |scene: &mut Scene, rect: Rect, alpha: f32| {
2464            let mul_c = |c: Color| {
2465                Color(
2466                    c.0,
2467                    c.1,
2468                    c.2,
2469                    ((c.3 as f32) * alpha).clamp(0.0, 255.0) as u8,
2470                )
2471            };
2472            let cx = rect.x + rect.w * 0.5;
2473            let cy = rect.y + rect.h * 0.5;
2474            let r = (rect.w.min(rect.h)) * 0.5 - stroke_px * 0.5;
2475            let circle = Rect {
2476                x: cx - r,
2477                y: cy - r,
2478                w: r * 2.0,
2479                h: r * 2.0,
2480            };
2481
2482            match val {
2483                Some(p) => {
2484                    let sweep_rad = p * std::f32::consts::TAU;
2485                    let start_angle = -std::f32::consts::FRAC_PI_2;
2486                    let effective_gap = gap_sweep_rad.min(sweep_rad);
2487
2488                    // Indicator arc
2489                    if p > 0.0 {
2490                        scene.nodes.push(SceneNode::Arc {
2491                            rect: circle,
2492                            start_angle,
2493                            sweep_angle: sweep_rad,
2494                            stroke_width: stroke_px,
2495                            color: mul_c(config.color),
2496                            cap: config.stroke_cap,
2497                        });
2498                    }
2499
2500                    // Track arc (with gap from indicator)
2501                    let track_start = start_angle + sweep_rad + effective_gap;
2502                    let track_sweep = std::f32::consts::TAU - sweep_rad - 2.0 * effective_gap;
2503                    if track_sweep > 0.0 {
2504                        scene.nodes.push(SceneNode::Arc {
2505                            rect: circle,
2506                            start_angle: track_start,
2507                            sweep_angle: track_sweep,
2508                            stroke_width: stroke_px,
2509                            color: mul_c(config.track_color),
2510                            cap: config.stroke_cap,
2511                        });
2512                    }
2513                }
2514                None => {
2515                    let radians =
2516                        (global_rotation + additional_rotation) * std::f32::consts::PI / 180.0;
2517                    let start_angle = -std::f32::consts::FRAC_PI_2 + radians;
2518                    let sweep_rad = sweep_val * std::f32::consts::TAU;
2519                    let effective_gap = gap_sweep_rad.min(sweep_rad);
2520
2521                    // Indicator arc
2522                    scene.nodes.push(SceneNode::Arc {
2523                        rect: circle,
2524                        start_angle,
2525                        sweep_angle: sweep_rad,
2526                        stroke_width: stroke_px,
2527                        color: mul_c(config.color),
2528                        cap: config.stroke_cap,
2529                    });
2530
2531                    // Track arc (with gap from indicator)
2532                    let track_start = start_angle + sweep_rad + effective_gap;
2533                    let track_sweep = std::f32::consts::TAU - sweep_rad - 2.0 * effective_gap;
2534                    if track_sweep > 0.0 {
2535                        scene.nodes.push(SceneNode::Arc {
2536                            rect: circle,
2537                            start_angle: track_start,
2538                            sweep_angle: track_sweep,
2539                            stroke_width: stroke_px,
2540                            color: mul_c(config.track_color),
2541                            cap: config.stroke_cap,
2542                        });
2543                    }
2544                }
2545            }
2546        },
2547    ))
2548    .semantics(Semantics {
2549        role: Role::ProgressBar,
2550        label: None,
2551        focused: false,
2552        enabled: true,
2553        selectable_group: false,
2554    })
2555}
2556
2557/// Configuration for [`LinearProgressIndicator`].
2558#[derive(Clone, Debug)]
2559pub struct LinearProgressIndicatorConfig {
2560    pub modifier: Modifier,
2561    pub color: Color,
2562    pub track_color: Color,
2563    /// Stroke cap style for the indicator ends. Default: `StrokeCap::Round`
2564    pub stroke_cap: StrokeCap,
2565    /// Gap between indicator and track, in dp.
2566    pub gap_size: f32,
2567    /// Diameter of the stop indicator dot, in dp.
2568    pub stop_size: f32,
2569}
2570
2571impl Default for LinearProgressIndicatorConfig {
2572    fn default() -> Self {
2573        Self {
2574            modifier: Modifier::new(),
2575            color: ProgressIndicatorDefaults::linear_color(),
2576            track_color: ProgressIndicatorDefaults::linear_track_color(),
2577            stroke_cap: StrokeCap::Round,
2578            gap_size: ProgressIndicatorDefaults::LINEAR_INDICATOR_GAP_SIZE,
2579            stop_size: ProgressIndicatorDefaults::LINEAR_TRACK_STOP_SIZE,
2580        }
2581    }
2582}
2583
2584/// M3 Linear Progress Indicator.
2585///
2586/// Pass `LinearProgressIndicatorConfig::default()` for standard M3 appearance,
2587/// or override individual fields via struct-update syntax.
2588pub fn LinearProgressIndicator(value: Option<f32>, config: LinearProgressIndicatorConfig) -> View {
2589    Box(Modifier::new()
2590        .fill_max_width()
2591        .height(ProgressIndicatorDefaults::LINEAR_INDICATOR_HEIGHT)
2592        .then(config.modifier)
2593        .painter(move |scene: &mut Scene, rect: Rect, alpha: f32| {
2594            let mul_c = |c: Color| {
2595                Color(
2596                    c.0,
2597                    c.1,
2598                    c.2,
2599                    ((c.3 as f32) * alpha).clamp(0.0, 255.0) as u8,
2600                )
2601            };
2602            let track_h = rect.h;
2603            let corner = track_h * 0.5;
2604            let dot_r = dp_to_px(config.stop_size) * 0.5;
2605            let cy = rect.y + rect.h * 0.5;
2606            let t = value.unwrap_or(0.0).clamp(0.0, 1.0);
2607
2608            let cap_radius = if config.stroke_cap == StrokeCap::Butt {
2609                0.0
2610            } else {
2611                corner
2612            };
2613
2614            let gap = dp_to_px(config.gap_size)
2615                - if config.stroke_cap == StrokeCap::Butt {
2616                    0.0
2617                } else {
2618                    cap_radius
2619                };
2620
2621            let cap_ofs = cap_radius;
2622            let ind_end = (t * rect.w).clamp(cap_ofs, rect.w - cap_ofs);
2623            let ind_w = (ind_end - cap_ofs).max(0.0);
2624
2625            // Indicator (active portion from left)
2626            if t > 0.0 && ind_w > 0.0 {
2627                scene.nodes.push(SceneNode::Rect {
2628                    rect: Rect {
2629                        x: rect.x + cap_ofs,
2630                        y: cy - corner,
2631                        w: ind_w,
2632                        h: track_h,
2633                    },
2634                    brush: Brush::Solid(mul_c(config.color)),
2635                    radius: [cap_radius; 4],
2636                });
2637            }
2638
2639            // Track (inactive portion after gap)
2640            let track_start = (rect.x + ind_end + gap).min(rect.x + rect.w);
2641            let track_w = (rect.x + rect.w - track_start).max(0.0);
2642            if t < 1.0 && track_w > 0.0 {
2643                let track_left = track_start + cap_ofs;
2644                let track_right = rect.x + rect.w;
2645                if track_right > track_left {
2646                    scene.nodes.push(SceneNode::Rect {
2647                        rect: Rect {
2648                            x: track_left,
2649                            y: cy - corner,
2650                            w: track_right - track_left,
2651                            h: track_h,
2652                        },
2653                        brush: Brush::Solid(mul_c(config.track_color)),
2654                        radius: [cap_radius; 4],
2655                    });
2656                }
2657            }
2658
2659            // Stop indicator at right end circle
2660            {
2661                let sx = rect.x + rect.w - dot_r;
2662                scene.nodes.push(SceneNode::Ellipse {
2663                    rect: Rect {
2664                        x: sx - dot_r,
2665                        y: cy - dot_r,
2666                        w: dot_r * 2.0,
2667                        h: dot_r * 2.0,
2668                    },
2669                    brush: Brush::Solid(mul_c(config.color)),
2670                });
2671            }
2672        }))
2673    .semantics(Semantics {
2674        role: Role::ProgressBar,
2675        label: None,
2676        focused: false,
2677        enabled: true,
2678        selectable_group: false,
2679    })
2680}
2681
2682/// Color slots for text fields -> matches Compose Material3 `TextFieldColors`.
2683/// All 42 color fields (focused/unfocused/disabled/error variants of each slot).
2684#[allow(dead_code)]
2685#[derive(Clone, Debug)]
2686pub struct TextFieldColors {
2687    pub focused_text_color: Color,
2688    pub unfocused_text_color: Color,
2689    pub disabled_text_color: Color,
2690    pub error_text_color: Color,
2691    pub focused_container_color: Color,
2692    pub unfocused_container_color: Color,
2693    pub disabled_container_color: Color,
2694    pub error_container_color: Color,
2695    pub cursor_color: Color,
2696    pub error_cursor_color: Color,
2697    pub focused_indicator_color: Color,
2698    pub unfocused_indicator_color: Color,
2699    pub disabled_indicator_color: Color,
2700    pub error_indicator_color: Color,
2701    pub focused_leading_icon_color: Color,
2702    pub unfocused_leading_icon_color: Color,
2703    pub disabled_leading_icon_color: Color,
2704    pub error_leading_icon_color: Color,
2705    pub focused_trailing_icon_color: Color,
2706    pub unfocused_trailing_icon_color: Color,
2707    pub disabled_trailing_icon_color: Color,
2708    pub error_trailing_icon_color: Color,
2709    pub focused_label_color: Color,
2710    pub unfocused_label_color: Color,
2711    pub disabled_label_color: Color,
2712    pub error_label_color: Color,
2713    pub focused_placeholder_color: Color,
2714    pub unfocused_placeholder_color: Color,
2715    pub disabled_placeholder_color: Color,
2716    pub error_placeholder_color: Color,
2717    pub focused_supporting_text_color: Color,
2718    pub unfocused_supporting_text_color: Color,
2719    pub disabled_supporting_text_color: Color,
2720    pub error_supporting_text_color: Color,
2721    pub focused_prefix_color: Color,
2722    pub unfocused_prefix_color: Color,
2723    pub disabled_prefix_color: Color,
2724    pub error_prefix_color: Color,
2725    pub focused_suffix_color: Color,
2726    pub unfocused_suffix_color: Color,
2727    pub disabled_suffix_color: Color,
2728    pub error_suffix_color: Color,
2729}
2730
2731#[allow(dead_code)]
2732impl TextFieldColors {
2733    pub fn text_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
2734        if !enabled {
2735            self.disabled_text_color
2736        } else if is_error {
2737            self.error_text_color
2738        } else if focused {
2739            self.focused_text_color
2740        } else {
2741            self.unfocused_text_color
2742        }
2743    }
2744    pub fn container_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
2745        if !enabled {
2746            self.disabled_container_color
2747        } else if is_error {
2748            self.error_container_color
2749        } else if focused {
2750            self.focused_container_color
2751        } else {
2752            self.unfocused_container_color
2753        }
2754    }
2755    pub fn cursor_color(&self, is_error: bool) -> Color {
2756        if is_error {
2757            self.error_cursor_color
2758        } else {
2759            self.cursor_color
2760        }
2761    }
2762    pub fn indicator_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
2763        if !enabled {
2764            self.disabled_indicator_color
2765        } else if is_error {
2766            self.error_indicator_color
2767        } else if focused {
2768            self.focused_indicator_color
2769        } else {
2770            self.unfocused_indicator_color
2771        }
2772    }
2773    pub fn leading_icon_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
2774        if !enabled {
2775            self.disabled_leading_icon_color
2776        } else if is_error {
2777            self.error_leading_icon_color
2778        } else if focused {
2779            self.focused_leading_icon_color
2780        } else {
2781            self.unfocused_leading_icon_color
2782        }
2783    }
2784    pub fn trailing_icon_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
2785        if !enabled {
2786            self.disabled_trailing_icon_color
2787        } else if is_error {
2788            self.error_trailing_icon_color
2789        } else if focused {
2790            self.focused_trailing_icon_color
2791        } else {
2792            self.unfocused_trailing_icon_color
2793        }
2794    }
2795    pub fn label_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
2796        if !enabled {
2797            self.disabled_label_color
2798        } else if is_error {
2799            self.error_label_color
2800        } else if focused {
2801            self.focused_label_color
2802        } else {
2803            self.unfocused_label_color
2804        }
2805    }
2806    pub fn placeholder_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
2807        if !enabled {
2808            self.disabled_placeholder_color
2809        } else if is_error {
2810            self.error_placeholder_color
2811        } else if focused {
2812            self.focused_placeholder_color
2813        } else {
2814            self.unfocused_placeholder_color
2815        }
2816    }
2817    pub fn supporting_text_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
2818        if !enabled {
2819            self.disabled_supporting_text_color
2820        } else if is_error {
2821            self.error_supporting_text_color
2822        } else if focused {
2823            self.focused_supporting_text_color
2824        } else {
2825            self.unfocused_supporting_text_color
2826        }
2827    }
2828}
2829
2830/// Default values for text field colors.
2831pub struct TextFieldDefaults;
2832
2833impl TextFieldDefaults {
2834    /// Default minimum height for a filled TextField (56dp matches M3 spec).
2835    pub const MIN_HEIGHT: f32 = 56.0;
2836    /// Default minimum width for a filled TextField (280dp matches M3 spec).
2837    pub const MIN_WIDTH: f32 = 280.0;
2838
2839    pub fn colors() -> TextFieldColors {
2840        let th = theme();
2841        TextFieldColors {
2842            focused_text_color: th.on_surface,
2843            unfocused_text_color: th.on_surface,
2844            disabled_text_color: th.on_surface.with_alpha_f32(0.38),
2845            error_text_color: th.on_surface,
2846            focused_container_color: th.surface_container_highest,
2847            unfocused_container_color: th.surface_container_highest,
2848            disabled_container_color: th.on_surface.with_alpha_f32(0.04),
2849            error_container_color: th.surface_container_highest,
2850            cursor_color: th.primary,
2851            error_cursor_color: th.error,
2852            focused_indicator_color: th.primary,
2853            unfocused_indicator_color: th.on_surface_variant,
2854            disabled_indicator_color: th.on_surface.with_alpha_f32(0.12),
2855            error_indicator_color: th.error,
2856            focused_leading_icon_color: th.on_surface_variant,
2857            unfocused_leading_icon_color: th.on_surface_variant,
2858            disabled_leading_icon_color: th.on_surface.with_alpha_f32(0.38),
2859            error_leading_icon_color: th.error,
2860            focused_trailing_icon_color: th.on_surface_variant,
2861            unfocused_trailing_icon_color: th.on_surface_variant,
2862            disabled_trailing_icon_color: th.on_surface.with_alpha_f32(0.38),
2863            error_trailing_icon_color: th.error,
2864            focused_label_color: th.primary,
2865            unfocused_label_color: th.on_surface_variant,
2866            disabled_label_color: th.on_surface.with_alpha_f32(0.38),
2867            error_label_color: th.error,
2868            focused_placeholder_color: th.on_surface_variant,
2869            unfocused_placeholder_color: th.on_surface_variant,
2870            disabled_placeholder_color: th.on_surface.with_alpha_f32(0.38),
2871            error_placeholder_color: th.error,
2872            focused_supporting_text_color: th.on_surface_variant,
2873            unfocused_supporting_text_color: th.on_surface_variant,
2874            disabled_supporting_text_color: th.on_surface.with_alpha_f32(0.38),
2875            error_supporting_text_color: th.error,
2876            focused_prefix_color: th.on_surface,
2877            unfocused_prefix_color: th.on_surface,
2878            disabled_prefix_color: th.on_surface.with_alpha_f32(0.38),
2879            error_prefix_color: th.on_surface,
2880            focused_suffix_color: th.on_surface,
2881            unfocused_suffix_color: th.on_surface,
2882            disabled_suffix_color: th.on_surface.with_alpha_f32(0.38),
2883            error_suffix_color: th.on_surface,
2884        }
2885    }
2886}
2887
2888/// Configuration for an `OutlinedTextField`.
2889#[derive(Clone)]
2890pub struct OutlinedTextFieldConfig {
2891    /// Floating label shown above the input when the field has text or is focused.
2892    /// When set, this acts as the visual placeholder (the TextField's own placeholder
2893    /// is suppressed). When the label floats, it animates to the top border.
2894    pub label: Option<String>,
2895    /// Placeholder text shown inside the TextField when empty and unfocused.
2896    /// Only shown when `label` is `None`; when a label is present the label
2897    /// itself serves as the visual placeholder.
2898    pub placeholder: Option<String>,
2899    /// Icon displayed at the start of the input.
2900    pub leading_icon: Option<View>,
2901    /// Icon displayed at the end of the input.
2902    pub trailing_icon: Option<View>,
2903    /// If true, Enter submits; if false, Enter inserts a newline.
2904    pub single_line: bool,
2905    /// If true, border and label color switch to error color.
2906    pub is_error: bool,
2907    /// If false, input is visually disabled and `on_value_change` won't fire.
2908    pub enabled: bool,
2909    /// Called when the user presses Enter on a single-line field.
2910    pub on_submit: Option<Rc<dyn Fn(String)>>,
2911    /// Colors for all text field UI elements.
2912    pub colors: Option<TextFieldColors>,
2913    /// Optional external focus tracker. When `None`, an internal focus tracker
2914    /// is created (keyed by label). Pass a tracker to synchronize focus state
2915    /// (e.g. to avoid overriding external text while the user is editing).
2916    pub focus_tracker: Option<Rc<Cell<bool>>>,
2917}
2918
2919impl Default for OutlinedTextFieldConfig {
2920    fn default() -> Self {
2921        Self {
2922            label: None,
2923            placeholder: None,
2924            leading_icon: None,
2925            trailing_icon: None,
2926            single_line: true,
2927            is_error: false,
2928            enabled: true,
2929            on_submit: None,
2930            colors: None,
2931            focus_tracker: None,
2932        }
2933    }
2934}
2935
2936/// M3 Outlined Text Field with floating label, leading/trailing icons, and error state.
2937///
2938/// The label floats up when `value` is non-empty or when the field is focused.
2939/// Note: focus-based floating is approximated via animated `float_t` - the label
2940/// begins floating once `on_value_change` fires (i.e. when the user types).
2941/// For strict focus-on-tap floating, pair with an external focus signal.
2942///
2943/// # Example
2944/// ```ignore
2945/// let text = remember(|| signal(String::new()));
2946/// OutlinedTextField(
2947///     Modifier::new().fill_max_width().padding(16.0),
2948///     text.get(),
2949///     { let t = text.clone(); move |v| t.set(v) },
2950///     OutlinedTextFieldConfig {
2951///         label: Some("Email".into()),
2952///         placeholder: Some("user@example.com".into()),
2953///         ..Default::default()
2954///     },
2955/// );
2956/// ```
2957pub fn OutlinedTextField(
2958    modifier: Modifier,
2959    value: String,
2960    on_value_change: impl Fn(String) + 'static,
2961    config: OutlinedTextFieldConfig,
2962) -> View {
2963    let label_str: Option<Rc<str>> = config.label.clone().map(Rc::from);
2964    let has_label = label_str.is_some();
2965
2966    // Unique animation key per label to avoid conflicts when multiple fields exist
2967    let anim_key = match &label_str {
2968        Some(l) => format!("otf_{}", &l[..l.len().min(32)]),
2969        None => "otf_nolabel".into(),
2970    };
2971
2972    // Persistent focus tracker - set by layout/paint when this field is focused,
2973    // read here on the next frame. This gives a one-frame delay on tap-to-float,
2974    // which is negligible at 60fps. An external tracker takes precedence.
2975    let focus_tracker: Rc<Cell<bool>> = match config.focus_tracker.clone() {
2976        Some(ft) => ft,
2977        None => remember_with_key(format!("otf_focus_{}", anim_key), || Cell::new(false)),
2978    };
2979    let is_focused = focus_tracker.get();
2980    let should_float = !value.is_empty() || is_focused;
2981
2982    let tf_placeholder = if has_label {
2983        if should_float {
2984            config.placeholder.clone().unwrap_or_default()
2985        } else {
2986            String::new()
2987        }
2988    } else {
2989        config.placeholder.clone().unwrap_or_default()
2990    };
2991
2992    let text_input = View::new(0, ViewKind::Box)
2993        .modifier(
2994            Modifier::new().flex_grow(1.0).text_input(TextInputConfig {
2995                hint: tf_placeholder,
2996                multiline: false,
2997                on_change: Some(Rc::new(on_value_change) as _),
2998                on_submit: config.on_submit.clone().map(|f| {
2999                    let f = f.clone();
3000                    Rc::new(move |s| f(s)) as Rc<dyn Fn(String)>
3001                }),
3002                focus_tracker: Some(focus_tracker),
3003                value: value.clone(),
3004                visual_transformation: None,
3005                keyboard_type: Default::default(),
3006                capitalization: Default::default(),
3007                ime_action: Default::default(),
3008                enabled: config.enabled,
3009                read_only: false,
3010                max_lines: None,
3011                min_lines: 1,
3012                cursor_color: config
3013                    .colors
3014                    .as_ref()
3015                    .map(|c| c.cursor_color(config.is_error)),
3016                on_text_layout: None,
3017                text_style: None,
3018                keyboard_actions: None,
3019                interaction_source: None,
3020                line_limits: None,
3021            }),
3022        )
3023        .semantics(Semantics {
3024            role: Role::TextField,
3025            label: None,
3026            focused: false,
3027            enabled: true,
3028            selectable_group: false,
3029        });
3030
3031    outlined_field_decoration(
3032        modifier,
3033        anim_key,
3034        label_str,
3035        &config,
3036        is_focused,
3037        !value.is_empty(),
3038        text_input,
3039    )
3040}
3041
3042/// State-based M3 Outlined Text Field.
3043pub fn OutlinedTextFieldState(
3044    modifier: Modifier,
3045    state: Rc<RefCell<TextFieldState>>,
3046    on_value_change: impl Fn(String) + 'static,
3047    config: OutlinedTextFieldConfig,
3048) -> View {
3049    let label_str: Option<Rc<str>> = config.label.clone().map(Rc::from);
3050    let has_label = label_str.is_some();
3051
3052    // Unique animation key per label to avoid conflicts when multiple fields exist
3053    let anim_key = match &label_str {
3054        Some(l) => format!("otf_{}", &l[..l.len().min(32)]),
3055        None => "otf_nolabel".into(),
3056    };
3057
3058    let focus_tracker: Rc<Cell<bool>> = match config.focus_tracker.clone() {
3059        Some(ft) => ft,
3060        None => remember_with_key(format!("otf_focus_{}", anim_key), || Cell::new(false)),
3061    };
3062    let is_focused = focus_tracker.get();
3063    let has_content = !state.borrow().text.is_empty();
3064    let should_float = has_content || is_focused;
3065
3066    // Placeholder shows when there's no label, or when label is floating (focused/has content)
3067    let tf_placeholder = if has_label {
3068        if should_float {
3069            config.placeholder.clone().unwrap_or_default()
3070        } else {
3071            String::new()
3072        }
3073    } else {
3074        config.placeholder.clone().unwrap_or_default()
3075    };
3076
3077    let text_input = BasicTextField(
3078        state,
3079        Modifier::new().flex_grow(1.0),
3080        tf_placeholder,
3081        BasicTextFieldConfig {
3082            line_limits: if config.single_line {
3083                TextFieldLineLimits::SingleLine
3084            } else {
3085                TextFieldLineLimits::MultiLine {
3086                    min_height_in_lines: 1,
3087                    max_height_in_lines: usize::MAX,
3088                }
3089            },
3090            on_change: Some(Rc::new(on_value_change)),
3091            on_submit: config.on_submit.clone(),
3092            focus_tracker: Some(focus_tracker),
3093            enabled: config.enabled,
3094            ..Default::default()
3095        },
3096    );
3097
3098    outlined_field_decoration(
3099        modifier,
3100        anim_key,
3101        label_str,
3102        &config,
3103        is_focused,
3104        has_content,
3105        text_input,
3106    )
3107}
3108
3109fn outlined_field_decoration(
3110    modifier: Modifier,
3111    anim_key: String,
3112    label_str: Option<Rc<str>>,
3113    config: &OutlinedTextFieldConfig,
3114    is_focused: bool,
3115    has_content: bool,
3116    text_input: View,
3117) -> View {
3118    let th = theme();
3119    let has_label = label_str.is_some();
3120
3121    let should_float = has_content || is_focused;
3122    let float_t = animate_f32(
3123        anim_key.clone(),
3124        if should_float { 1.0 } else { 0.0 },
3125        th.motion.color,
3126    );
3127
3128    let target_border_w = if config.is_error || should_float {
3129        OutlinedTextFieldDefaults::FOCUSED_BORDER_THICKNESS
3130    } else {
3131        OutlinedTextFieldDefaults::UNFOCUSED_BORDER_THICKNESS
3132    };
3133    let border_w = animate_f32(
3134        format!("otf_bw_{}", anim_key),
3135        target_border_w,
3136        th.motion.color,
3137    );
3138
3139    let (border_color, label_color, container_bg) = if let Some(ref tc) = config.colors {
3140        (
3141            tc.indicator_color(config.enabled, config.is_error, is_focused),
3142            tc.label_color(config.enabled, config.is_error, is_focused),
3143            tc.container_color(config.enabled, config.is_error, is_focused),
3144        )
3145    } else {
3146        (
3147            if config.is_error {
3148                th.error
3149            } else if is_focused {
3150                th.primary
3151            } else {
3152                th.outline
3153            },
3154            if config.is_error {
3155                th.error
3156            } else if is_focused {
3157                th.primary
3158            } else {
3159                th.on_surface_variant
3160            },
3161            th.surface,
3162        )
3163    };
3164
3165    // Label font size: 16dp (expanded, inside) → 12dp (minimized, at border)
3166    let label_size = 16.0 - 4.0 * float_t;
3167
3168    // Minimized label half-height matches bodySmall line height (~16dp) / 2
3169    let min_label_half_h: f32 = if has_label { 8.0 } else { 0.0 };
3170
3171    // Label Y: expanded centered within 56dp field → minimized overlapping top border (-labelHeight/2)
3172    let label_start_y = (56.0 - 16.0) / 2.0;
3173    let label_end_y = -min_label_half_h;
3174    let label_y = label_start_y - (label_start_y - label_end_y) * float_t;
3175
3176    // Label X: expanded at text-input start (~24dp) → minimized at border-start (~20dp)
3177    let label_start_x = if has_label { 24.0 } else { 0.0 };
3178    let label_end_x = if has_label { 20.0 } else { 0.0 };
3179    let label_x = label_start_x - (label_start_x - label_end_x) * float_t;
3180
3181    // Container padding matches reference: 8dp top/bottom with label, 16dp without
3182    let (top_pad, bottom_pad) = if has_label { (8.0, 8.0) } else { (16.0, 16.0) };
3183
3184    // Outer Stack holds both the clipped content and the unclipped label.
3185    // The label sits outside the clipped Box so it can extend above the border.
3186    let label_cutout = label_str.as_ref().map(|lbl| {
3187        let font_px = dp_to_px(label_size) * repose_core::locals::text_scale().0;
3188        let m = measure_text(lbl, font_px, TextMeasureConfig::default());
3189        let text_width_px = m.positions.last().copied().unwrap_or(0.0);
3190        let text_width_dp = px_to_dp(text_width_px);
3191        let pad = 1.0;
3192        let line_h = 16.0;
3193        (
3194            label_x - pad,
3195            label_y - pad,
3196            label_x + text_width_dp + pad,
3197            label_y + line_h + pad,
3198        )
3199    });
3200
3201    ZStack(
3202        modifier
3203            .min_height(OutlinedTextFieldDefaults::MIN_HEIGHT)
3204            .min_width(OutlinedTextFieldDefaults::MIN_WIDTH),
3205    )
3206    .child((
3207        // Background layer -> no border, full surface color (no notch)
3208        Box(Modifier::new()
3209            .fill_max_size()
3210            .clip_rounded(th.shapes.small)
3211            .background(container_bg)),
3212        // Border layer -> drawn on top of background, with notch for the label
3213        if has_label {
3214            let mut bm = Modifier::new()
3215                .fill_max_size()
3216                .clip_rounded(th.shapes.small)
3217                .border(border_w, border_color, th.shapes.small);
3218            if let Some((l, t, r, b)) = label_cutout {
3219                bm = bm.clip_rect(l, t, r, b, ClipOp::Difference);
3220            }
3221            Box(bm)
3222        } else {
3223            Box(Modifier::new()
3224                .fill_max_size()
3225                .clip_rounded(th.shapes.small)
3226                .border(border_w, border_color, th.shapes.small))
3227        },
3228        // Content layer -> text input with proper padding
3229        Row(Modifier::new()
3230            .fill_max_size()
3231            .padding_values(PaddingValues {
3232                left: 16.0,
3233                right: 16.0,
3234                top: top_pad,
3235                bottom: bottom_pad,
3236            })
3237            .align_items(AlignItems::CENTER))
3238        .child((
3239            config.leading_icon.clone().unwrap_or(Box(Modifier::new())),
3240            text_input,
3241            config.trailing_icon.clone().unwrap_or(Box(Modifier::new())),
3242        )),
3243        // Floating label -> plain text, no background chip (matches M3 reference)
3244        if let Some(lbl) = label_str {
3245            Box(Modifier::new()
3246                .min_width(200.0)
3247                .padding_values(PaddingValues {
3248                    left: label_x,
3249                    right: 20.0,
3250                    top: 0.0,
3251                    bottom: 0.0,
3252                })
3253                .absolute()
3254                .offset(Some(0.0), Some(label_y), None, None))
3255            .child(
3256                Text(lbl.as_ref().to_string())
3257                    .color(label_color)
3258                    .size(label_size),
3259            )
3260        } else {
3261            Box(Modifier::new())
3262        },
3263    ))
3264}
3265
3266/// Configuration for a filled M3 [`TextField`].
3267#[derive(Clone)]
3268pub struct TextFieldConfig {
3269    pub label: Option<String>,
3270    pub placeholder: Option<String>,
3271    pub leading_icon: Option<View>,
3272    pub trailing_icon: Option<View>,
3273    pub single_line: bool,
3274    pub is_error: bool,
3275    pub enabled: bool,
3276    pub on_submit: Option<Rc<dyn Fn(String)>>,
3277    pub colors: Option<TextFieldColors>,
3278}
3279
3280impl Default for TextFieldConfig {
3281    fn default() -> Self {
3282        Self {
3283            label: None,
3284            placeholder: None,
3285            leading_icon: None,
3286            trailing_icon: None,
3287            single_line: true,
3288            is_error: false,
3289            enabled: true,
3290            on_submit: None,
3291            colors: None,
3292        }
3293    }
3294}
3295
3296/// M3 Filled Text Field with floating label, leading/trailing icons, error state,
3297/// and a bottom indicator line. (Equivalent to Compose Material3's `TextField`.)
3298///
3299/// The label floats up when `value` is non-empty or when the field is focused.
3300/// Container: `SurfaceContainerHighest` bg, top-rounded corners (4dp), flat bottom.
3301/// Indicator: always visible, 1dp (unfocused) / 2dp (focused/error), animated color+thickness.
3302pub fn TextField(
3303    modifier: Modifier,
3304    value: String,
3305    on_value_change: impl Fn(String) + 'static,
3306    config: TextFieldConfig,
3307) -> View {
3308    let th = theme();
3309    let label_str: Option<Rc<str>> = config.label.map(Rc::from);
3310    let has_label = label_str.is_some();
3311
3312    let anim_key = match &label_str {
3313        Some(l) => format!("tf_{}", &l[..l.len().min(32)]),
3314        None => "tf_nolabel".into(),
3315    };
3316
3317    let focus_tracker: Rc<Cell<bool>> =
3318        remember_with_key(format!("tf_focus_{}", anim_key), || Cell::new(false));
3319    let is_focused = focus_tracker.get();
3320    let should_float = !value.is_empty() || is_focused;
3321
3322    let float_t = animate_f32(
3323        anim_key.clone(),
3324        if should_float { 1.0 } else { 0.0 },
3325        th.motion.color,
3326    );
3327
3328    let (indicator_color, label_color, container_bg) = if let Some(ref tc) = config.colors {
3329        let enf = config.enabled && is_focused;
3330        let ind = tc.indicator_color(config.enabled, config.is_error, enf);
3331        let lb = tc.label_color(config.enabled, config.is_error, enf);
3332        let bg = tc.container_color(config.enabled, config.is_error, enf);
3333        (ind, lb, bg)
3334    } else {
3335        let ind = if config.is_error {
3336            th.error
3337        } else if float_t > 0.5 {
3338            th.primary
3339        } else {
3340            th.on_surface_variant
3341        };
3342        let lb = 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 bg = if config.enabled {
3350            th.surface_container_highest
3351        } else {
3352            th.on_surface
3353                .with_alpha_f32(0.04)
3354                .composite_over(th.surface)
3355        };
3356        (ind, lb, bg)
3357    };
3358
3359    let label_size = 16.0 - 4.0 * float_t;
3360
3361    let label_start_y = (56.0 - 16.0) / 2.0;
3362    let label_end_y = if has_label { 8.0 } else { 0.0 };
3363    let label_y = label_start_y - (label_start_y - label_end_y) * float_t;
3364
3365    let label_start_x = if has_label { 24.0 } else { 0.0 };
3366    let label_end_x = if has_label { 20.0 } else { 0.0 };
3367    let label_x = label_start_x - (label_start_x - label_end_x) * float_t;
3368
3369    let tf_placeholder = if has_label {
3370        if should_float {
3371            config.placeholder.unwrap_or_default()
3372        } else {
3373            String::new()
3374        }
3375    } else {
3376        config.placeholder.unwrap_or_default()
3377    };
3378
3379    let indicator_active = config.is_error || (config.enabled && is_focused);
3380    let indicator_target_w = if indicator_active { 2.0 } else { 1.0 };
3381    let indicator_w = animate_f32(
3382        format!("tf_ind_w_{}", anim_key),
3383        indicator_target_w,
3384        th.motion.color,
3385    );
3386
3387    let (top_pad, bottom_pad) = if has_label { (8.0, 8.0) } else { (16.0, 16.0) };
3388
3389    Column(
3390        modifier
3391            .min_height(TextFieldDefaults::MIN_HEIGHT)
3392            .min_width(TextFieldDefaults::MIN_WIDTH),
3393    )
3394    .child((
3395        // Clipped background and input content
3396        Box(Modifier::new()
3397            .fill_max_size()
3398            .clip_rounded(th.shapes.extra_small)
3399            .background(container_bg))
3400        .child(
3401            Column(Modifier::new().fill_max_size()).child((
3402                // Input row
3403                Row(Modifier::new()
3404                    .fill_max_size()
3405                    .padding_values(PaddingValues {
3406                        left: 16.0,
3407                        right: 16.0,
3408                        top: top_pad,
3409                        bottom: bottom_pad,
3410                    })
3411                    .align_items(AlignItems::CENTER))
3412                .child((
3413                    config.leading_icon.unwrap_or(Box(Modifier::new())),
3414                    View::new(0, ViewKind::Box)
3415                        .modifier(
3416                            Modifier::new().flex_grow(1.0).text_input(TextInputConfig {
3417                                hint: tf_placeholder,
3418                                multiline: !config.single_line,
3419                                on_change: Some(Rc::new(on_value_change) as _),
3420                                on_submit: config.on_submit.clone().map(|f| {
3421                                    let f = f.clone();
3422                                    Rc::new(move |s| f(s)) as Rc<dyn Fn(String)>
3423                                }),
3424                                focus_tracker: Some(focus_tracker.clone()),
3425                                value: value.clone(),
3426                                visual_transformation: None,
3427                                keyboard_type: Default::default(),
3428                                capitalization: Default::default(),
3429                                ime_action: Default::default(),
3430                                enabled: config.enabled,
3431                                read_only: false,
3432                                max_lines: None,
3433                                min_lines: 1,
3434                                cursor_color: config
3435                                    .colors
3436                                    .as_ref()
3437                                    .map(|c| c.cursor_color(config.is_error)),
3438                                on_text_layout: None,
3439                                text_style: None,
3440                                keyboard_actions: None,
3441                                interaction_source: None,
3442                                line_limits: None,
3443                            }),
3444                        )
3445                        .semantics(Semantics {
3446                            role: Role::TextField,
3447                            label: None,
3448                            focused: false,
3449                            enabled: true,
3450                            selectable_group: false,
3451                        }),
3452                    config.trailing_icon.unwrap_or(Box(Modifier::new())),
3453                )),
3454                // Bottom indicator line
3455                Box(Modifier::new()
3456                    .fill_max_width()
3457                    .height(indicator_w)
3458                    .absolute()
3459                    .offset(None, None, None, Some(0.0))
3460                    .background(indicator_color)),
3461            )),
3462        ),
3463        // Floating label
3464        if let Some(lbl) = label_str {
3465            Box(Modifier::new()
3466                .min_width(200.0)
3467                .padding_values(PaddingValues {
3468                    left: label_x,
3469                    right: 20.0,
3470                    top: 0.0,
3471                    bottom: 0.0,
3472                })
3473                .absolute()
3474                .offset(Some(0.0), Some(label_y), None, None))
3475            .child(
3476                Text(lbl.as_ref().to_string())
3477                    .color(label_color)
3478                    .size(label_size),
3479            )
3480        } else {
3481            Box(Modifier::new())
3482        },
3483    ))
3484}
3485
3486/// Configuration for [`Checkbox`].
3487#[derive(Clone, Debug)]
3488pub struct CheckboxConfig {
3489    pub modifier: Modifier,
3490    /// When false, the checkbox renders disabled colors and does not respond to clicks.
3491    pub enabled: bool,
3492    pub checked_color: Color,
3493    pub unchecked_color: Color,
3494    pub checkmark_color: Color,
3495    /// Border color when checked. Default: same as `checked_color`.
3496    pub checked_border_color: Color,
3497    /// Border color when unchecked. Default: same as `unchecked_color`.
3498    pub unchecked_border_color: Color,
3499    pub disabled_checked_box_color: Color,
3500    pub disabled_unchecked_box_color: Color,
3501    pub disabled_indeterminate_box_color: Color,
3502    pub disabled_checkmark_color: Color,
3503    pub disabled_checked_border_color: Color,
3504    pub disabled_unchecked_border_color: Color,
3505    pub disabled_indeterminate_border_color: Color,
3506    pub state_colors: StateColors,
3507    pub interaction_source: Option<MutableInteractionSource>,
3508}
3509
3510impl Default for CheckboxConfig {
3511    fn default() -> Self {
3512        Self {
3513            modifier: Modifier::new(),
3514            enabled: true,
3515            checked_color: CheckboxDefaults::checked_color(),
3516            unchecked_color: CheckboxDefaults::unchecked_color(),
3517            checkmark_color: CheckboxDefaults::checkmark_color(),
3518            checked_border_color: CheckboxDefaults::checked_color(),
3519            unchecked_border_color: CheckboxDefaults::unchecked_color(),
3520            disabled_checked_box_color: CheckboxDefaults::disabled_checked_box_color(),
3521            disabled_unchecked_box_color: Color::TRANSPARENT,
3522            disabled_indeterminate_box_color: CheckboxDefaults::disabled_checked_box_color(),
3523            disabled_checkmark_color: CheckboxDefaults::disabled_checkmark_color(),
3524            disabled_checked_border_color: CheckboxDefaults::disabled_checked_box_color(),
3525            disabled_unchecked_border_color: CheckboxDefaults::disabled_unchecked_border_color(),
3526            disabled_indeterminate_border_color: CheckboxDefaults::disabled_checked_box_color(),
3527            state_colors: CheckboxDefaults::state_colors_default(),
3528            interaction_source: None,
3529        }
3530    }
3531}
3532
3533/// M3 Checkbox.
3534/// Renders a 40dp touch-target with an 18dp check box inside.
3535/// Fill, border, and check mark animate with 100ms FastOutSlowIn.
3536static CHECKBOX_COUNTER: AtomicU64 = AtomicU64::new(0);
3537pub fn Checkbox(checked: bool, on_change: impl Fn(bool) + 'static, config: CheckboxConfig) -> View {
3538    let th = theme();
3539    let sz = CheckboxDefaults::BOX_SIZE;
3540
3541    let id = remember(|| CHECKBOX_COUNTER.fetch_add(1, Ordering::Relaxed));
3542    let spec = th.motion.color_fast;
3543
3544    let is_enabled = config.enabled;
3545
3546    let fill = animate_color(
3547        format!("cb_fill_{}", id),
3548        if !is_enabled {
3549            if checked {
3550                config.disabled_checked_box_color
3551            } else {
3552                config.disabled_unchecked_box_color
3553            }
3554        } else if checked {
3555            config.checked_color
3556        } else {
3557            Color::TRANSPARENT
3558        },
3559        spec,
3560    );
3561    let bd_w = animate_f32(
3562        format!("cb_bw_{}", id),
3563        if !is_enabled && checked {
3564            0.0
3565        } else if !is_enabled {
3566            CheckboxDefaults::STROKE_WIDTH
3567        } else if checked {
3568            0.0
3569        } else {
3570            CheckboxDefaults::STROKE_WIDTH
3571        },
3572        spec,
3573    );
3574    let bd = animate_color(
3575        format!("cb_bd_{}", id),
3576        if !is_enabled {
3577            if checked {
3578                config.disabled_checked_border_color
3579            } else {
3580                config.disabled_unchecked_border_color
3581            }
3582        } else if checked {
3583            Color::TRANSPARENT
3584        } else {
3585            config.unchecked_border_color
3586        },
3587        spec,
3588    );
3589    let check_alpha = animate_f32(
3590        format!("cb_ca_{}", id),
3591        if checked { 1.0 } else { 0.0 },
3592        spec,
3593    );
3594    let check_col = if !is_enabled {
3595        config.disabled_checkmark_color
3596    } else {
3597        config.checkmark_color
3598    };
3599
3600    let cb = move || {
3601        if config.enabled {
3602            on_change(!checked)
3603        }
3604    };
3605
3606    let cb_source: Rc<MutableInteractionSource> = config
3607        .interaction_source
3608        .clone()
3609        .map(Rc::new)
3610        .unwrap_or_else(|| remember(MutableInteractionSource::new));
3611    Box(Modifier::new()
3612        .width(CheckboxDefaults::TOUCH_TARGET_SIZE)
3613        .height(CheckboxDefaults::TOUCH_TARGET_SIZE)
3614        .padding(0.0)
3615        .clip_rounded(20.0)
3616        .background(Color::TRANSPARENT)
3617        .state_colors(config.state_colors)
3618        .interaction_source(&*cb_source)
3619        .clickable()
3620        .align_items(AlignItems::CENTER)
3621        .justify_content(JustifyContent::CENTER)
3622        .on_click(cb)
3623        .then(config.modifier))
3624    .child(
3625        Box(Modifier::new()
3626            .size(sz, sz)
3627            .background(fill)
3628            .border(bd_w, bd, CheckboxDefaults::CORNER_RADIUS)
3629            .clip_rounded(CheckboxDefaults::CORNER_RADIUS)
3630            .align_items(AlignItems::CENTER)
3631            .justify_content(JustifyContent::CENTER))
3632        .child(if check_alpha > 0.01 {
3633            Box(Modifier::new().alpha(check_alpha)).child(
3634                Icon(Symbol::new("done", '\u{E876}'))
3635                    .color(check_col)
3636                    .size(CheckboxDefaults::CHECK_ICON_SIZE),
3637            )
3638        } else {
3639            Box(Modifier::new())
3640        }),
3641    )
3642}
3643
3644/// Three-state value for [`TriStateCheckbox`].
3645#[derive(Clone, Copy, Debug, PartialEq)]
3646pub enum TriState {
3647    Checked,
3648    Unchecked,
3649    Indeterminate,
3650}
3651
3652/// M3 Tri-State Checkbox - cycles through Checked → Indeterminate → Unchecked.
3653/// Indeterminate shows a dash instead of a checkmark.
3654pub fn TriStateCheckbox(
3655    state: TriState,
3656    on_change: impl Fn(TriState) + 'static,
3657    config: CheckboxConfig,
3658) -> View {
3659    let th = theme();
3660    let sz = CheckboxDefaults::BOX_SIZE;
3661
3662    let id = remember(|| CHECKBOX_COUNTER.fetch_add(1, Ordering::Relaxed));
3663    let spec = th.motion.color_fast;
3664
3665    let is_checked = state == TriState::Checked;
3666    let is_indeterminate = state == TriState::Indeterminate;
3667    let has_fill = is_checked || is_indeterminate;
3668    let is_enabled = config.enabled;
3669
3670    let fill = animate_color(
3671        format!("tc_fill_{}", id),
3672        if !is_enabled {
3673            if has_fill {
3674                config.disabled_indeterminate_box_color
3675            } else {
3676                config.disabled_unchecked_box_color
3677            }
3678        } else if has_fill {
3679            config.checked_color
3680        } else {
3681            Color::TRANSPARENT
3682        },
3683        spec,
3684    );
3685    let bd_w = animate_f32(
3686        format!("tc_bw_{}", id),
3687        if !is_enabled {
3688            if has_fill {
3689                0.0
3690            } else {
3691                CheckboxDefaults::STROKE_WIDTH
3692            }
3693        } else if has_fill {
3694            0.0
3695        } else {
3696            CheckboxDefaults::STROKE_WIDTH
3697        },
3698        spec,
3699    );
3700    let bd = animate_color(
3701        format!("tc_bd_{}", id),
3702        if !is_enabled {
3703            if has_fill {
3704                config.disabled_indeterminate_border_color
3705            } else {
3706                config.disabled_unchecked_border_color
3707            }
3708        } else if has_fill {
3709            Color::TRANSPARENT
3710        } else {
3711            config.unchecked_border_color
3712        },
3713        spec,
3714    );
3715    let symbol_alpha = animate_f32(
3716        format!("tc_sa_{}", id),
3717        if has_fill { 1.0 } else { 0.0 },
3718        spec,
3719    );
3720    let symbol_col = if !is_enabled {
3721        config.disabled_checkmark_color
3722    } else {
3723        config.checkmark_color
3724    };
3725
3726    Box(Modifier::new()
3727        .width(CheckboxDefaults::TOUCH_TARGET_SIZE)
3728        .height(CheckboxDefaults::TOUCH_TARGET_SIZE)
3729        .padding(0.0)
3730        .clip_rounded(20.0)
3731        .background(Color::TRANSPARENT)
3732        .clickable()
3733        .align_items(AlignItems::CENTER)
3734        .justify_content(JustifyContent::CENTER)
3735        .on_click(move || {
3736            if is_enabled {
3737                on_change(match state {
3738                    TriState::Checked => TriState::Unchecked,
3739                    TriState::Indeterminate => TriState::Checked,
3740                    TriState::Unchecked => TriState::Checked,
3741                })
3742            }
3743        })
3744        .then(config.modifier))
3745    .child(
3746        Box(Modifier::new()
3747            .size(sz, sz)
3748            .background(fill)
3749            .border(bd_w, bd, CheckboxDefaults::CORNER_RADIUS)
3750            .clip_rounded(CheckboxDefaults::CORNER_RADIUS)
3751            .align_items(AlignItems::CENTER)
3752            .justify_content(JustifyContent::CENTER))
3753        .child(if symbol_alpha > 0.01 {
3754            Box(Modifier::new().alpha(symbol_alpha)).child(if is_indeterminate {
3755                // Dash for indeterminate
3756                Box(Modifier::new()
3757                    .width(10.0)
3758                    .height(2.0)
3759                    .background(symbol_col)
3760                    .clip_rounded(1.0))
3761            } else {
3762                Icon(Symbol::new("done", '\u{E876}'))
3763                    .color(symbol_col)
3764                    .size(CheckboxDefaults::CHECK_ICON_SIZE)
3765            })
3766        } else {
3767            Box(Modifier::new())
3768        }),
3769    )
3770}
3771
3772/// Configuration for [`RadioButton`].
3773#[derive(Clone, Debug)]
3774pub struct RadioButtonConfig {
3775    pub modifier: Modifier,
3776    /// When false, renders disabled colors and does not respond to clicks.
3777    pub enabled: bool,
3778    pub selected_color: Color,
3779    pub unselected_color: Color,
3780    pub disabled_selected_color: Color,
3781    pub disabled_unselected_color: Color,
3782    pub state_colors: StateColors,
3783    pub interaction_source: Option<MutableInteractionSource>,
3784}
3785
3786impl Default for RadioButtonConfig {
3787    fn default() -> Self {
3788        Self {
3789            modifier: Modifier::new(),
3790            enabled: true,
3791            selected_color: RadioButtonDefaults::selected_color(),
3792            unselected_color: RadioButtonDefaults::unselected_color(),
3793            disabled_selected_color: RadioButtonDefaults::disabled_selected_color(),
3794            disabled_unselected_color: RadioButtonDefaults::disabled_unselected_color(),
3795            state_colors: RadioButtonDefaults::state_colors_default(),
3796            interaction_source: None,
3797        }
3798    }
3799}
3800
3801/// M3 RadioButton.
3802/// Renders a 40dp touch-target with a 20dp outer circle + inner dot.
3803/// Ring color animates with 100ms FastOutSlowIn; dot size animates with spring.
3804static RADIO_COUNTER: AtomicU64 = AtomicU64::new(0);
3805pub fn RadioButton(
3806    selected: bool,
3807    on_select: impl Fn() + 'static,
3808    config: RadioButtonConfig,
3809) -> View {
3810    let th = theme();
3811    let d = RadioButtonDefaults::OUTER_RADIUS * 2.0;
3812
3813    let id = remember(|| RADIO_COUNTER.fetch_add(1, Ordering::Relaxed));
3814    let color_spec = th.motion.color_fast;
3815    let spring = th.motion.spring;
3816
3817    let ring_col = animate_color(
3818        format!("rb_ring_{}", id),
3819        if !config.enabled {
3820            if selected {
3821                config.disabled_selected_color
3822            } else {
3823                config.disabled_unselected_color
3824            }
3825        } else if selected {
3826            config.selected_color
3827        } else {
3828            config.unselected_color
3829        },
3830        color_spec,
3831    );
3832    let dot_size = animate_f32(
3833        format!("rb_dot_{}", id),
3834        if selected {
3835            RadioButtonDefaults::DOT_RADIUS * 2.0
3836        } else {
3837            0.0
3838        },
3839        spring,
3840    );
3841    let dot_col = if !config.enabled {
3842        config.disabled_selected_color
3843    } else {
3844        config.selected_color
3845    };
3846
3847    let cb = move || {
3848        if config.enabled {
3849            on_select()
3850        }
3851    };
3852
3853    let rb_source: Rc<MutableInteractionSource> = config
3854        .interaction_source
3855        .clone()
3856        .map(Rc::new)
3857        .unwrap_or_else(|| remember(MutableInteractionSource::new));
3858    Box(Modifier::new()
3859        .width(RadioButtonDefaults::TOUCH_TARGET_SIZE)
3860        .height(RadioButtonDefaults::TOUCH_TARGET_SIZE)
3861        .padding(0.0)
3862        .clip_rounded(20.0)
3863        .background(Color::TRANSPARENT)
3864        .state_colors(config.state_colors)
3865        .interaction_source(&*rb_source)
3866        .clickable()
3867        .align_items(AlignItems::CENTER)
3868        .justify_content(JustifyContent::CENTER)
3869        .on_click(cb)
3870        .then(config.modifier))
3871    .child(
3872        Box(Modifier::new()
3873            .size(d, d)
3874            .border(RadioButtonDefaults::STROKE_WIDTH, ring_col, d * 0.5)
3875            .clip_rounded(d * 0.5)
3876            .align_items(AlignItems::CENTER)
3877            .justify_content(JustifyContent::CENTER))
3878        .child(if dot_size > 0.5 {
3879            Box(Modifier::new()
3880                .size(dot_size, dot_size)
3881                .background(dot_col)
3882                .clip_rounded(dot_size * 0.5))
3883        } else {
3884            Box(Modifier::new())
3885        }),
3886    )
3887}
3888
3889/// Configuration for [`Switch`].
3890#[derive(Clone, Debug)]
3891pub struct SwitchConfig {
3892    pub modifier: Modifier,
3893    /// When false, renders disabled colors and does not respond to clicks.
3894    pub enabled: bool,
3895    pub checked_track_color: Color,
3896    pub unchecked_track_color: Color,
3897    pub checked_thumb_color: Color,
3898    pub unchecked_thumb_color: Color,
3899    /// Icon color for the thumb content when checked. Default: `on_primary`.
3900    pub checked_icon_color: Color,
3901    /// Icon color for the thumb content when unchecked. Default: `outline`.
3902    pub unchecked_icon_color: Color,
3903    /// Border color when checked. Default: transparent.
3904    pub checked_border_color: Color,
3905    /// Border color when unchecked.
3906    pub unchecked_border_color: Color,
3907    pub disabled_checked_thumb_color: Color,
3908    pub disabled_checked_track_color: Color,
3909    pub disabled_checked_border_color: Color,
3910    pub disabled_checked_icon_color: Color,
3911    pub disabled_unchecked_thumb_color: Color,
3912    pub disabled_unchecked_track_color: Color,
3913    pub disabled_unchecked_border_color: Color,
3914    pub disabled_unchecked_icon_color: Color,
3915    pub state_colors: StateColors,
3916    pub thumb_content: Option<View>,
3917    pub interaction_source: Option<MutableInteractionSource>,
3918}
3919
3920impl Default for SwitchConfig {
3921    fn default() -> Self {
3922        Self {
3923            modifier: Modifier::new(),
3924            enabled: true,
3925            checked_track_color: SwitchDefaults::checked_track_color(),
3926            unchecked_track_color: SwitchDefaults::unchecked_track_color(),
3927            checked_thumb_color: SwitchDefaults::checked_thumb_color(),
3928            unchecked_thumb_color: SwitchDefaults::unchecked_thumb_color(),
3929            checked_icon_color: SwitchDefaults::checked_icon_color(),
3930            unchecked_icon_color: SwitchDefaults::unchecked_icon_color(),
3931            checked_border_color: Color::TRANSPARENT,
3932            unchecked_border_color: SwitchDefaults::unchecked_border_color(),
3933            disabled_checked_thumb_color: SwitchDefaults::disabled_checked_thumb_color(),
3934            disabled_checked_track_color: SwitchDefaults::disabled_checked_track_color(),
3935            disabled_checked_border_color: Color::TRANSPARENT,
3936            disabled_checked_icon_color: SwitchDefaults::disabled_checked_icon_color(),
3937            disabled_unchecked_thumb_color: SwitchDefaults::disabled_unchecked_thumb_color(),
3938            disabled_unchecked_track_color: SwitchDefaults::disabled_unchecked_track_color(),
3939            disabled_unchecked_border_color: SwitchDefaults::disabled_unchecked_border_color(),
3940            disabled_unchecked_icon_color: SwitchDefaults::disabled_unchecked_icon_color(),
3941            state_colors: SwitchDefaults::state_colors_default(),
3942            thumb_content: None,
3943            interaction_source: None,
3944        }
3945    }
3946}
3947
3948/// M3 Switch.
3949/// Renders a pill track with an animated thumb knob.
3950/// Thumb position, size, and colors animate with spring/tween physics.
3951static SWITCH_COUNTER: AtomicU64 = AtomicU64::new(0);
3952pub fn Switch(checked: bool, on_change: impl Fn(bool) + 'static, config: SwitchConfig) -> View {
3953    let th = theme();
3954    let track_w = SwitchDefaults::TRACK_WIDTH;
3955    let track_h = SwitchDefaults::TRACK_HEIGHT;
3956
3957    let id = remember(|| SWITCH_COUNTER.fetch_add(1, Ordering::Relaxed));
3958
3959    let hovered = remember(|| Signal::new(false));
3960    let pressed = remember(|| Signal::new(false));
3961
3962    // Thumb: spring-animated position and size
3963    let thumb_target_pos = if checked {
3964        track_w - SwitchDefaults::THUMB_CHECKED_SIZE - 4.0
3965    } else {
3966        8.0
3967    };
3968    let thumb_target_d = if checked {
3969        SwitchDefaults::THUMB_CHECKED_SIZE
3970    } else {
3971        SwitchDefaults::THUMB_UNCHECKED_SIZE
3972    };
3973    let spring = th.motion.spring;
3974
3975    let thumb_left = animate_f32(format!("sw_pos_{}", id), thumb_target_pos, spring);
3976    let thumb_d = animate_f32(format!("sw_d_{}", id), thumb_target_d, spring);
3977    let thumb_top = (track_h - thumb_d) * 0.5;
3978
3979    let color_spec = th.motion.color_fast;
3980    let is_enabled = config.enabled;
3981
3982    let track_bg = animate_color(
3983        format!("sw_tbg_{}", id),
3984        if !is_enabled {
3985            if checked {
3986                config.disabled_checked_track_color
3987            } else {
3988                config.disabled_unchecked_track_color
3989            }
3990        } else if checked {
3991            config.checked_track_color
3992        } else {
3993            config.unchecked_track_color
3994        },
3995        color_spec,
3996    );
3997    let thumb_bg = animate_color(
3998        format!("sw_tmbg_{}", id),
3999        if !is_enabled {
4000            if checked {
4001                config.disabled_checked_thumb_color
4002            } else {
4003                config.disabled_unchecked_thumb_color
4004            }
4005        } else if checked {
4006            config.checked_thumb_color
4007        } else {
4008            config.unchecked_thumb_color
4009        },
4010        color_spec,
4011    );
4012    let track_border = animate_f32(
4013        format!("sw_tb_{}", id),
4014        if !is_enabled {
4015            if checked { 0.0 } else { 2.0 }
4016        } else if checked {
4017            0.0
4018        } else {
4019            2.0
4020        },
4021        color_spec,
4022    );
4023    let border_color = animate_color(
4024        format!("sw_bc_{}", id),
4025        if !is_enabled {
4026            if checked {
4027                config.disabled_checked_border_color
4028            } else {
4029                config.disabled_unchecked_border_color
4030            }
4031        } else if checked {
4032            config.checked_border_color
4033        } else {
4034            config.unchecked_border_color
4035        },
4036        color_spec,
4037    );
4038
4039    let state_overlay = animate_color(
4040        format!("sw_ol_{}", id),
4041        if !is_enabled {
4042            Color::TRANSPARENT
4043        } else if pressed.get() {
4044            config.state_colors.pressed
4045        } else if hovered.get() {
4046            config.state_colors.hovered
4047        } else {
4048            config.state_colors.default
4049        },
4050        color_spec,
4051    );
4052
4053    let sw_source: Rc<MutableInteractionSource> = config
4054        .interaction_source
4055        .clone()
4056        .map(Rc::new)
4057        .unwrap_or_else(|| remember(MutableInteractionSource::new));
4058    Box(Modifier::new()
4059        .size(track_w, track_h)
4060        .padding(0.0)
4061        .clip_rounded(track_h * 0.5)
4062        .background(track_bg)
4063        .border(track_border, border_color, track_h * 0.5)
4064        .interaction_source(&*sw_source)
4065        .clickable()
4066        .on_pointer_enter({
4067            let h = hovered.clone();
4068            move |_| h.set(true)
4069        })
4070        .on_pointer_leave({
4071            let h = hovered.clone();
4072            let p = pressed.clone();
4073            move |_| {
4074                h.set(false);
4075                p.set(false);
4076            }
4077        })
4078        .on_pointer_down({
4079            let p = pressed.clone();
4080            move |_| p.set(true)
4081        })
4082        .on_click({
4083            let cb = on_change;
4084            move || cb(!checked)
4085        })
4086        .on_pointer_up({
4087            let p = pressed.clone();
4088            move |_| p.set(false)
4089        })
4090        .then(config.modifier))
4091    .child((
4092        Box(Modifier::new()
4093            .size(thumb_d, thumb_d)
4094            .background(thumb_bg)
4095            .clip_rounded(thumb_d * 0.5)
4096            .hit_passthrough()
4097            .absolute()
4098            .offset(Some(thumb_left), Some(thumb_top), None, None)),
4099        Box(Modifier::new()
4100            .size(40.0, 40.0)
4101            .clip_rounded(20.0)
4102            .background(state_overlay)
4103            .hit_passthrough()
4104            .absolute()
4105            .offset(
4106                Some(thumb_left + thumb_d * 0.5 - 20.0),
4107                Some(track_h * 0.5 - 20.0),
4108                None,
4109                None,
4110            )),
4111    ))
4112}
4113
4114/// Configuration for [`Slider`] and [`RangeSlider`].
4115#[derive(Clone)]
4116pub struct SliderConfig {
4117    // Debug impl is manual because on_value_change_finished contains a closure
4118    pub modifier: Modifier,
4119    /// When false, renders disabled colors and does not respond to input.
4120    pub enabled: bool,
4121    pub active_track_color: Color,
4122    pub inactive_track_color: Color,
4123    pub thumb_color: Color,
4124    pub active_tick_color: Color,
4125    pub inactive_tick_color: Color,
4126    pub disabled_thumb_color: Color,
4127    pub disabled_active_track_color: Color,
4128    pub disabled_inactive_track_color: Color,
4129    pub disabled_active_tick_color: Color,
4130    pub disabled_inactive_tick_color: Color,
4131    pub state_colors: StateColors,
4132    pub on_value_change_finished: Option<Rc<dyn Fn()>>,
4133    pub interaction_source: Option<MutableInteractionSource>,
4134}
4135
4136impl std::fmt::Debug for SliderConfig {
4137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4138        f.debug_struct("SliderConfig")
4139            .field("modifier", &self.modifier)
4140            .field("enabled", &self.enabled)
4141            .field("active_track_color", &self.active_track_color)
4142            .field("inactive_track_color", &self.inactive_track_color)
4143            .field("thumb_color", &self.thumb_color)
4144            .field("active_tick_color", &self.active_tick_color)
4145            .field("inactive_tick_color", &self.inactive_tick_color)
4146            .field("disabled_thumb_color", &self.disabled_thumb_color)
4147            .field(
4148                "disabled_active_track_color",
4149                &self.disabled_active_track_color,
4150            )
4151            .field(
4152                "disabled_inactive_track_color",
4153                &self.disabled_inactive_track_color,
4154            )
4155            .field(
4156                "disabled_active_tick_color",
4157                &self.disabled_active_tick_color,
4158            )
4159            .field(
4160                "disabled_inactive_tick_color",
4161                &self.disabled_inactive_tick_color,
4162            )
4163            .field("state_colors", &self.state_colors)
4164            .field(
4165                "on_value_change_finished",
4166                &self.on_value_change_finished.as_ref().map(|_| ".."),
4167            )
4168            .field(
4169                "interaction_source",
4170                &self.interaction_source.as_ref().map(|_| ".."),
4171            )
4172            .finish()
4173    }
4174}
4175
4176impl Default for SliderConfig {
4177    fn default() -> Self {
4178        Self {
4179            modifier: Modifier::new(),
4180            enabled: true,
4181            active_track_color: SliderDefaults::active_track_color(),
4182            inactive_track_color: SliderDefaults::inactive_track_color(),
4183            thumb_color: SliderDefaults::thumb_color(),
4184            active_tick_color: SliderDefaults::active_tick_color(),
4185            inactive_tick_color: SliderDefaults::inactive_tick_color(),
4186            disabled_thumb_color: SliderDefaults::disabled_thumb_color(),
4187            disabled_active_track_color: SliderDefaults::disabled_active_track_color(),
4188            disabled_inactive_track_color: SliderDefaults::disabled_inactive_track_color(),
4189            disabled_active_tick_color: SliderDefaults::disabled_active_tick_color(),
4190            disabled_inactive_tick_color: SliderDefaults::disabled_inactive_tick_color(),
4191            state_colors: SliderDefaults::state_colors_default(),
4192            on_value_change_finished: None,
4193            interaction_source: None,
4194        }
4195    }
4196}
4197
4198static SLIDER_COUNTER: AtomicU64 = AtomicU64::new(0);
4199
4200fn snap_step(v: f32, min: f32, max: f32, step: Option<f32>) -> f32 {
4201    let v = v.clamp(min, max);
4202    if let Some(s) = step.filter(|s| *s > 0.0) {
4203        let t = ((v - min) / s).round();
4204        (min + t * s).clamp(min, max)
4205    } else {
4206        v
4207    }
4208}
4209
4210fn value_from_x(x: f32, rect: Rect, min: f32, max: f32, step: Option<f32>) -> f32 {
4211    let w = rect.w.max(1.0);
4212    let t = ((x - rect.x) / w).clamp(0.0, 1.0);
4213    let v = min + t * (max - min);
4214    snap_step(v, min, max, step)
4215}
4216
4217pub fn Slider(
4218    value: f32,
4219    range: (f32, f32),
4220    step: Option<f32>,
4221    on_change: impl Fn(f32) + 'static,
4222    config: SliderConfig,
4223) -> View {
4224    assert!(range.0 <= range.1, "Slider range start must be <= end");
4225    if let Some(s) = step {
4226        assert!(s > 0.0, "Slider step must be positive");
4227    }
4228    let id = *remember(|| SLIDER_COUNTER.fetch_add(1, Ordering::Relaxed));
4229    let track_rect = remember_state_with_key(format!("ms_rect_{}", id), || Rect::default());
4230    let drag_active = remember_mutable_with_key(format!("ms_da_{}", id), || false);
4231    let hovered = remember(|| Signal::new(false));
4232
4233    let track_rect_p = track_rect.clone();
4234    let drag_active_p = drag_active.clone();
4235    let hovered_sig = hovered.clone();
4236    let sc = config.state_colors;
4237
4238    let min = range.0;
4239    let max = range.1;
4240    let oc = Rc::new(on_change);
4241    let range_size = (max - min).max(1e-6);
4242    let t = ((value - min) / range_size).clamp(0.0, 1.0);
4243
4244    let tick_frac: Vec<f32> = if let Some(s) = step {
4245        let n = ((max - min) / s.max(1e-6)).round() as usize;
4246        (0..=n).map(|i| i as f32 / n as f32).collect()
4247    } else {
4248        Vec::new()
4249    };
4250
4251    let sl_source: Rc<MutableInteractionSource> = config
4252        .interaction_source
4253        .clone()
4254        .map(Rc::new)
4255        .unwrap_or_else(|| remember(MutableInteractionSource::new));
4256    Box(Modifier::new()
4257        .min_width(200.0)
4258        .height(44.0)
4259        .interaction_source(&*sl_source)
4260        .painter(move |scene: &mut Scene, rect: Rect, alpha: f32| {
4261            let mul_c = |c: Color| {
4262                Color(
4263                    c.0,
4264                    c.1,
4265                    c.2,
4266                    ((c.3 as f32) * alpha).clamp(0.0, 255.0) as u8,
4267                )
4268            };
4269            let track_h = dp_to_px(16.0);
4270            let thumb_w = dp_to_px(4.0);
4271            let thumb_h = dp_to_px(44.0);
4272            let dot_r = dp_to_px(2.0);
4273            let corner = track_h * 0.5;
4274            let gap = thumb_w * 0.5 + dp_to_px(ProgressIndicatorDefaults::SLIDER_THUMB_TRACK_GAP);
4275            let pad = thumb_w * 0.5;
4276            let track_x = rect.x + pad;
4277            let track_w = (rect.w - thumb_w).max(0.0);
4278            let cy = rect.y + rect.h * 0.5;
4279
4280            let kx = if step.is_some() && !tick_frac.is_empty() {
4281                let is_first = (t - tick_frac[0]).abs() < 1e-6;
4282                let is_last = (t - tick_frac[tick_frac.len() - 1]).abs() < 1e-6;
4283                if is_first || is_last {
4284                    track_x + t * track_w
4285                } else {
4286                    track_x + (track_w - track_h) * t + corner
4287                }
4288            } else {
4289                track_x + t * track_w
4290            };
4291
4292            *track_rect_p.borrow_mut() = Rect {
4293                x: track_x,
4294                y: rect.y,
4295                w: track_w,
4296                h: rect.h,
4297            };
4298
4299            let inactive_x = track_x.max(kx + gap);
4300            let inactive_w = (track_x + track_w - inactive_x).max(0.0);
4301            if inactive_w > 0.0 {
4302                scene.nodes.push(SceneNode::Rect {
4303                    rect: Rect {
4304                        x: inactive_x,
4305                        y: cy - track_h * 0.5,
4306                        w: inactive_w,
4307                        h: track_h,
4308                    },
4309                    brush: Brush::Solid(mul_c(config.inactive_track_color)),
4310                    radius: [corner; 4],
4311                });
4312                let sx = track_x + track_w - corner;
4313                scene.nodes.push(SceneNode::Ellipse {
4314                    rect: Rect {
4315                        x: sx - dot_r,
4316                        y: cy - dot_r,
4317                        w: dot_r * 2.0,
4318                        h: dot_r * 2.0,
4319                    },
4320                    brush: Brush::Solid(mul_c(config.inactive_tick_color)),
4321                });
4322            }
4323            let fill_w = (kx - gap - track_x).max(0.0);
4324            if fill_w > 0.0 {
4325                scene.nodes.push(SceneNode::Rect {
4326                    rect: Rect {
4327                        x: track_x,
4328                        y: cy - track_h * 0.5,
4329                        w: fill_w,
4330                        h: track_h,
4331                    },
4332                    brush: Brush::Solid(mul_c(config.active_track_color)),
4333                    radius: [corner; 4],
4334                });
4335            }
4336            let tick_start = track_x + corner;
4337            let tick_end = track_x + track_w - corner;
4338            for (i, &tf) in tick_frac.iter().enumerate() {
4339                let tx = tick_start + tf * (tick_end - tick_start);
4340                // skip ticks that fall on the stop indicator (last)
4341                if i == tick_frac.len() - 1 {
4342                    continue;
4343                }
4344                if tx >= kx - gap && tx <= kx + gap {
4345                    continue;
4346                }
4347                let on_active = tx <= kx - gap;
4348                scene.nodes.push(SceneNode::Ellipse {
4349                    rect: Rect {
4350                        x: tx - dot_r,
4351                        y: cy - dot_r,
4352                        w: dot_r * 2.0,
4353                        h: dot_r * 2.0,
4354                    },
4355                    brush: Brush::Solid(mul_c(if on_active {
4356                        config.active_tick_color
4357                    } else {
4358                        config.inactive_tick_color
4359                    })),
4360                });
4361            }
4362            let da = *drag_active_p.get();
4363            let hv = hovered_sig.get();
4364            let tw = if da { thumb_w * 0.5 } else { thumb_w };
4365            scene.nodes.push(SceneNode::Rect {
4366                rect: Rect {
4367                    x: kx - tw * 0.5,
4368                    y: cy - thumb_h * 0.5,
4369                    w: tw,
4370                    h: thumb_h,
4371                },
4372                brush: Brush::Solid(mul_c(config.thumb_color)),
4373                radius: [tw * 0.5; 4],
4374            });
4375            let sc_target = if da {
4376                sc.pressed
4377            } else if hv {
4378                sc.hovered
4379            } else {
4380                sc.default
4381            };
4382            if sc_target.3 > 0 {
4383                scene.nodes.push(SceneNode::Rect {
4384                    rect: Rect {
4385                        x: kx - tw * 0.5,
4386                        y: cy - thumb_h * 0.5,
4387                        w: tw,
4388                        h: thumb_h,
4389                    },
4390                    brush: Brush::Solid(mul_c(sc_target)),
4391                    radius: [tw * 0.5; 4],
4392                });
4393            }
4394        })
4395        .on_pointer_enter({
4396            let h = hovered.clone();
4397            move |_pe: PointerEvent| h.set(true)
4398        })
4399        .on_pointer_leave({
4400            let h = hovered.clone();
4401            move |_pe: PointerEvent| h.set(false)
4402        })
4403        .on_pointer_down({
4404            let oc = oc.clone();
4405            let track_rect = track_rect.clone();
4406            let drag_active = drag_active.clone();
4407            move |pe: PointerEvent| {
4408                drag_active.set(true);
4409                let r = *track_rect.borrow();
4410                (oc)(value_from_x(pe.position.x, r, min, max, step));
4411            }
4412        })
4413        .on_pointer_move({
4414            let oc = oc.clone();
4415            let track_rect = track_rect.clone();
4416            let drag_active = drag_active.clone();
4417            move |pe: PointerEvent| {
4418                if !*drag_active.get() {
4419                    return;
4420                }
4421                let r = *track_rect.borrow();
4422                (oc)(value_from_x(pe.position.x, r, min, max, step));
4423            }
4424        })
4425        .on_pointer_up({
4426            let on_finished = config.on_value_change_finished.clone();
4427            move |_pe: PointerEvent| {
4428                drag_active.set(false);
4429                if let Some(ref cb) = on_finished {
4430                    (cb)();
4431                }
4432            }
4433        })
4434        .on_scroll({
4435            let oc = oc.clone();
4436            move |d: Vec2| -> Vec2 {
4437                let dir = if d.y < -0.5 {
4438                    1
4439                } else if d.y > 0.5 {
4440                    -1
4441                } else {
4442                    0
4443                };
4444                if dir == 0 {
4445                    return d;
4446                }
4447                let step_val = step.unwrap_or(1.0).max(1e-6);
4448                let new_val = snap_step(value + (dir as f32) * step_val, min, max, step);
4449                if (new_val - value).abs() > 1e-6 {
4450                    (oc)(new_val);
4451                    Vec2 { x: d.x, y: 0.0 }
4452                } else {
4453                    d
4454                }
4455            }
4456        })
4457        .then(config.modifier))
4458    .semantics(Semantics {
4459        role: Role::Slider,
4460        label: None,
4461        focused: false,
4462        enabled: true,
4463        selectable_group: false,
4464    })
4465}
4466
4467pub fn RangeSlider(
4468    start: f32,
4469    end: f32,
4470    range: (f32, f32),
4471    step: Option<f32>,
4472    on_change: impl Fn(f32, f32) + 'static,
4473    config: SliderConfig,
4474) -> View {
4475    assert!(range.0 <= range.1, "Slider range start must be <= end");
4476    if let Some(s) = step {
4477        assert!(s > 0.0, "Slider step must be positive");
4478    }
4479    let id = *remember(|| SLIDER_COUNTER.fetch_add(1, Ordering::Relaxed));
4480    let track_rect = remember_state_with_key(format!("mrs_rect_{}", id), || Rect::default());
4481    let drag_active = remember_mutable_with_key(format!("mrs_da_{}", id), || false);
4482    let active_thumb = remember_mutable_with_key(format!("mrs_at_{}", id), || false);
4483    let hovered = remember(|| Signal::new(false));
4484
4485    let min = range.0;
4486    let max = range.1;
4487    let oc = Rc::new(on_change);
4488    let range_size = (max - min).max(1e-6);
4489    let t0 = ((start - min) / range_size).clamp(0.0, 1.0);
4490    let t1 = ((end - min) / range_size).clamp(0.0, 1.0);
4491    let sc = config.state_colors;
4492    let is_enabled = config.enabled;
4493
4494    let act_trk = if !is_enabled {
4495        config.disabled_active_track_color
4496    } else {
4497        config.active_track_color
4498    };
4499    let inact_trk = if !is_enabled {
4500        config.disabled_inactive_track_color
4501    } else {
4502        config.inactive_track_color
4503    };
4504    let act_tick = if !is_enabled {
4505        config.disabled_active_tick_color
4506    } else {
4507        config.active_tick_color
4508    };
4509    let inact_tick = if !is_enabled {
4510        config.disabled_inactive_tick_color
4511    } else {
4512        config.inactive_tick_color
4513    };
4514    let thumb_col = if !is_enabled {
4515        config.disabled_thumb_color
4516    } else {
4517        config.thumb_color
4518    };
4519
4520    let tick_frac: Vec<f32> = if let Some(s) = step {
4521        let n = ((max - min) / s.max(1e-6)).round() as usize;
4522        (0..=n).map(|i| i as f32 / n as f32).collect()
4523    } else {
4524        Vec::new()
4525    };
4526
4527    let track_rect_p = track_rect.clone();
4528    let drag_active_p = drag_active.clone();
4529    let active_thumb_p = active_thumb.clone();
4530    let hovered_sig = hovered.clone();
4531
4532    Box(Modifier::new()
4533        .min_width(200.0)
4534        .height(44.0)
4535        .painter(move |scene: &mut Scene, rect: Rect, alpha: f32| {
4536            let mul_c = |c: Color| {
4537                Color(
4538                    c.0,
4539                    c.1,
4540                    c.2,
4541                    ((c.3 as f32) * alpha).clamp(0.0, 255.0) as u8,
4542                )
4543            };
4544            let track_h = dp_to_px(16.0);
4545            let thumb_w = dp_to_px(4.0);
4546            let thumb_h = dp_to_px(44.0);
4547            let dot_r = dp_to_px(2.0);
4548            let corner = track_h * 0.5;
4549            let gap = thumb_w * 0.5 + dp_to_px(ProgressIndicatorDefaults::SLIDER_THUMB_TRACK_GAP);
4550            let pad = thumb_w * 0.5;
4551            let track_x = rect.x + pad;
4552            let track_w = (rect.w - thumb_w).max(0.0);
4553            let cy = rect.y + rect.h * 0.5;
4554
4555            let thumb_pos = |tf: f32, fracs: &[f32]| {
4556                if step.is_some() && !fracs.is_empty() {
4557                    let is_first = (tf - fracs[0]).abs() < 1e-6;
4558                    let is_last = (tf - fracs[fracs.len() - 1]).abs() < 1e-6;
4559                    if is_first || is_last {
4560                        track_x + tf * track_w
4561                    } else {
4562                        track_x + (track_w - track_h) * tf + corner
4563                    }
4564                } else {
4565                    track_x + tf * track_w
4566                }
4567            };
4568            let k0 = thumb_pos(t0, &tick_frac);
4569            let k1 = thumb_pos(t1, &tick_frac);
4570            let active_l = k0.min(k1);
4571            let active_r = k0.max(k1);
4572
4573            *track_rect_p.borrow_mut() = Rect {
4574                x: track_x,
4575                y: rect.y,
4576                w: track_w,
4577                h: rect.h,
4578            };
4579
4580            let linactive_w = (active_l - gap - track_x).max(0.0);
4581            if linactive_w > 0.0 {
4582                scene.nodes.push(SceneNode::Rect {
4583                    rect: Rect {
4584                        x: track_x,
4585                        y: cy - track_h * 0.5,
4586                        w: linactive_w,
4587                        h: track_h,
4588                    },
4589                    brush: Brush::Solid(mul_c(inact_trk)),
4590                    radius: [corner; 4],
4591                });
4592                let sx0 = track_x + corner;
4593                scene.nodes.push(SceneNode::Ellipse {
4594                    rect: Rect {
4595                        x: sx0 - dot_r,
4596                        y: cy - dot_r,
4597                        w: dot_r * 2.0,
4598                        h: dot_r * 2.0,
4599                    },
4600                    brush: Brush::Solid(mul_c(inact_tick)),
4601                });
4602            }
4603            let rinactive_x = (active_r + gap).min(track_x + track_w);
4604            let rinactive_w = (track_x + track_w - rinactive_x).max(0.0);
4605            if rinactive_w > 0.0 {
4606                scene.nodes.push(SceneNode::Rect {
4607                    rect: Rect {
4608                        x: rinactive_x,
4609                        y: cy - track_h * 0.5,
4610                        w: rinactive_w,
4611                        h: track_h,
4612                    },
4613                    brush: Brush::Solid(mul_c(inact_trk)),
4614                    radius: [corner; 4],
4615                });
4616                let sx = track_x + track_w - corner;
4617                scene.nodes.push(SceneNode::Ellipse {
4618                    rect: Rect {
4619                        x: sx - dot_r,
4620                        y: cy - dot_r,
4621                        w: dot_r * 2.0,
4622                        h: dot_r * 2.0,
4623                    },
4624                    brush: Brush::Solid(mul_c(inact_tick)),
4625                });
4626            }
4627            let active_w = (active_r - gap - (active_l + gap)).max(0.0);
4628            if active_w > 0.0 {
4629                scene.nodes.push(SceneNode::Rect {
4630                    rect: Rect {
4631                        x: active_l + gap,
4632                        y: cy - track_h * 0.5,
4633                        w: active_w,
4634                        h: track_h,
4635                    },
4636                    brush: Brush::Solid(mul_c(act_trk)),
4637                    radius: [corner; 4],
4638                });
4639            }
4640            let tick_start = track_x + corner;
4641            let tick_end = track_x + track_w - corner;
4642            for (i, &tf) in tick_frac.iter().enumerate() {
4643                let tx = tick_start + tf * (tick_end - tick_start);
4644                // skip ticks that fall on the stop indicators (first and last)
4645                if i == 0 || i == tick_frac.len() - 1 {
4646                    continue;
4647                }
4648                let in_lgap = tx >= active_l - gap && tx <= active_l + gap;
4649                let in_rgap = tx >= active_r - gap && tx <= active_r + gap;
4650                if in_lgap || in_rgap {
4651                    continue;
4652                }
4653                let on_active = tx >= active_l + gap && tx <= active_r - gap;
4654                scene.nodes.push(SceneNode::Ellipse {
4655                    rect: Rect {
4656                        x: tx - dot_r,
4657                        y: cy - dot_r,
4658                        w: dot_r * 2.0,
4659                        h: dot_r * 2.0,
4660                    },
4661                    brush: Brush::Solid(mul_c(if on_active { act_tick } else { inact_tick })),
4662                });
4663            }
4664            let da = *drag_active_p.get();
4665            let at = *active_thumb_p.get();
4666            let hv = hovered_sig.get();
4667            let thumbs = [k0, k1];
4668            for (idx, &kx) in thumbs.iter().enumerate() {
4669                let is_active = da && (if idx == 0 { !at } else { at });
4670                let tw = if is_active { thumb_w * 0.5 } else { thumb_w };
4671                scene.nodes.push(SceneNode::Rect {
4672                    rect: Rect {
4673                        x: kx - tw * 0.5,
4674                        y: cy - thumb_h * 0.5,
4675                        w: tw,
4676                        h: thumb_h,
4677                    },
4678                    brush: Brush::Solid(mul_c(thumb_col)),
4679                    radius: [tw * 0.5; 4],
4680                });
4681                let sc_target = if !is_enabled {
4682                    Color::TRANSPARENT
4683                } else if is_active {
4684                    sc.pressed
4685                } else if hv {
4686                    sc.hovered
4687                } else {
4688                    sc.default
4689                };
4690                if sc_target.3 > 0 {
4691                    scene.nodes.push(SceneNode::Rect {
4692                        rect: Rect {
4693                            x: kx - tw * 0.5,
4694                            y: cy - thumb_h * 0.5,
4695                            w: tw,
4696                            h: thumb_h,
4697                        },
4698                        brush: Brush::Solid(mul_c(sc_target)),
4699                        radius: [tw * 0.5; 4],
4700                    });
4701                }
4702            }
4703        })
4704        .on_pointer_enter({
4705            let h = hovered.clone();
4706            let en = is_enabled;
4707            move |_pe: PointerEvent| {
4708                if en {
4709                    h.set(true);
4710                }
4711            }
4712        })
4713        .on_pointer_leave({
4714            let h = hovered.clone();
4715            move |_pe: PointerEvent| h.set(false)
4716        })
4717        .on_pointer_down({
4718            let oc = oc.clone();
4719            let track_rect = track_rect.clone();
4720            let drag_active = drag_active.clone();
4721            let active_thumb = active_thumb.clone();
4722            let en = is_enabled;
4723            move |pe: PointerEvent| {
4724                if !en {
4725                    return;
4726                }
4727                drag_active.set(true);
4728                let r = *track_rect.borrow();
4729                let v = value_from_x(pe.position.x, r, min, max, step);
4730                let use_end = (v - end).abs() < (v - start).abs();
4731                active_thumb.set(use_end);
4732                let (a, b) = if use_end {
4733                    (start, v.max(start))
4734                } else {
4735                    (v.min(end), end)
4736                };
4737                (oc)(a, b);
4738            }
4739        })
4740        .on_pointer_move({
4741            let oc = oc.clone();
4742            let track_rect = track_rect.clone();
4743            let drag_active = drag_active.clone();
4744            let active_thumb = active_thumb.clone();
4745            move |pe: PointerEvent| {
4746                if !*drag_active.get() {
4747                    return;
4748                }
4749                let r = *track_rect.borrow();
4750                let v = value_from_x(pe.position.x, r, min, max, step);
4751                let use_end = *active_thumb.get();
4752                let (a, b) = if use_end {
4753                    (start, v.max(start))
4754                } else {
4755                    (v.min(end), end)
4756                };
4757                (oc)(a, b);
4758            }
4759        })
4760        .on_pointer_up({
4761            let drag_active = drag_active.clone();
4762            let active_thumb = active_thumb.clone();
4763            move |_pe: PointerEvent| {
4764                drag_active.set(false);
4765                active_thumb.set(false);
4766            }
4767        })
4768        .on_scroll({
4769            let oc = oc.clone();
4770            let active_thumb = active_thumb.clone();
4771            let en = is_enabled;
4772            move |d: Vec2| -> Vec2 {
4773                if !en {
4774                    return d;
4775                }
4776                let dir = if d.y < -0.5 {
4777                    1
4778                } else if d.y > 0.5 {
4779                    -1
4780                } else {
4781                    0
4782                };
4783                if dir == 0 {
4784                    return d;
4785                }
4786                let step_val = step.unwrap_or(1.0).max(1e-6);
4787                let use_end = *active_thumb.get();
4788                let (mut a, mut b) = (start, end);
4789                if use_end {
4790                    b = snap_step(end + (dir as f32) * step_val, min, max, step).max(a);
4791                } else {
4792                    a = snap_step(start + (dir as f32) * step_val, min, max, step).min(b);
4793                }
4794                if (a - start).abs() > 1e-6 || (b - end).abs() > 1e-6 {
4795                    (oc)(a, b);
4796                    Vec2 { x: d.x, y: 0.0 }
4797                } else {
4798                    d
4799                }
4800            }
4801        })
4802        .then(config.modifier))
4803    .semantics(Semantics {
4804        role: Role::Slider,
4805        label: None,
4806        focused: false,
4807        enabled: is_enabled,
4808        selectable_group: false,
4809    })
4810}
4811
4812/// Configuration for [`Card`].
4813#[derive(Clone, Debug)]
4814pub struct CardConfig {
4815    pub modifier: Modifier,
4816    /// When false, renders disabled colors and does not respond to clicks.
4817    pub enabled: bool,
4818    pub container_color: Color,
4819    pub content_color: Color,
4820    pub disabled_container_color: Color,
4821    pub disabled_content_color: Color,
4822    pub shape_radius: f32,
4823    pub tonal_elevation: f32,
4824    pub state_elevation: Option<StateElevation>,
4825    pub border: Option<(f32, Color)>,
4826    pub interaction_source: Option<MutableInteractionSource>,
4827}
4828
4829impl Default for CardConfig {
4830    fn default() -> Self {
4831        Self {
4832            modifier: Modifier::new(),
4833            enabled: true,
4834            container_color: CardDefaults::filled_container_color(),
4835            content_color: CardDefaults::filled_content_color(),
4836            disabled_container_color: CardDefaults::disabled_container_color(),
4837            disabled_content_color: CardDefaults::disabled_content_color(),
4838            shape_radius: CardDefaults::SHAPE_RADIUS,
4839            tonal_elevation: CardDefaults::ELEVATION,
4840            state_elevation: None,
4841            border: None,
4842            interaction_source: None,
4843        }
4844    }
4845}
4846
4847/// M3 Card - a configurable container surface.
4848pub fn Card(config: CardConfig, content: impl FnOnce() -> View) -> View {
4849    let bg = if !config.enabled {
4850        config.disabled_container_color
4851    } else {
4852        config.container_color
4853    };
4854    let fg = if !config.enabled {
4855        config.disabled_content_color
4856    } else {
4857        config.content_color
4858    };
4859    let source: Rc<MutableInteractionSource> = config
4860        .interaction_source
4861        .clone()
4862        .map(Rc::new)
4863        .unwrap_or_else(|| remember(MutableInteractionSource::new));
4864    let mut m = Modifier::new()
4865        .background(bg)
4866        .clip_rounded(config.shape_radius)
4867        .interaction_source(&*source)
4868        .then(config.modifier);
4869    if let Some((w, c)) = config.border {
4870        m = m.border(w, c, config.shape_radius);
4871    }
4872    if let Some(se) = config.state_elevation {
4873        m = m.state_elevation(se);
4874    } else if config.tonal_elevation > 0.0 {
4875        m = m.state_elevation(StateElevation {
4876            default: config.tonal_elevation,
4877            hovered: config.tonal_elevation,
4878            pressed: config.tonal_elevation,
4879            disabled: 0.0,
4880        });
4881    }
4882    Box(m).color(fg).child(content())
4883}
4884
4885/// M3 Elevated Card - card with elevation.
4886pub fn ElevatedCard(config: CardConfig, content: impl FnOnce() -> View) -> View {
4887    let th = theme();
4888    Card(
4889        CardConfig {
4890            container_color: CardDefaults::elevated_container_color(),
4891            state_elevation: Some(StateElevation {
4892                default: th.elevation.level1,
4893                hovered: th.elevation.level2,
4894                pressed: th.elevation.level3,
4895                disabled: 0.0,
4896            }),
4897            ..config
4898        },
4899        content,
4900    )
4901}
4902
4903/// M3 Outlined Card - card with border outline.
4904pub fn OutlinedCard(config: CardConfig, content: impl FnOnce() -> View) -> View {
4905    Card(
4906        CardConfig {
4907            container_color: CardDefaults::outlined_container_color(),
4908            border: Some((1.0, CardDefaults::outlined_border_color())),
4909            ..config
4910        },
4911        content,
4912    )
4913}
4914
4915fn card_state_colors(bg: Color) -> StateColors {
4916    let th = theme();
4917    StateColors {
4918        default: Color::TRANSPARENT,
4919        hovered: th.on_surface.with_alpha_f32(0.08).composite_over(bg),
4920        pressed: th.on_surface.with_alpha_f32(0.12).composite_over(bg),
4921        disabled: th.on_surface.with_alpha_f32(0.12).composite_over(bg),
4922    }
4923}
4924
4925fn clickable_card_impl(
4926    on_click: impl Fn() + 'static,
4927    modifier: Modifier,
4928    bg: Color,
4929    shape_radius: f32,
4930    config: CardConfig,
4931    content: impl FnOnce() -> View,
4932) -> View {
4933    let m = modifier
4934        .state_colors(card_state_colors(bg))
4935        .clickable()
4936        .on_pointer_down({
4937            let cb = on_click;
4938            let en = config.enabled;
4939            move |_| {
4940                if en {
4941                    cb();
4942                }
4943            }
4944        });
4945    Card(
4946        CardConfig {
4947            modifier: m,
4948            enabled: config.enabled,
4949            container_color: bg,
4950            content_color: config.content_color,
4951            disabled_container_color: config.disabled_container_color,
4952            disabled_content_color: config.disabled_content_color,
4953            shape_radius,
4954            border: config.border,
4955            state_elevation: config.state_elevation,
4956            tonal_elevation: config.tonal_elevation,
4957            interaction_source: config.interaction_source.clone(),
4958        },
4959        || Column(Modifier::new().fill_max_size()).child(content()),
4960    )
4961}
4962
4963/// M3 Clickable Filled Card - interactive card with state coloring.
4964pub fn ClickableCard(
4965    on_click: impl Fn() + 'static,
4966    modifier: Modifier,
4967    config: CardConfig,
4968    content: impl FnOnce() -> View,
4969) -> View {
4970    let th = theme();
4971    clickable_card_impl(
4972        on_click,
4973        modifier,
4974        th.surface_container_highest,
4975        th.shapes.medium,
4976        config,
4977        content,
4978    )
4979}
4980
4981/// M3 Clickable Elevated Card - interactive card with elevation.
4982pub fn ClickableElevatedCard(
4983    on_click: impl Fn() + 'static,
4984    modifier: Modifier,
4985    config: CardConfig,
4986    content: impl FnOnce() -> View,
4987) -> View {
4988    let th = theme();
4989    let cfg = CardConfig {
4990        state_elevation: Some(StateElevation {
4991            default: th.elevation.level1,
4992            hovered: th.elevation.level2,
4993            pressed: th.elevation.level3,
4994            disabled: 0.0,
4995        }),
4996        ..config
4997    };
4998    clickable_card_impl(
4999        on_click,
5000        modifier,
5001        th.surface,
5002        th.shapes.medium,
5003        cfg,
5004        content,
5005    )
5006}
5007
5008/// M3 Clickable Outlined Card - interactive card with border.
5009pub fn ClickableOutlinedCard(
5010    on_click: impl Fn() + 'static,
5011    modifier: Modifier,
5012    config: CardConfig,
5013    content: impl FnOnce() -> View,
5014) -> View {
5015    let th = theme();
5016    let cfg = CardConfig {
5017        border: Some((1.0, th.outline_variant)),
5018        ..config
5019    };
5020    clickable_card_impl(
5021        on_click,
5022        modifier,
5023        th.surface,
5024        th.shapes.medium,
5025        cfg,
5026        content,
5027    )
5028}
5029
5030/// Configuration for [`Snackbar`].
5031#[derive(Clone, Debug)]
5032pub struct SnackbarConfig {
5033    pub modifier: Modifier,
5034    pub container_color: Color,
5035    pub content_color: Color,
5036    pub action_color: Color,
5037    pub dismiss_action_content_color: Color,
5038    pub action_on_new_line: bool,
5039    pub shape_radius: f32,
5040    pub min_height: f32,
5041    pub min_width: f32,
5042    pub max_width: f32,
5043}
5044
5045impl Default for SnackbarConfig {
5046    fn default() -> Self {
5047        Self {
5048            modifier: Modifier::new(),
5049            container_color: SnackbarDefaults::container_color(),
5050            content_color: SnackbarDefaults::content_color(),
5051            action_color: SnackbarDefaults::action_color(),
5052            dismiss_action_content_color: SnackbarDefaults::dismiss_action_content_color(),
5053            action_on_new_line: false,
5054            shape_radius: SnackbarDefaults::SHAPE_RADIUS,
5055            min_height: SnackbarDefaults::MIN_HEIGHT,
5056            min_width: SnackbarDefaults::MIN_WIDTH,
5057            max_width: SnackbarDefaults::MAX_WIDTH,
5058        }
5059    }
5060}
5061
5062/// Color slots for chips (both non-selectable and selectable).
5063#[derive(Clone, Copy, Debug)]
5064pub struct ChipColors {
5065    pub container_color: Color,
5066    pub label_color: Color,
5067    pub leading_icon_color: Color,
5068    pub trailing_icon_color: Color,
5069    pub disabled_container_color: Color,
5070    pub disabled_label_color: Color,
5071    pub disabled_leading_icon_color: Color,
5072    pub disabled_trailing_icon_color: Color,
5073    pub selected_container_color: Color,
5074    pub selected_label_color: Color,
5075    pub selected_leading_icon_color: Color,
5076    pub selected_trailing_icon_color: Color,
5077    pub disabled_selected_container_color: Color,
5078}
5079
5080impl ChipColors {
5081    pub fn container(&self, enabled: bool, selected: bool) -> Color {
5082        match (enabled, selected) {
5083            (true, true) => self.selected_container_color,
5084            (true, false) => self.container_color,
5085            (false, true) => self.disabled_selected_container_color,
5086            (false, false) => self.disabled_container_color,
5087        }
5088    }
5089    pub fn label(&self, enabled: bool, selected: bool) -> Color {
5090        if !enabled {
5091            self.disabled_label_color
5092        } else if selected {
5093            self.selected_label_color
5094        } else {
5095            self.label_color
5096        }
5097    }
5098    pub fn leading_icon(&self, enabled: bool, selected: bool) -> Color {
5099        if !enabled {
5100            self.disabled_leading_icon_color
5101        } else if selected {
5102            self.selected_leading_icon_color
5103        } else {
5104            self.leading_icon_color
5105        }
5106    }
5107    pub fn trailing_icon(&self, enabled: bool, selected: bool) -> Color {
5108        if !enabled {
5109            self.disabled_trailing_icon_color
5110        } else if selected {
5111            self.selected_trailing_icon_color
5112        } else {
5113            self.trailing_icon_color
5114        }
5115    }
5116}
5117
5118/// Elevation levels for chips.
5119#[derive(Clone, Copy, Debug)]
5120pub struct ChipElevation {
5121    pub default: f32,
5122    pub hovered: f32,
5123    pub focused: f32,
5124    pub pressed: f32,
5125    pub dragged: f32,
5126    pub disabled: f32,
5127}
5128
5129impl ChipElevation {
5130    pub fn to_state_elevation(&self) -> StateElevation {
5131        StateElevation {
5132            default: self.default,
5133            hovered: self.hovered,
5134            pressed: self.pressed,
5135            disabled: self.disabled,
5136        }
5137    }
5138}
5139
5140impl Default for ChipElevation {
5141    fn default() -> Self {
5142        Self {
5143            default: ChipDefaults::elevation_default(),
5144            hovered: ChipDefaults::elevation_hovered(),
5145            focused: ChipDefaults::elevation_focused(),
5146            pressed: ChipDefaults::elevation_pressed(),
5147            dragged: ChipDefaults::elevation_dragged(),
5148            disabled: ChipDefaults::elevation_disabled(),
5149        }
5150    }
5151}
5152
5153/// Configuration for chips.
5154#[derive(Clone, Debug)]
5155pub struct ChipConfig {
5156    pub modifier: Modifier,
5157    pub enabled: bool,
5158    pub colors: ChipColors,
5159    pub elevation: ChipElevation,
5160    pub border_width: f32,
5161    pub border_color: Color,
5162    pub selected_border_color: Color,
5163    pub disabled_border_color: Color,
5164    pub disabled_selected_border_color: Color,
5165    pub shape_radius: f32,
5166    pub horizontal_padding: f32,
5167    pub interaction_source: Option<MutableInteractionSource>,
5168}
5169
5170impl Default for ChipConfig {
5171    fn default() -> Self {
5172        Self {
5173            modifier: Modifier::new(),
5174            enabled: true,
5175            colors: ChipColors {
5176                container_color: ChipDefaults::container_color(),
5177                label_color: ChipDefaults::label_color(),
5178                leading_icon_color: ChipDefaults::leading_icon_color(),
5179                trailing_icon_color: ChipDefaults::trailing_icon_color(),
5180                disabled_container_color: ChipDefaults::disabled_container_color(),
5181                disabled_label_color: ChipDefaults::disabled_label_color(),
5182                disabled_leading_icon_color: ChipDefaults::disabled_leading_icon_color(),
5183                disabled_trailing_icon_color: ChipDefaults::disabled_trailing_icon_color(),
5184                selected_container_color: ChipDefaults::selected_container_color(),
5185                selected_label_color: ChipDefaults::selected_label_color(),
5186                selected_leading_icon_color: ChipDefaults::selected_leading_icon_color(),
5187                selected_trailing_icon_color: ChipDefaults::selected_trailing_icon_color(),
5188                disabled_selected_container_color: ChipDefaults::disabled_selected_container_color(
5189                ),
5190            },
5191            elevation: ChipElevation::default(),
5192            border_width: ChipDefaults::BORDER_WIDTH,
5193            border_color: ChipDefaults::border_color(),
5194            selected_border_color: ChipDefaults::selected_border_color(),
5195            disabled_border_color: ChipDefaults::disabled_border_color(),
5196            disabled_selected_border_color: ChipDefaults::disabled_selected_border_color(),
5197            shape_radius: ChipDefaults::SHAPE_RADIUS,
5198            horizontal_padding: ChipDefaults::HORIZONTAL_PADDING,
5199            interaction_source: None,
5200        }
5201    }
5202}
5203
5204/// M3 Assist Chip - a chip for triggering actions.
5205pub fn AssistChip(
5206    on_click: impl Fn() + 'static,
5207    label: View,
5208    leading_icon: Option<View>,
5209    trailing_icon: Option<View>,
5210    config: ChipConfig,
5211) -> View {
5212    let th = theme();
5213    let is_enabled = config.enabled;
5214    let colors = &config.colors;
5215    let bg = colors.container(is_enabled, false);
5216    let label_color = colors.label(is_enabled, false);
5217    let leading_color = colors.leading_icon(is_enabled, false);
5218    let trailing_color = colors.trailing_icon(is_enabled, false);
5219    let border = if is_enabled {
5220        config.border_color
5221    } else {
5222        config.disabled_border_color
5223    };
5224    let shape = config.shape_radius;
5225    let ch_source: Rc<MutableInteractionSource> = config
5226        .interaction_source
5227        .clone()
5228        .map(Rc::new)
5229        .unwrap_or_else(|| remember(MutableInteractionSource::new));
5230
5231    let mut m = Modifier::new()
5232        .state_colors(StateColors {
5233            default: Color::TRANSPARENT,
5234            hovered: th.on_surface.with_alpha_f32(0.08),
5235            pressed: th.on_surface.with_alpha_f32(0.12),
5236            disabled: Color::TRANSPARENT,
5237        })
5238        .padding_values(PaddingValues {
5239            left: config.horizontal_padding,
5240            right: config.horizontal_padding,
5241            top: 8.0,
5242            bottom: 8.0,
5243        })
5244        .background(bg)
5245        .clip_rounded(shape)
5246        .interaction_source(&*ch_source)
5247        .then(config.modifier);
5248
5249    if config.border_width > 0.0 && border != Color::TRANSPARENT {
5250        m = m.border(config.border_width, border, shape);
5251    }
5252
5253    if is_enabled {
5254        m = m.clickable().on_click(move || on_click());
5255    }
5256
5257    Box(m).child(
5258        Row(Modifier::new().align_items(AlignItems::CENTER)).child((
5259            leading_icon
5260                .map(|v| {
5261                    Box(Modifier::new().padding_values(PaddingValues {
5262                        left: 0.0,
5263                        right: 8.0,
5264                        top: 0.0,
5265                        bottom: 0.0,
5266                    }))
5267                    .child(with_content_color(leading_color, move || v))
5268                })
5269                .unwrap_or(Box(Modifier::new())),
5270            with_content_color(label_color, move || label),
5271            trailing_icon
5272                .map(|v| {
5273                    Box(Modifier::new().padding_values(PaddingValues {
5274                        left: 8.0,
5275                        right: 0.0,
5276                        top: 0.0,
5277                        bottom: 0.0,
5278                    }))
5279                    .child(with_content_color(trailing_color, move || v))
5280                })
5281                .unwrap_or(Box(Modifier::new())),
5282        )),
5283    )
5284}
5285
5286/// M3 Elevated Assist Chip - like [`AssistChip`] but with elevated container.
5287pub fn ElevatedAssistChip(
5288    on_click: impl Fn() + 'static,
5289    label: View,
5290    leading_icon: Option<View>,
5291    trailing_icon: Option<View>,
5292    config: ChipConfig,
5293) -> View {
5294    let th = theme();
5295    let is_enabled = config.enabled;
5296    let colors = &config.colors;
5297    let bg = colors.container(is_enabled, false);
5298    let label_color = colors.label(is_enabled, false);
5299    let leading_color = colors.leading_icon(is_enabled, false);
5300    let trailing_color = colors.trailing_icon(is_enabled, false);
5301    let shape = config.shape_radius;
5302
5303    let mut m = Modifier::new()
5304        .state_colors(StateColors {
5305            default: Color::TRANSPARENT,
5306            hovered: th.on_surface.with_alpha_f32(0.08),
5307            pressed: th.on_surface.with_alpha_f32(0.12),
5308            disabled: Color::TRANSPARENT,
5309        })
5310        .state_elevation(config.elevation.to_state_elevation())
5311        .padding_values(PaddingValues {
5312            left: config.horizontal_padding,
5313            right: config.horizontal_padding,
5314            top: 8.0,
5315            bottom: 8.0,
5316        })
5317        .background(bg)
5318        .clip_rounded(shape)
5319        .then(config.modifier);
5320
5321    if is_enabled {
5322        m = m.clickable().on_click(move || on_click());
5323    }
5324
5325    Box(m).child(
5326        Row(Modifier::new().align_items(AlignItems::CENTER)).child((
5327            leading_icon
5328                .map(|v| {
5329                    Box(Modifier::new().padding_values(PaddingValues {
5330                        left: 0.0,
5331                        right: 8.0,
5332                        top: 0.0,
5333                        bottom: 0.0,
5334                    }))
5335                    .child(with_content_color(leading_color, move || v))
5336                })
5337                .unwrap_or(Box(Modifier::new())),
5338            with_content_color(label_color, move || label),
5339            trailing_icon
5340                .map(|v| {
5341                    Box(Modifier::new().padding_values(PaddingValues {
5342                        left: 8.0,
5343                        right: 0.0,
5344                        top: 0.0,
5345                        bottom: 0.0,
5346                    }))
5347                    .child(with_content_color(trailing_color, move || v))
5348                })
5349                .unwrap_or(Box(Modifier::new())),
5350        )),
5351    )
5352}
5353
5354/// Configuration for [`NavigationBar`].
5355#[derive(Clone, Debug)]
5356pub struct NavigationBarConfig {
5357    pub modifier: Modifier,
5358    pub container_color: Color,
5359    pub content_color: Color,
5360    pub selected_icon_color: Color,
5361    pub selected_text_color: Color,
5362    pub unselected_icon_color: Color,
5363    pub unselected_text_color: Color,
5364    pub indicator_color: Color,
5365    pub height: f32,
5366    pub tonal_elevation: f32,
5367    pub indicator_opacity: f32,
5368    pub indicator_radius: f32,
5369    pub item_spacing: f32,
5370    pub indicator_width: f32,
5371    pub indicator_height: f32,
5372}
5373
5374impl Default for NavigationBarConfig {
5375    fn default() -> Self {
5376        Self {
5377            modifier: Modifier::new(),
5378            container_color: NavigationBarDefaults::container_color(),
5379            content_color: NavigationBarDefaults::content_color(),
5380            selected_icon_color: NavigationBarDefaults::selected_icon_color(),
5381            selected_text_color: NavigationBarDefaults::selected_text_color(),
5382            unselected_icon_color: NavigationBarDefaults::unselected_icon_color(),
5383            unselected_text_color: NavigationBarDefaults::unselected_text_color(),
5384            indicator_color: NavigationBarDefaults::indicator_color(),
5385            height: NavigationBarDefaults::HEIGHT,
5386            tonal_elevation: NavigationBarDefaults::TONAL_ELEVATION,
5387            indicator_opacity: NavigationBarDefaults::ITEM_ACTIVE_INDICATOR_OPACITY,
5388            indicator_radius: NavigationBarDefaults::INDICATOR_RADIUS,
5389            item_spacing: NavigationBarDefaults::ITEM_SPACING,
5390            indicator_width: NavigationBarDefaults::ACTIVE_INDICATOR_WIDTH,
5391            indicator_height: NavigationBarDefaults::ACTIVE_INDICATOR_HEIGHT,
5392        }
5393    }
5394}
5395
5396/// Configuration for [`NavigationRail`].
5397#[derive(Clone, Debug)]
5398pub struct NavigationRailConfig {
5399    pub modifier: Modifier,
5400    pub container_color: Color,
5401    pub selected_icon_color: Color,
5402    pub selected_text_color: Color,
5403    pub unselected_icon_color: Color,
5404    pub unselected_text_color: Color,
5405    pub indicator_color: Color,
5406    pub width: f32,
5407    pub item_radius: f32,
5408    pub indicator_opacity: f32,
5409    pub item_spacing: f32,
5410    pub indicator_width: f32,
5411    pub indicator_height: f32,
5412}
5413
5414impl Default for NavigationRailConfig {
5415    fn default() -> Self {
5416        Self {
5417            modifier: Modifier::new(),
5418            container_color: NavigationRailDefaults::container_color(),
5419            selected_icon_color: NavigationRailDefaults::selected_icon_color(),
5420            selected_text_color: NavigationRailDefaults::selected_text_color(),
5421            unselected_icon_color: NavigationRailDefaults::unselected_icon_color(),
5422            unselected_text_color: NavigationRailDefaults::unselected_text_color(),
5423            indicator_color: NavigationRailDefaults::indicator_color(),
5424            width: NavigationRailDefaults::WIDTH,
5425            item_radius: NavigationRailDefaults::ITEM_RADIUS,
5426            indicator_opacity: NavigationRailDefaults::ITEM_ACTIVE_INDICATOR_OPACITY,
5427            item_spacing: NavigationRailDefaults::ITEM_SPACING,
5428            indicator_width: NavigationRailDefaults::ACTIVE_INDICATOR_WIDTH,
5429            indicator_height: NavigationRailDefaults::ACTIVE_INDICATOR_HEIGHT,
5430        }
5431    }
5432}
5433
5434/// Configuration for [`Scaffold`].
5435#[derive(Clone, Debug)]
5436pub struct ScaffoldConfig {
5437    pub modifier: Modifier,
5438    pub container_color: Color,
5439    pub top_bar_height: f32,
5440    pub bottom_bar_height: f32,
5441    pub fab_margin: f32,
5442}
5443
5444impl Default for ScaffoldConfig {
5445    fn default() -> Self {
5446        Self {
5447            modifier: Modifier::new(),
5448            container_color: ScaffoldDefaults::container_color(),
5449            top_bar_height: ScaffoldDefaults::TOP_BAR_HEIGHT,
5450            bottom_bar_height: ScaffoldDefaults::BOTTOM_BAR_HEIGHT,
5451            fab_margin: ScaffoldDefaults::FAB_MARGIN,
5452        }
5453    }
5454}
5455
5456/// Configuration for [`NavigationDrawer`].
5457#[derive(Clone, Debug)]
5458pub struct NavigationDrawerConfig {
5459    pub modifier: Modifier,
5460    pub container_color: Color,
5461    pub content_color: Color,
5462    pub scrim_color: Color,
5463    pub tonal_elevation: f32,
5464    pub width: f32,
5465    pub shape_radius: f32,
5466}
5467
5468impl Default for NavigationDrawerConfig {
5469    fn default() -> Self {
5470        Self {
5471            modifier: Modifier::new(),
5472            container_color: NavigationDrawerDefaults::container_color(),
5473            content_color: NavigationDrawerDefaults::content_color(),
5474            scrim_color: NavigationDrawerDefaults::scrim_color(),
5475            tonal_elevation: NavigationDrawerDefaults::TONAL_ELEVATION,
5476            width: NavigationDrawerDefaults::WIDTH,
5477            shape_radius: NavigationDrawerDefaults::SHAPE_RADIUS,
5478        }
5479    }
5480}
5481
5482/// Configuration for [`BottomSheet`] / `ModalBottomSheet`.
5483#[derive(Clone, Debug)]
5484pub struct BottomSheetConfig {
5485    pub modifier: Modifier,
5486    pub container_color: Color,
5487    pub content_color: Color,
5488    pub scrim_color: Color,
5489    pub tonal_elevation: f32,
5490    pub shadow_elevation: f32,
5491    pub drag_handle_color: Color,
5492    pub shape_radius: f32,
5493    pub max_width: f32,
5494    pub drag_handle_width: f32,
5495    pub drag_handle_height: f32,
5496    pub peek_height: f32,
5497    pub gestures_enabled: bool,
5498}
5499
5500impl Default for BottomSheetConfig {
5501    fn default() -> Self {
5502        Self {
5503            modifier: Modifier::new(),
5504            container_color: BottomSheetDefaults::container_color(),
5505            content_color: BottomSheetDefaults::content_color(),
5506            scrim_color: BottomSheetDefaults::scrim_color(),
5507            tonal_elevation: BottomSheetDefaults::TONAL_ELEVATION,
5508            shadow_elevation: 0.0,
5509            drag_handle_color: BottomSheetDefaults::drag_handle_color(),
5510            shape_radius: BottomSheetDefaults::SHAPE_RADIUS,
5511            max_width: BottomSheetDefaults::MAX_WIDTH,
5512            drag_handle_width: BottomSheetDefaults::DRAG_HANDLE_WIDTH,
5513            drag_handle_height: BottomSheetDefaults::DRAG_HANDLE_HEIGHT,
5514            peek_height: BottomSheetDefaults::PEEK_HEIGHT,
5515            gestures_enabled: true,
5516        }
5517    }
5518}
5519
5520/// Color slots for [`SearchBar`]. Matches Compose Material3 `SearchBarColors`.
5521#[derive(Clone, Copy, Debug)]
5522pub struct SearchBarColors {
5523    pub container_color: Color,
5524    pub active_container_color: Color,
5525    pub divider_color: Color,
5526    pub content_color: Color,
5527    pub placeholder_color: Color,
5528    pub scrim_color: Color,
5529}
5530
5531impl SearchBarColors {
5532    pub fn container(&self, active: bool) -> Color {
5533        if active {
5534            self.active_container_color
5535        } else {
5536            self.container_color
5537        }
5538    }
5539}
5540
5541impl Default for SearchBarColors {
5542    fn default() -> Self {
5543        Self {
5544            container_color: SearchBarDefaults::container_color(),
5545            active_container_color: SearchBarDefaults::active_container_color(),
5546            divider_color: SearchBarDefaults::divider_color(),
5547            content_color: SearchBarDefaults::content_color(),
5548            placeholder_color: SearchBarDefaults::placeholder_color(),
5549            scrim_color: SearchBarDefaults::scrim_color(),
5550        }
5551    }
5552}
5553
5554/// Color slots for [`AppBarWithSearch`]. Scrolled/not-scrolled pairs.
5555#[derive(Clone, Copy, Debug)]
5556pub struct AppBarWithSearchColors {
5557    pub search_bar_colors: SearchBarColors,
5558    pub scrolled_search_bar_container_color: Color,
5559    pub app_bar_container_color: Color,
5560    pub scrolled_app_bar_container_color: Color,
5561    pub navigation_icon_content_color: Color,
5562    pub action_icon_content_color: Color,
5563}
5564
5565impl AppBarWithSearchColors {
5566    pub fn search_bar_container(&self, scroll_fraction: f32) -> Color {
5567        lerp_color(
5568            self.search_bar_colors.container_color,
5569            self.scrolled_search_bar_container_color,
5570            scroll_fraction.clamp(0.0, 1.0),
5571        )
5572    }
5573    pub fn app_bar_container(&self, scroll_fraction: f32) -> Color {
5574        lerp_color(
5575            self.app_bar_container_color,
5576            self.scrolled_app_bar_container_color,
5577            scroll_fraction.clamp(0.0, 1.0),
5578        )
5579    }
5580}
5581
5582impl Default for AppBarWithSearchColors {
5583    fn default() -> Self {
5584        Self {
5585            search_bar_colors: SearchBarColors::default(),
5586            scrolled_search_bar_container_color: SearchBarDefaults::scrolled_container_color(),
5587            app_bar_container_color: SearchBarDefaults::app_bar_container_color(),
5588            scrolled_app_bar_container_color: SearchBarDefaults::scrolled_app_bar_container_color(),
5589            navigation_icon_content_color: SearchBarDefaults::navigation_icon_content_color(),
5590            action_icon_content_color: SearchBarDefaults::action_icon_content_color(),
5591        }
5592    }
5593}
5594
5595/// Configuration for [`SearchBar`].
5596#[derive(Clone, Debug)]
5597pub struct SearchBarConfig {
5598    pub modifier: Modifier,
5599    pub colors: SearchBarColors,
5600    pub height: f32,
5601    pub shape_radius: f32,
5602    pub active_shape_radius: f32,
5603    pub expanded_width: f32,
5604    pub collapsed_width: f32,
5605    pub tonal_elevation: f32,
5606    pub shadow_elevation: f32,
5607    pub window_insets: WindowInsets,
5608    pub content_padding: PaddingValues,
5609    pub min_width: f32,
5610    pub max_width: f32,
5611}
5612
5613impl Default for SearchBarConfig {
5614    fn default() -> Self {
5615        Self {
5616            modifier: Modifier::new(),
5617            colors: SearchBarColors::default(),
5618            height: SearchBarDefaults::HEIGHT,
5619            shape_radius: SearchBarDefaults::SHAPE_RADIUS,
5620            active_shape_radius: SearchBarDefaults::ACTIVE_SHAPE_RADIUS,
5621            expanded_width: SearchBarDefaults::EXPANDED_WIDTH,
5622            collapsed_width: SearchBarDefaults::COLLAPSED_WIDTH,
5623            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
5624            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
5625            window_insets: WindowInsets::default(),
5626            content_padding: SearchBarDefaults::CONTENT_PADDING,
5627            min_width: SearchBarDefaults::MIN_WIDTH,
5628            max_width: SearchBarDefaults::MAX_WIDTH,
5629        }
5630    }
5631}
5632
5633/// Configuration for [`ExpandedFullScreenSearchBar`].
5634#[derive(Clone, Debug)]
5635pub struct ExpandedFullScreenSearchBarConfig {
5636    pub modifier: Modifier,
5637    pub colors: SearchBarColors,
5638    pub collapsed_shape_radius: f32,
5639    pub tonal_elevation: f32,
5640    pub shadow_elevation: f32,
5641    pub window_insets: WindowInsets,
5642    pub scrim_color: Color,
5643}
5644
5645impl Default for ExpandedFullScreenSearchBarConfig {
5646    fn default() -> Self {
5647        Self {
5648            modifier: Modifier::new(),
5649            colors: SearchBarColors::default(),
5650            collapsed_shape_radius: SearchBarDefaults::SHAPE_RADIUS,
5651            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
5652            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
5653            window_insets: WindowInsets::default(),
5654            scrim_color: SearchBarDefaults::scrim_color(),
5655        }
5656    }
5657}
5658
5659/// Configuration for [`ExpandedDockedSearchBar`].
5660#[derive(Clone, Debug)]
5661pub struct ExpandedDockedSearchBarConfig {
5662    pub modifier: Modifier,
5663    pub colors: SearchBarColors,
5664    pub shape_radius: f32,
5665    pub dropdown_shape_radius: f32,
5666    pub dropdown_gap_size: f32,
5667    pub dropdown_scrim_color: Color,
5668    pub tonal_elevation: f32,
5669    pub shadow_elevation: f32,
5670}
5671
5672impl Default for ExpandedDockedSearchBarConfig {
5673    fn default() -> Self {
5674        Self {
5675            modifier: Modifier::new(),
5676            colors: SearchBarColors::default(),
5677            shape_radius: SearchBarDefaults::DOCKED_SHAPE_RADIUS,
5678            dropdown_shape_radius: SearchBarDefaults::DROPDOWN_SHAPE_RADIUS,
5679            dropdown_gap_size: SearchBarDefaults::DROPDOWN_GAP_SIZE,
5680            dropdown_scrim_color: SearchBarDefaults::dropdown_scrim_color(),
5681            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
5682            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
5683        }
5684    }
5685}
5686
5687/// Configuration for [`AppBarWithSearch`].
5688#[derive(Clone, Debug)]
5689pub struct AppBarWithSearchConfig {
5690    pub modifier: Modifier,
5691    pub colors: AppBarWithSearchColors,
5692    pub height: f32,
5693    pub shape_radius: f32,
5694    pub tonal_elevation: f32,
5695    pub shadow_elevation: f32,
5696    pub content_padding: PaddingValues,
5697    pub window_insets: WindowInsets,
5698    pub scroll_fraction: f32,
5699    pub scroll_offset: f32,
5700}
5701
5702impl Default for AppBarWithSearchConfig {
5703    fn default() -> Self {
5704        Self {
5705            modifier: Modifier::new(),
5706            colors: AppBarWithSearchColors::default(),
5707            height: SearchBarDefaults::HEIGHT,
5708            shape_radius: SearchBarDefaults::SHAPE_RADIUS,
5709            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
5710            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
5711            content_padding: SearchBarDefaults::CONTENT_PADDING,
5712            window_insets: WindowInsets::default(),
5713            scroll_fraction: 0.0,
5714            scroll_offset: 0.0,
5715        }
5716    }
5717}
5718
5719/// Scroll behavior for [`AppBarWithSearch`] -> collapses/expands on scroll.
5720pub struct SearchBarScrollBehavior {
5721    pub collapsed_offset: Signal<f32>,
5722    pub height: f32,
5723    pub collapsed_height: f32,
5724    _pending: Rc<Cell<f32>>,
5725}
5726
5727impl SearchBarScrollBehavior {
5728    pub fn new(height: f32, collapsed_height: f32) -> Self {
5729        Self {
5730            collapsed_offset: signal(0.0),
5731            height,
5732            collapsed_height,
5733            _pending: Rc::new(Cell::new(0.0)),
5734        }
5735    }
5736
5737    pub fn offset(&self) -> f32 {
5738        self.collapsed_offset.get()
5739    }
5740
5741    pub fn nested_scroll_connection(&self) -> NestedScrollConnection {
5742        let offset = self.collapsed_offset.clone();
5743        let max_offset = self.height - self.collapsed_height;
5744        NestedScrollConnection::new().on_pre_scroll(move |delta: Vec2, _source| {
5745            let cur = offset.get();
5746            let new = (cur - delta.y).clamp(-max_offset, 0.0);
5747            let consumed = cur - new;
5748            offset.set(new);
5749            request_frame();
5750            Vec2 {
5751                x: 0.0,
5752                y: consumed,
5753            }
5754        })
5755    }
5756}
5757
5758/// Configuration for [`DropdownMenu`].
5759#[derive(Clone, Debug)]
5760pub struct DropdownMenuConfig {
5761    pub modifier: Modifier,
5762    pub container_color: Color,
5763    pub item_text_color: Color,
5764    pub disabled_item_text_color: Color,
5765    pub divider_color: Color,
5766    pub min_width: f32,
5767    pub item_height: f32,
5768    pub max_width: f32,
5769    pub shadow_elevation: Option<f32>,
5770    pub tonal_elevation: f32,
5771    pub border: Option<(f32, Color, f32)>,
5772    pub shape_radius: Option<f32>,
5773    pub offset_x: f32,
5774    pub offset_y: f32,
5775    pub vertical_margin: f32,
5776}
5777
5778impl Default for DropdownMenuConfig {
5779    fn default() -> Self {
5780        Self {
5781            modifier: Modifier::new(),
5782            container_color: DropdownMenuDefaults::container_color(),
5783            item_text_color: DropdownMenuDefaults::item_text_color(),
5784            disabled_item_text_color: DropdownMenuDefaults::disabled_item_text_color(),
5785            divider_color: DropdownMenuDefaults::divider_color(),
5786            min_width: DropdownMenuDefaults::MIN_WIDTH,
5787            item_height: DropdownMenuDefaults::ITEM_HEIGHT,
5788            max_width: DropdownMenuDefaults::MAX_WIDTH,
5789            shadow_elevation: None,
5790            tonal_elevation: 0.0,
5791            border: None,
5792            shape_radius: None,
5793            offset_x: 0.0,
5794            offset_y: 0.0,
5795            vertical_margin: DropdownMenuDefaults::VERTICAL_MARGIN,
5796        }
5797    }
5798}
5799
5800/// Configuration for tooltip.
5801#[derive(Clone, Debug)]
5802pub struct TooltipConfig {
5803    pub modifier: Modifier,
5804    pub container_color: Color,
5805    pub content_color: Color,
5806    pub offset_y: f32,
5807    pub horizontal_padding: f32,
5808    pub vertical_padding: f32,
5809    pub has_action: bool,
5810    pub enable_user_input: bool,
5811    pub focusable: bool,
5812    pub max_width: f32,
5813    pub tonal_elevation: f32,
5814    pub shadow_elevation: f32,
5815}
5816
5817impl Default for TooltipConfig {
5818    fn default() -> Self {
5819        Self {
5820            modifier: Modifier::new(),
5821            container_color: TooltipDefaults::container_color(),
5822            content_color: TooltipDefaults::content_color(),
5823            offset_y: TooltipDefaults::OFFSET_Y,
5824            horizontal_padding: TooltipDefaults::HORIZONTAL_PADDING,
5825            vertical_padding: TooltipDefaults::VERTICAL_PADDING,
5826            has_action: false,
5827            enable_user_input: true,
5828            focusable: false,
5829            max_width: TooltipDefaults::MAX_WIDTH,
5830            tonal_elevation: 0.0,
5831            shadow_elevation: 0.0,
5832        }
5833    }
5834}
5835
5836/// Configuration for swipe-to-dismiss.
5837#[derive(Clone, Debug)]
5838pub struct SwipeToDismissConfig {
5839    pub modifier: Modifier,
5840    pub dismiss_threshold: f32,
5841    pub dismissed_offset: f32,
5842    pub animation_spec: AnimationSpec,
5843    pub gestures_enabled: bool,
5844    pub enable_dismiss_from_start_to_end: bool,
5845    pub enable_dismiss_from_end_to_start: bool,
5846}
5847
5848impl Default for SwipeToDismissConfig {
5849    fn default() -> Self {
5850        Self {
5851            modifier: Modifier::new(),
5852            dismiss_threshold: SwipeToDismissDefaults::DISMISS_THRESHOLD,
5853            dismissed_offset: SwipeToDismissDefaults::DISMISSED_OFFSET,
5854            animation_spec: AnimationSpec::spring_gentle(),
5855            gestures_enabled: true,
5856            enable_dismiss_from_start_to_end: true,
5857            enable_dismiss_from_end_to_start: true,
5858        }
5859    }
5860}
5861
5862/// Configuration for pull-to-refresh.
5863#[derive(Clone, Debug)]
5864pub struct PullToRefreshConfig {
5865    pub modifier: Modifier,
5866    pub indicator_color: Color,
5867    pub threshold: f32,
5868    pub content_alignment: AlignItems,
5869}
5870
5871impl Default for PullToRefreshConfig {
5872    fn default() -> Self {
5873        Self {
5874            modifier: Modifier::new(),
5875            indicator_color: PullToRefreshDefaults::indicator_color(),
5876            threshold: PullToRefreshDefaults::THRESHOLD,
5877            content_alignment: AlignItems::FLEX_START,
5878        }
5879    }
5880}