Skip to main content

repose_material/material3/
components.rs

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