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