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