Skip to main content

repose_material/material3/
components.rs

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