Skip to main content

repose_material/material3/
mod.rs

1#![allow(non_snake_case)]
2
3pub mod defaults;
4pub use defaults::*;
5
6mod components;
7pub use components::*;
8
9pub mod dialog;
10pub use dialog::*;
11
12pub mod advbuttons;
13pub use advbuttons::*;
14
15use std::cell::{Cell, RefCell};
16use std::rc::Rc;
17use std::sync::atomic::{AtomicU64, Ordering};
18use web_time::Duration;
19
20use crate::ripple::{RippleConfig, ripple};
21use crate::{Icon, Symbol};
22use repose_core::NestedScrollConnection;
23use repose_core::animation::{AnimationSpec, Easing, RepeatableSpec};
24use repose_core::text::ImeAction;
25use repose_core::*;
26use repose_ui::LazyRowState;
27use repose_ui::lazy::LazyRow;
28use repose_ui::lazy_states::LazyRowConfig;
29use repose_ui::{
30    BasicSecureTextField, BasicTextField, Box, Column, Row, Spacer, Text, TextFieldState,
31    TextStyle, ViewExt, ZStack,
32    anim::{animate_color, animate_f32, animate_f32_from},
33    overlay::OverlayHandle,
34    overlay::SnackbarAction,
35    overlay::snackbar_is_dismissing,
36};
37
38pub(crate) fn alert_dialog_body(
39    title: View,
40    text: View,
41    confirm_button: View,
42    dismiss_button: Option<View>,
43) -> View {
44    Column(Modifier::new()).child((
45        title,
46        Box(Modifier::new().fill_max_width().height(16.0)),
47        text,
48        Spacer(),
49        Row(Modifier::new()).child((
50            dismiss_button.unwrap_or(Box(Modifier::new())),
51            Spacer(),
52            confirm_button,
53        )),
54    ))
55}
56
57static BOTTOMSHEET_COUNTER: AtomicU64 = AtomicU64::new(0);
58
59static TOOLTIP_COUNTER: AtomicU64 = AtomicU64::new(0);
60
61pub fn BottomSheet(
62    visible: bool,
63    on_dismiss: impl Fn() + 'static,
64    modifier: Modifier,
65    content: View,
66    config: BottomSheetConfig, // HACK: use ot
67) -> View {
68    let th = theme();
69    let id = remember(|| BOTTOMSHEET_COUNTER.fetch_add(1, Ordering::Relaxed));
70
71    let opacity = animate_f32_from(
72        format!("bs_opacity_{id}"),
73        if visible { 0.0 } else { 1.0 },
74        if visible { 1.0 } else { 0.0 },
75        th.motion.layout,
76    );
77
78    let keep = visible || opacity > 0.01;
79    if keep {
80        Column(Modifier::new()).child((
81            Box(modifier.alpha(opacity)).child(content),
82            Box(Modifier::new()
83                .width(1.0)
84                .height(0.0)
85                .fill_max_width()
86                .alpha(opacity)
87                .hit_passthrough()
88                .on_pointer_down(move |_| on_dismiss())),
89        ))
90    } else {
91        Box(Modifier::new())
92    }
93}
94
95static NAVBAR_COUNTER: AtomicU64 = AtomicU64::new(0);
96
97/// M3 Navigation Bar - a bottom navigation bar with animated selection.
98/// Colors and indicator background transition with 200ms FastOutSlowIn.
99pub fn NavigationBar(
100    selected_index: usize,
101    items: Vec<NavItem>,
102    config: NavigationBarConfig,
103) -> View {
104    let th = theme();
105    let id = remember(|| NAVBAR_COUNTER.fetch_add(1, Ordering::Relaxed));
106
107    let mut bar_m = Modifier::new()
108        .fill_max_size()
109        .min_height(config.height)
110        .background(config.container_color)
111        .then(config.modifier);
112
113    if config.tonal_elevation > 0.0 {
114        bar_m = bar_m.state_elevation(StateElevation {
115            default: config.tonal_elevation,
116            hovered: config.tonal_elevation,
117            pressed: config.tonal_elevation,
118            disabled: 0.0,
119        });
120    }
121
122    Box(bar_m).child(
123        Row(Modifier::new()
124            .fill_max_size()
125            .align_items(AlignItems::CENTER)
126            .column_gap(config.item_spacing)
127            .semantics(Semantics::new(Role::Container).with_selectable_group()))
128        .child(
129            items
130                .into_iter()
131                .enumerate()
132                .map(|(i, item)| {
133                    let selected = i == selected_index;
134                    let is_enabled = item.enabled;
135                    let default_effects = AnimationSpec::spring_crit(40.0);
136                    let fg_icon = animate_color(
137                        format!("nb_fi_{}_{}", id, i),
138                        if selected {
139                            config.selected_icon_color
140                        } else {
141                            config.unselected_icon_color
142                        },
143                        default_effects,
144                    );
145                    let fg_label = animate_color(
146                        format!("nb_fl_{}_{}", id, i),
147                        if selected {
148                            config.selected_text_color
149                        } else {
150                            config.unselected_text_color
151                        },
152                        default_effects,
153                    );
154                    let bg_alpha = animate_f32(
155                        format!("nb_bg_{}_{}", id, i),
156                        if selected { 1.0 } else { 0.0 },
157                        default_effects,
158                    );
159                    let indicator_bg = config
160                        .indicator_color
161                        .with_alpha_f32(bg_alpha * config.indicator_opacity);
162                    let cb = item.on_click.clone();
163                    let nb_source: Rc<MutableInteractionSource> = item
164                        .interaction_source
165                        .clone()
166                        .map(Rc::new)
167                        .unwrap_or_else(|| remember(MutableInteractionSource::new));
168
169                    let mut item_m = Modifier::new()
170                        .flex_grow(1.0)
171                        .interaction_source(&*nb_source)
172                        .semantics(Semantics::new(Role::Tab).with_label(&item.label));
173
174                    if is_enabled {
175                        item_m = item_m.clickable().on_click({
176                            let cb = cb.clone();
177                            move || cb()
178                        });
179                    }
180
181                    Box(item_m).child(
182                        Column(
183                            Modifier::new()
184                                .fill_max_size()
185                                .align_items(AlignItems::CENTER)
186                                .justify_content(JustifyContent::CENTER),
187                        )
188                        .child((
189                            // Indicator pill behind icon
190                            Column(
191                                Modifier::new()
192                                    .align_items(AlignItems::CENTER)
193                                    .justify_content(JustifyContent::CENTER),
194                            )
195                            .child((
196                                Box(Modifier::new()
197                                    .absolute()
198                                    .offset(
199                                        Some((24.0 - config.indicator_width) / 2.0),
200                                        Some((24.0 - config.indicator_height) / 2.0),
201                                        None,
202                                        None,
203                                    )
204                                    .width(config.indicator_width)
205                                    .height(config.indicator_height)
206                                    .background(indicator_bg)
207                                    .clip_rounded(config.indicator_radius)
208                                    .state_colors(StateColors {
209                                        default: Color::TRANSPARENT,
210                                        hovered: th.on_surface.with_alpha_f32(0.08),
211                                        pressed: th.on_surface.with_alpha_f32(0.12),
212                                        disabled: Color::TRANSPARENT,
213                                    })),
214                                with_content_color(fg_icon, move || item.icon),
215                            )),
216                            // 8dp gap: 4dp IndicatorVerticalPadding + 4dp IndicatorToLabelPadding
217                            Box(Modifier::new().height(8.0)),
218                            Text(item.label)
219                                .color(fg_label)
220                                .size(th.typography.label_medium)
221                                .single_line(),
222                        )),
223                    )
224                })
225                .collect::<Vec<_>>(),
226        ),
227    )
228}
229
230pub struct NavItem {
231    pub icon: View,
232    pub label: String,
233    pub on_click: Rc<dyn Fn()>,
234    pub enabled: bool,
235    pub interaction_source: Option<MutableInteractionSource>,
236}
237
238pub fn Snackbar(
239    message: impl Into<String>,
240    action: Option<SnackbarAction>,
241    modifier: Modifier,
242    config: SnackbarConfig,
243) -> View {
244    let msg = message.into();
245    let th = theme();
246    let bg = config.container_color;
247    let fg = config.content_color;
248    let action_color = config.action_color;
249
250    let dismissing = snackbar_is_dismissing();
251
252    let slide_target = if dismissing { 80.0 } else { 0.0 };
253    let slide = animate_f32_from("snackbar_slide", 80.0, slide_target, th.motion.overlay);
254
255    let alpha_target = if dismissing { 0.0 } else { 1.0 };
256    let alpha = animate_f32_from("snackbar_alpha", 0.0, alpha_target, th.motion.overlay);
257
258    let snackbar = Box(Modifier::new()
259        .translate(0.0, slide)
260        .alpha(alpha)
261        .min_height(48.0)
262        .min_width(280.0)
263        .max_width(600.0)
264        .background(bg)
265        .clip_rounded(config.shape_radius));
266
267    let snackbar = if config.action_on_new_line {
268        snackbar.child(
269            Column(Modifier::new().padding_values(PaddingValues {
270                left: 16.0,
271                right: 8.0,
272                top: 0.0,
273                bottom: 0.0,
274            }))
275            .child((
276                Text(msg)
277                    .modifier(Modifier::new().padding_values(PaddingValues {
278                        left: 0.0,
279                        right: 0.0,
280                        top: 14.0,
281                        bottom: 14.0,
282                    }))
283                    .color(fg)
284                    .size(th.typography.body_medium)
285                    .max_lines(2)
286                    .overflow_ellipsize(),
287                action
288                    .map(|a| {
289                        let label = a.label.clone();
290                        Row(Modifier::new()
291                            .fill_max_width()
292                            .justify_content(repose_core::JustifyContent::END))
293                        .child(TextButton(
294                            Modifier::new(),
295                            move || (a.on_click)(),
296                            ButtonConfig::default(),
297                            || {
298                                Text(label)
299                                    .color(action_color)
300                                    .size(th.typography.label_large)
301                                    .single_line()
302                            },
303                        ))
304                    })
305                    .unwrap_or(Box(Modifier::new())),
306            )),
307        )
308    } else {
309        snackbar.child(
310            Row(Modifier::new()
311                .fill_max_width()
312                .padding_values(PaddingValues {
313                    left: 16.0,
314                    right: 8.0,
315                    top: 0.0,
316                    bottom: 0.0,
317                })
318                .align_items(repose_core::AlignItems::CENTER))
319            .child((
320                Text(msg)
321                    .modifier(Modifier::new().padding_values(PaddingValues {
322                        left: 0.0,
323                        right: 0.0,
324                        top: 14.0,
325                        bottom: 14.0,
326                    }))
327                    .color(fg)
328                    .size(th.typography.body_medium)
329                    .max_lines(2)
330                    .overflow_ellipsize(),
331                Spacer(),
332                action
333                    .map(|a| {
334                        let label = a.label.clone();
335                        TextButton(
336                            Modifier::new(),
337                            move || (a.on_click)(),
338                            ButtonConfig::default(),
339                            || {
340                                Text(label)
341                                    .color(action_color)
342                                    .size(th.typography.label_large)
343                                    .single_line()
344                            },
345                        )
346                    })
347                    .unwrap_or(Box(Modifier::new())),
348            )),
349        )
350    };
351
352    Box(Modifier::new()
353        .absolute()
354        .offset_bottom(0.0)
355        .fill_max_width()
356        .justify_content(repose_core::JustifyContent::CENTER)
357        .then(modifier))
358    .child(snackbar)
359}
360
361pub fn FilterChip(
362    selected: bool,
363    on_click: impl Fn() + 'static,
364    label: View,
365    leading_icon: Option<View>,
366    trailing_icon: Option<View>,
367    config: ChipConfig,
368) -> View {
369    let th = theme();
370    let id = remember(|| FILTERCHIP_COUNTER.fetch_add(1, Ordering::Relaxed));
371    let spec = th.motion.color;
372    let is_enabled = config.enabled;
373    let colors = &config.colors;
374
375    let bg = animate_color(
376        format!("fc_bg_{}", id),
377        colors.container(is_enabled, selected),
378        spec,
379    );
380    let label_color = animate_color(
381        format!("fc_lc_{}", id),
382        colors.label(is_enabled, selected),
383        spec,
384    );
385    let leading_color = animate_color(
386        format!("fc_lic_{}", id),
387        colors.leading_icon(is_enabled, selected),
388        spec,
389    );
390    let trailing_color = animate_color(
391        format!("fc_tic_{}", id),
392        colors.trailing_icon(is_enabled, selected),
393        spec,
394    );
395    let border = if !is_enabled {
396        if selected {
397            config.disabled_selected_border_color
398        } else {
399            config.disabled_border_color
400        }
401    } else {
402        if selected {
403            config.selected_border_color
404        } else {
405            config.border_color
406        }
407    };
408    let shape = config.shape_radius;
409
410    let mut m = Modifier::new()
411        .state_colors(StateColors {
412            default: Color::TRANSPARENT,
413            hovered: th.on_surface.with_alpha_f32(0.08).composite_over(bg),
414            pressed: th.on_surface.with_alpha_f32(0.12).composite_over(bg),
415            disabled: Color::TRANSPARENT,
416        })
417        .padding_values(PaddingValues {
418            left: config.horizontal_padding,
419            right: config.horizontal_padding,
420            top: 8.0,
421            bottom: 8.0,
422        })
423        .background(bg)
424        .clip_rounded(shape)
425        .then(config.modifier);
426
427    if config.border_width > 0.0 && border != Color::TRANSPARENT {
428        m = m.border(config.border_width, border, shape);
429    }
430    if is_enabled {
431        m = m.clickable().on_click(move || on_click());
432    }
433
434    Box(m).child(
435        Row(Modifier::new().align_items(AlignItems::CENTER)).child((
436            leading_icon
437                .map(|v| {
438                    Box(Modifier::new().padding_values(PaddingValues {
439                        left: 0.0,
440                        right: 8.0,
441                        top: 0.0,
442                        bottom: 0.0,
443                    }))
444                    .child(with_content_color(leading_color, move || v))
445                })
446                .unwrap_or(Box(Modifier::new())),
447            with_content_color(label_color, move || label),
448            trailing_icon
449                .map(|v| {
450                    Box(Modifier::new().padding_values(PaddingValues {
451                        left: 8.0,
452                        right: 0.0,
453                        top: 0.0,
454                        bottom: 0.0,
455                    }))
456                    .child(with_content_color(trailing_color, move || v))
457                })
458                .unwrap_or(Box(Modifier::new())),
459        )),
460    )
461}
462
463/// M3 Elevated Filter Chip - like [`FilterChip`] but with elevation and filled container.
464pub fn ElevatedFilterChip(
465    selected: bool,
466    on_click: impl Fn() + 'static,
467    label: View,
468    leading_icon: Option<View>,
469    trailing_icon: Option<View>,
470    config: ChipConfig,
471) -> View {
472    let th = theme();
473    let id = remember(|| FILTERCHIP_COUNTER.fetch_add(1, Ordering::Relaxed));
474    let spec = th.motion.color;
475    let is_enabled = config.enabled;
476    let colors = &config.colors;
477
478    let bg = animate_color(
479        format!("efc_bg_{}", id),
480        colors.container(is_enabled, selected),
481        spec,
482    );
483    let label_color = animate_color(
484        format!("efc_lc_{}", id),
485        colors.label(is_enabled, selected),
486        spec,
487    );
488    let leading_color = animate_color(
489        format!("efc_lic_{}", id),
490        colors.leading_icon(is_enabled, selected),
491        spec,
492    );
493    let trailing_color = animate_color(
494        format!("efc_tic_{}", id),
495        colors.trailing_icon(is_enabled, selected),
496        spec,
497    );
498    let shape = config.shape_radius;
499
500    let mut m = Modifier::new()
501        .state_colors(StateColors {
502            default: Color::TRANSPARENT,
503            hovered: th.on_surface.with_alpha_f32(0.08).composite_over(bg),
504            pressed: th.on_surface.with_alpha_f32(0.12).composite_over(bg),
505            disabled: Color::TRANSPARENT,
506        })
507        .state_elevation(config.elevation.to_state_elevation())
508        .padding_values(PaddingValues {
509            left: config.horizontal_padding,
510            right: config.horizontal_padding,
511            top: 8.0,
512            bottom: 8.0,
513        })
514        .background(bg)
515        .clip_rounded(shape)
516        .then(config.modifier);
517
518    if is_enabled {
519        m = m.clickable().on_click(move || on_click());
520    }
521
522    Box(m).child(
523        Row(Modifier::new().align_items(AlignItems::CENTER)).child((
524            leading_icon
525                .map(|v| {
526                    Box(Modifier::new().padding_values(PaddingValues {
527                        left: 0.0,
528                        right: 8.0,
529                        top: 0.0,
530                        bottom: 0.0,
531                    }))
532                    .child(with_content_color(leading_color, move || v))
533                })
534                .unwrap_or(Box(Modifier::new())),
535            with_content_color(label_color, move || label),
536            trailing_icon
537                .map(|v| {
538                    Box(Modifier::new().padding_values(PaddingValues {
539                        left: 8.0,
540                        right: 0.0,
541                        top: 0.0,
542                        bottom: 0.0,
543                    }))
544                    .child(with_content_color(trailing_color, move || v))
545                })
546                .unwrap_or(Box(Modifier::new())),
547        )),
548    )
549}
550
551pub fn SuggestionChip(
552    on_click: impl Fn() + 'static,
553    label: View,
554    icon: Option<View>,
555    config: ChipConfig,
556) -> View {
557    let th = theme();
558    let is_enabled = config.enabled;
559    let colors = &config.colors;
560    let bg = colors.container(is_enabled, false);
561    let label_color = colors.label(is_enabled, false);
562    let leading_color = colors.leading_icon(is_enabled, false);
563    let border = if is_enabled {
564        config.border_color
565    } else {
566        config.disabled_border_color
567    };
568    let shape = config.shape_radius;
569
570    let mut m = Modifier::new()
571        .state_colors(StateColors {
572            default: Color::TRANSPARENT,
573            hovered: th.on_surface.with_alpha_f32(0.08),
574            pressed: th.on_surface.with_alpha_f32(0.12),
575            disabled: Color::TRANSPARENT,
576        })
577        .padding_values(PaddingValues {
578            left: config.horizontal_padding,
579            right: config.horizontal_padding,
580            top: 8.0,
581            bottom: 8.0,
582        })
583        .background(bg)
584        .clip_rounded(shape)
585        .then(config.modifier);
586
587    if config.border_width > 0.0 && border != Color::TRANSPARENT {
588        m = m.border(config.border_width, border, shape);
589    }
590    if is_enabled {
591        m = m.clickable().on_click(move || on_click());
592    }
593
594    Box(m).child(
595        Row(Modifier::new().align_items(AlignItems::CENTER)).child((
596            icon.map(|v| {
597                Box(Modifier::new().padding_values(PaddingValues {
598                    left: 0.0,
599                    right: 8.0,
600                    top: 0.0,
601                    bottom: 0.0,
602                }))
603                .child(with_content_color(leading_color, move || v))
604            })
605            .unwrap_or(Box(Modifier::new())),
606            with_content_color(label_color, move || label),
607        )),
608    )
609}
610
611/// M3 Elevated Suggestion Chip - like [`SuggestionChip`] but with elevation and filled bg.
612pub fn ElevatedSuggestionChip(
613    on_click: impl Fn() + 'static,
614    label: View,
615    icon: Option<View>,
616    config: ChipConfig,
617) -> View {
618    let th = theme();
619    let is_enabled = config.enabled;
620    let colors = &config.colors;
621    let bg = colors.container(is_enabled, false);
622    let label_color = colors.label(is_enabled, false);
623    let leading_color = colors.leading_icon(is_enabled, false);
624    let shape = config.shape_radius;
625
626    let mut m = Modifier::new()
627        .state_colors(StateColors {
628            default: Color::TRANSPARENT,
629            hovered: th.on_surface.with_alpha_f32(0.08),
630            pressed: th.on_surface.with_alpha_f32(0.12),
631            disabled: Color::TRANSPARENT,
632        })
633        .state_elevation(config.elevation.to_state_elevation())
634        .padding_values(PaddingValues {
635            left: config.horizontal_padding,
636            right: config.horizontal_padding,
637            top: 8.0,
638            bottom: 8.0,
639        })
640        .background(bg)
641        .clip_rounded(shape)
642        .then(config.modifier);
643
644    if is_enabled {
645        m = m.clickable().on_click(move || on_click());
646    }
647
648    Box(m).child(
649        Row(Modifier::new().align_items(AlignItems::CENTER)).child((
650            icon.map(|v| {
651                Box(Modifier::new().padding_values(PaddingValues {
652                    left: 0.0,
653                    right: 8.0,
654                    top: 0.0,
655                    bottom: 0.0,
656                }))
657                .child(with_content_color(leading_color, move || v))
658            })
659            .unwrap_or(Box(Modifier::new())),
660            with_content_color(label_color, move || label),
661        )),
662    )
663}
664
665pub fn InputChip(
666    selected: bool,
667    on_click: impl Fn() + 'static,
668    label: View,
669    leading_icon: Option<View>,
670    avatar: Option<View>,
671    trailing_icon: Option<View>,
672    config: ChipConfig,
673) -> View {
674    let th = theme();
675    let id = remember(|| FILTERCHIP_COUNTER.fetch_add(1, Ordering::Relaxed));
676    let spec = th.motion.color;
677    let is_enabled = config.enabled;
678    let colors = &config.colors;
679
680    let bg = animate_color(
681        format!("ic_bg_{}", id),
682        colors.container(is_enabled, selected),
683        spec,
684    );
685    let label_color = animate_color(
686        format!("ic_lc_{}", id),
687        colors.label(is_enabled, selected),
688        spec,
689    );
690    let leading_color = animate_color(
691        format!("ic_lic_{}", id),
692        colors.leading_icon(is_enabled, selected),
693        spec,
694    );
695    let trailing_color = animate_color(
696        format!("ic_tic_{}", id),
697        colors.trailing_icon(is_enabled, selected),
698        spec,
699    );
700    let border = if !is_enabled {
701        if selected {
702            config.disabled_selected_border_color
703        } else {
704            config.disabled_border_color
705        }
706    } else {
707        if selected {
708            config.selected_border_color
709        } else {
710            config.border_color
711        }
712    };
713    let shape = config.shape_radius;
714
715    let mut m = Modifier::new()
716        .state_colors(StateColors {
717            default: Color::TRANSPARENT,
718            hovered: th.on_surface.with_alpha_f32(0.08).composite_over(bg),
719            pressed: th.on_surface.with_alpha_f32(0.12).composite_over(bg),
720            disabled: Color::TRANSPARENT,
721        })
722        .padding_values(PaddingValues {
723            left: config.horizontal_padding,
724            right: config.horizontal_padding,
725            top: 8.0,
726            bottom: 8.0,
727        })
728        .background(bg)
729        .clip_rounded(shape)
730        .then(config.modifier);
731
732    if config.border_width > 0.0 && border != Color::TRANSPARENT {
733        m = m.border(config.border_width, border, shape);
734    }
735    if is_enabled {
736        m = m.clickable().on_click(move || on_click());
737    }
738
739    Box(m).child(
740        Row(Modifier::new().align_items(AlignItems::CENTER)).child((
741            avatar
742                .or(leading_icon)
743                .map(|v| {
744                    Box(Modifier::new().padding_values(PaddingValues {
745                        left: 0.0,
746                        right: 8.0,
747                        top: 0.0,
748                        bottom: 0.0,
749                    }))
750                    .child(with_content_color(leading_color, move || v))
751                })
752                .unwrap_or(Box(Modifier::new())),
753            with_content_color(label_color, move || label),
754            trailing_icon
755                .map(|v| {
756                    Box(Modifier::new().padding_values(PaddingValues {
757                        left: 8.0,
758                        right: 0.0,
759                        top: 0.0,
760                        bottom: 0.0,
761                    }))
762                    .child(with_content_color(trailing_color, move || v))
763                })
764                .unwrap_or(Box(Modifier::new())),
765        )),
766    )
767}
768
769/// Position of the floating action button within a Scaffold.
770#[derive(Clone, Copy, Debug, PartialEq)]
771pub enum FabPosition {
772    End,
773    Center,
774}
775
776impl Default for FabPosition {
777    fn default() -> Self {
778        Self::End
779    }
780}
781
782#[derive(Clone)]
783pub struct ScaffoldConfig {
784    pub modifier: Modifier,
785    pub top_bar: Option<View>,
786    pub bottom_bar: Option<View>,
787    pub floating_action_button: Option<View>,
788    pub snackbar_host: Option<View>,
789    pub container_color: Color,
790    pub content_color: Color,
791    pub fab_position: FabPosition,
792}
793
794impl Default for ScaffoldConfig {
795    fn default() -> Self {
796        Self {
797            modifier: Modifier::new(),
798            top_bar: None,
799            bottom_bar: None,
800            floating_action_button: None,
801            snackbar_host: None,
802            container_color: ScaffoldDefaults::container_color(),
803            content_color: ScaffoldDefaults::content_color(),
804            fab_position: FabPosition::End,
805        }
806    }
807}
808
809pub fn Scaffold(content: impl Fn(PaddingValues) -> View, config: ScaffoldConfig) -> View {
810    let insets = window_insets();
811    let itop = px_to_dp(insets.top);
812    let ibottom = px_to_dp(insets.bottom);
813    let iime = px_to_dp(insets.ime_bottom);
814    let ileft = px_to_dp(insets.left);
815    let iright = px_to_dp(insets.right);
816
817    let content_padding = PaddingValues {
818        top: if config.top_bar.is_some() {
819            64.0
820        } else {
821            itop
822        },
823        bottom: if config.bottom_bar.is_some() {
824            80.0 + ibottom + iime
825        } else {
826            ibottom + iime
827        },
828        left: ileft,
829        right: iright,
830    };
831
832    Column(
833        config
834            .modifier
835            .fill_max_size()
836            .background(config.container_color),
837    )
838    .child((
839        Box(Modifier::new()
840            .fill_max_size()
841            .padding_values(PaddingValues {
842                top: if config.top_bar.is_some() {
843                    64.0 + itop
844                } else {
845                    0.0
846                },
847                bottom: if config.bottom_bar.is_some() {
848                    80.0 + ibottom + iime
849                } else {
850                    ibottom + iime
851                },
852                ..Default::default()
853            }))
854        .child(content(content_padding)),
855        if let Some(bar) = config.top_bar {
856            Box(Modifier::new()
857                .absolute()
858                .offset(Some(0.0), Some(itop), Some(0.0), None))
859            .child(bar)
860        } else {
861            Box(Modifier::new())
862        },
863        if let Some(bar) = config.bottom_bar {
864            Box(Modifier::new().absolute().offset(
865                Some(0.0),
866                None,
867                Some(ibottom + iime),
868                Some(0.0),
869            ))
870            .child(bar)
871        } else {
872            Box(Modifier::new())
873        },
874        if let Some(fab) = config.floating_action_button {
875            let mut fab_m = Modifier::new().absolute();
876            match config.fab_position {
877                FabPosition::End => {
878                    fab_m = fab_m.offset(
879                        None,
880                        None,
881                        Some(16.0 + ibottom + iime),
882                        Some(16.0),
883                    );
884                }
885                FabPosition::Center => {
886                    fab_m = fab_m.fill_max_width().align_self(AlignSelf::CENTER).offset(
887                        None,
888                        None,
889                        Some(16.0 + ibottom + iime),
890                        None,
891                    );
892                }
893            }
894            Box(fab_m).child(fab)
895        } else {
896            Box(Modifier::new())
897        },
898        config.snackbar_host.unwrap_or_else(|| Box(Modifier::new())),
899    ))
900}
901
902/// State controlling tooltip visibility.
903pub struct TooltipState {
904    visible: Signal<bool>,
905}
906
907impl TooltipState {
908    pub fn new() -> Rc<Self> {
909        Rc::new(Self {
910            visible: signal(false),
911        })
912    }
913
914    pub fn is_visible(&self) -> bool {
915        self.visible.get()
916    }
917
918    pub fn show(&self) {
919        self.visible.set(true);
920    }
921
922    pub fn dismiss(&self) {
923        self.visible.set(false);
924    }
925}
926
927/// Wraps `content` with a tooltip label shown above it when `state` is visible.
928///
929/// When [`TooltipConfig::enable_user_input`] is true (default), the tooltip is
930/// shown on pointer hover and dismissed on leave.
931///
932/// Usage:
933/// ```ignore
934/// let tip = TooltipState::new();
935/// TooltipBox("I'm a tooltip", tip.clone(), Modifier::new(), Button("Hover me", {
936///     let tip = tip.clone();
937///     move || tip.show()
938/// }));
939/// ```
940pub fn TooltipBox(
941    text: impl Into<String>,
942    state: Rc<TooltipState>,
943    content: View,
944    config: TooltipConfig,
945) -> View {
946    let text: Rc<str> = Rc::from(text.into());
947    let th = theme();
948    let spec = th.motion.overlay;
949    let id = remember(|| TOOLTIP_COUNTER.fetch_add(1, Ordering::Relaxed));
950
951    let alpha = animate_f32(
952        format!("tooltip_alpha_{id}"),
953        if state.is_visible() { 1.0 } else { 0.0 },
954        spec,
955    );
956
957    let tooltip_visible = state.is_visible() || alpha > 0.01;
958    let scale = 0.92 + 0.08 * alpha;
959
960    let mut host = config
961        .modifier
962        .align_self(AlignSelf::FLEX_START)
963        .flex_shrink(0.0);
964
965    if config.enable_user_input {
966        let enter = state.clone();
967        let leave = state.clone();
968        host = host.hoverable(
969            move || enter.show(),
970            move || leave.dismiss(),
971        );
972    }
973
974    Box(host).child((
975        content,
976        if tooltip_visible {
977            Box(Modifier::new()
978                .absolute()
979                .offset(Some(0.0), Some(config.offset_y), Some(0.0), None)
980                .justify_content(JustifyContent::CENTER)
981                .align_items(AlignItems::CENTER)
982                .hit_passthrough()
983                .render_z_index(10_000.0)
984                .alpha(alpha))
985            .child(
986                Box(Modifier::new()
987                    .background(config.container_color)
988                    .clip_rounded(th.shapes.extra_small)
989                    .padding_values(PaddingValues {
990                        left: config.horizontal_padding,
991                        right: config.horizontal_padding,
992                        top: config.vertical_padding,
993                        bottom: config.vertical_padding,
994                    })
995                    .max_width(config.max_width)
996                    .flex_shrink(0.0)
997                    .scale(scale)
998                    .hit_passthrough()
999                    .then({
1000                        let mut m = Modifier::new();
1001                        if config.shadow_elevation > 0.0 {
1002                            m = m.shadow(config.shadow_elevation, 0.0);
1003                        }
1004                        if config.tonal_elevation > 0.0 {
1005                            m = m.state_elevation(StateElevation {
1006                                default: config.tonal_elevation,
1007                                hovered: config.tonal_elevation,
1008                                pressed: config.tonal_elevation,
1009                                disabled: 0.0,
1010                            });
1011                        }
1012                        m
1013                    }))
1014                .child(
1015                    Text((*text).to_string())
1016                        .color(config.content_color)
1017                        .size(th.typography.label_medium),
1018                ),
1019            )
1020        } else {
1021            Box(Modifier::new())
1022        },
1023    ))
1024}
1025
1026/// State controlling drawer open/close.
1027pub struct DrawerState {
1028    visible: Signal<bool>,
1029}
1030
1031impl DrawerState {
1032    pub fn new() -> Rc<Self> {
1033        Rc::new(Self {
1034            visible: signal(false),
1035        })
1036    }
1037
1038    pub fn is_open(&self) -> bool {
1039        self.visible.get()
1040    }
1041
1042    pub fn open(&self) {
1043        self.visible.set(true);
1044    }
1045
1046    pub fn dismiss(&self) {
1047        self.visible.set(false);
1048    }
1049}
1050
1051/// A modal navigation drawer that slides in from the left with a scrim overlay.
1052pub fn ModalNavigationDrawer(
1053    drawer_state: Rc<DrawerState>,
1054    drawer_content: View,
1055    content: View,
1056    config: NavigationDrawerConfig,
1057) -> View {
1058    let th = theme();
1059
1060    let drawer_offset = animate_f32(
1061        "modal_drawer_offset",
1062        if drawer_state.is_open() { 0.0 } else { -360.0 },
1063        theme().motion.spring,
1064    );
1065
1066    let mut drawer_m = Modifier::new()
1067        .absolute()
1068        .offset(Some(drawer_offset), Some(0.0), None, Some(0.0))
1069        .fill_max_height()
1070        .width(config.width)
1071        .background(config.container_color)
1072        .clip_rounded(config.shape_radius);
1073
1074    if config.tonal_elevation > 0.0 {
1075        drawer_m = drawer_m.state_elevation(StateElevation {
1076            default: config.tonal_elevation,
1077            hovered: config.tonal_elevation,
1078            pressed: config.tonal_elevation,
1079            disabled: 0.0,
1080        });
1081    }
1082
1083    ZStack(Modifier::new().fill_max_size()).child((
1084        Box(Modifier::new()
1085            .fill_max_size()
1086            .background(config.content_color))
1087        .child(content),
1088        if drawer_state.is_open() {
1089            Box(Modifier::new()
1090                .fill_max_size()
1091                .background(config.scrim_color)
1092                .clickable()
1093                .on_pointer_down({
1094                    let ds = drawer_state.clone();
1095                    move |_| ds.dismiss()
1096                }))
1097            .child(Box(Modifier::new()))
1098        } else {
1099            Box(Modifier::new())
1100        },
1101        Box(drawer_m).child(drawer_content),
1102    ))
1103}
1104
1105/// M3 Dismissible Navigation Drawer - slides alongside content without scrim.
1106/// Uses [`DrawerState`] to control open/close.
1107pub fn DismissibleNavigationDrawer(
1108    drawer_state: Rc<DrawerState>,
1109    drawer_content: View,
1110    content: View,
1111    config: NavigationDrawerConfig,
1112) -> View {
1113    let th = theme();
1114    let drawer_offset = animate_f32(
1115        "dismissible_drawer_offset",
1116        if drawer_state.is_open() { 0.0 } else { -360.0 },
1117        theme().motion.spring,
1118    );
1119
1120    let mut drawer_m = Modifier::new()
1121        .absolute()
1122        .offset(Some(drawer_offset), Some(0.0), None, Some(0.0))
1123        .fill_max_height()
1124        .width(config.width)
1125        .background(config.container_color)
1126        .clip_rounded(config.shape_radius);
1127
1128    if config.tonal_elevation > 0.0 {
1129        drawer_m = drawer_m.state_elevation(StateElevation {
1130            default: config.tonal_elevation,
1131            hovered: config.tonal_elevation,
1132            pressed: config.tonal_elevation,
1133            disabled: 0.0,
1134        });
1135    }
1136
1137    ZStack(Modifier::new().fill_max_size()).child((
1138        Box(Modifier::new()
1139            .fill_max_size()
1140            .background(config.content_color))
1141        .child(content),
1142        Box(drawer_m).child(drawer_content),
1143    ))
1144}
1145
1146/// M3 Permanent Navigation Drawer - always visible alongside content.
1147pub fn PermanentNavigationDrawer(
1148    drawer_content: View,
1149    content: View,
1150    config: NavigationDrawerConfig,
1151) -> View {
1152    Row(Modifier::new().fill_max_size()).child((
1153        Box(Modifier::new()
1154            .width(config.width)
1155            .fill_max_height()
1156            .background(config.container_color))
1157        .child(
1158            Box(Modifier::new())
1159                .color(config.content_color)
1160                .child(drawer_content),
1161        ),
1162        Box(Modifier::new().flex_grow(1.0)).child(content),
1163    ))
1164}
1165
1166/// A destination entry inside a NavigationDrawer.
1167#[derive(Clone)]
1168pub struct NavigationDrawerItemConfig {
1169    pub modifier: Modifier,
1170    pub icon: Option<View>,
1171    pub badge: Option<View>,
1172    pub enabled: bool,
1173    pub shape_radius: f32,
1174    pub interaction_source: Option<MutableInteractionSource>,
1175}
1176
1177impl Default for NavigationDrawerItemConfig {
1178    fn default() -> Self {
1179        Self {
1180            modifier: Modifier::new(),
1181            icon: None,
1182            badge: None,
1183            enabled: true,
1184            shape_radius: repose_core::locals::theme().shapes.large,
1185            interaction_source: None,
1186        }
1187    }
1188}
1189
1190pub fn NavigationDrawerItem(
1191    label: View,
1192    selected: bool,
1193    on_click: impl Fn() + 'static,
1194    config: NavigationDrawerItemConfig,
1195) -> View {
1196    let th = theme();
1197    let id = remember(|| FILTERCHIP_COUNTER.fetch_add(1, Ordering::Relaxed));
1198    let spec = th.motion.color;
1199    let bg = animate_color(
1200        format!("ndi_bg_{}", id),
1201        if selected {
1202            th.secondary_container
1203        } else {
1204            Color::TRANSPARENT
1205        },
1206        spec,
1207    );
1208    let fg = animate_color(
1209        format!("ndi_fg_{}", id),
1210        if selected {
1211            th.on_secondary_container
1212        } else {
1213            th.on_surface_variant
1214        },
1215        spec,
1216    );
1217
1218    let nd_source: Rc<MutableInteractionSource> = config
1219        .interaction_source
1220        .clone()
1221        .map(Rc::new)
1222        .unwrap_or_else(|| remember(MutableInteractionSource::new));
1223
1224    let mut m = Modifier::new()
1225        .fill_max_width()
1226        .padding_values(PaddingValues {
1227            left: 12.0,
1228            right: 12.0,
1229            top: 0.0,
1230            bottom: 0.0,
1231        })
1232        .min_height(56.0)
1233        .background(bg)
1234        .state_colors(StateColors {
1235            default: Color::TRANSPARENT,
1236            hovered: th.on_surface.with_alpha_f32(0.08),
1237            pressed: th.on_surface.with_alpha_f32(0.12),
1238            disabled: Color::TRANSPARENT,
1239        })
1240        .clip_rounded(config.shape_radius)
1241        .interaction_source(&*nd_source)
1242        .then(config.modifier);
1243
1244    if config.enabled {
1245        m = m.clickable().on_click(move || on_click());
1246    }
1247
1248    Box(m).child(with_content_color(fg, || {
1249        Row(Modifier::new()
1250            .align_items(AlignItems::CENTER)
1251            .padding_values(PaddingValues {
1252                left: 16.0,
1253                right: 24.0,
1254                top: 0.0,
1255                bottom: 0.0,
1256            }))
1257        .child((
1258            config
1259                .icon
1260                .unwrap_or(Box(Modifier::new().width(24.0).height(24.0))),
1261            Box(Modifier::new().width(12.0).height(1.0)),
1262            Box(Modifier::new().flex_grow(1.0)).child(label),
1263            config.badge.unwrap_or(Box(Modifier::new())),
1264        ))
1265    }))
1266}
1267
1268/// A single item inside a `DropdownMenu`.
1269#[derive(Clone)]
1270pub struct DropdownMenuItem {
1271    pub text: String,
1272    pub leading_icon: Option<View>,
1273    pub trailing_icon: Option<View>,
1274    pub on_click: Rc<dyn Fn()>,
1275    pub enabled: bool,
1276}
1277
1278impl DropdownMenuItem {
1279    pub fn new(text: impl Into<String>, on_click: impl Fn() + 'static) -> Self {
1280        Self {
1281            text: text.into(),
1282            leading_icon: None,
1283            trailing_icon: None,
1284            on_click: Rc::new(on_click),
1285            enabled: true,
1286        }
1287    }
1288
1289    pub fn leading_icon(mut self, icon: View) -> Self {
1290        self.leading_icon = Some(icon);
1291        self
1292    }
1293
1294    pub fn trailing_icon(mut self, icon: View) -> Self {
1295        self.trailing_icon = Some(icon);
1296        self
1297    }
1298
1299    pub fn disabled(mut self) -> Self {
1300        self.enabled = false;
1301        self
1302    }
1303}
1304
1305/// A menu divider line.
1306pub struct MenuDivider;
1307
1308/// State for controlling `DropdownMenu` visibility.
1309pub struct MenuState {
1310    visible: Signal<bool>,
1311    anchor: Signal<Option<Vec2>>,
1312}
1313
1314impl Default for MenuState {
1315    fn default() -> Self {
1316        Self::new()
1317    }
1318}
1319
1320impl MenuState {
1321    pub fn new() -> Self {
1322        Self {
1323            visible: signal(false),
1324            anchor: signal(None),
1325        }
1326    }
1327
1328    pub fn is_open(&self) -> bool {
1329        self.visible.get()
1330    }
1331
1332    pub fn open(&self) {
1333        self.visible.set(true);
1334    }
1335
1336    pub fn open_at(&self, screen_pos: Vec2) {
1337        self.anchor.set(Some(screen_pos));
1338        self.visible.set(true);
1339    }
1340
1341    pub fn dismiss(&self) {
1342        self.visible.set(false);
1343    }
1344}
1345
1346static DROPDOWN_COUNTER: AtomicU64 = AtomicU64::new(0);
1347
1348const DDM_SCALE_FROM: f32 = 0.8;
1349const DDM_VERTICAL_PADDING: f32 = 8.0;
1350const DDM_ITEM_H_PAD: f32 = 12.0;
1351const DDM_ITEM_MIN_HEIGHT: f32 = 48.0;
1352
1353/// Either a menu item or a divider.
1354#[derive(Clone)]
1355pub enum DropdownMenuEntry {
1356    Item(DropdownMenuItem),
1357    Divider,
1358}
1359
1360/// M3 Dropdown Menu anchored to a trigger element.
1361///
1362/// Renders as a single overlay entry with a transparent full-screen scrim and
1363/// positioned card, matching Compose's Popup behavior. The card is bounded in
1364/// height so vertical_scroll activates when content overflows.
1365pub fn DropdownMenu(
1366    state: Rc<MenuState>,
1367    overlay: OverlayHandle,
1368    modifier: Modifier,
1369    trigger: View,
1370    items: Vec<DropdownMenuEntry>,
1371    config: DropdownMenuConfig,
1372) -> View {
1373    let th = theme();
1374    let ddm_id = remember(|| DROPDOWN_COUNTER.fetch_add(1, Ordering::Relaxed));
1375    let overlay_id = remember_with_key(format!("ddm_oid_{ddm_id}"), || signal(0u64));
1376    let trigger_rect = remember_state_with_key(format!("ddm_tr_{ddm_id}"), Rect::default);
1377    let scroll_state: Rc<ScrollState> =
1378        remember_with_key(format!("ddm_scroll_{ddm_id}"), ScrollState::new);
1379
1380    let trigger = Box(Modifier::new().on_globally_positioned({
1381        let tr = trigger_rect.clone();
1382        move |rect| {
1383            *tr.borrow_mut() = rect;
1384        }
1385    }))
1386    .child(trigger);
1387
1388    let anim = remember_state_with_key(format!("ddm_anim_{ddm_id}"), || {
1389        AnimatedValue::new(0.0, theme().motion.overlay)
1390    });
1391    let last_target = remember_state_with_key(format!("ddm_lt_{ddm_id}"), || f32::NAN);
1392    let anim_target = if state.is_open() { 1.0 } else { 0.0 };
1393
1394    {
1395        let mut a = anim.borrow_mut();
1396        let mut lt = last_target.borrow_mut();
1397        if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
1398            a.set_target(anim_target);
1399            *lt = anim_target;
1400        }
1401        drop(lt);
1402        if a.update() {
1403            request_frame();
1404        }
1405    }
1406
1407    let progress = *anim.borrow().get();
1408    let menu_visible = state.is_open() || progress > 0.01;
1409
1410    if menu_visible {
1411        if overlay_id.get() == 0 {
1412            let anim = anim.clone();
1413            let th = th.clone();
1414            let items = items.clone();
1415            let state = state.clone();
1416            let config = config.clone();
1417            let trigger_rect = trigger_rect.clone();
1418            let scroll_state = scroll_state.clone();
1419
1420            let id = overlay.show_entry(
1421                Rc::new(move || {
1422                    let p = *anim.borrow().get();
1423                    let scale = DDM_SCALE_FROM + (1.0 - DDM_SCALE_FROM) * p;
1424                    let alpha = p;
1425
1426                    let rect = *trigger_rect.borrow();
1427                    let win_h = get_window_container_height();
1428                    let hm = config.vertical_margin;
1429
1430                    let space_below = (win_h - hm) - (rect.y + rect.h);
1431                    let space_above = rect.y - hm;
1432                    let place_below = space_below >= space_above;
1433                    let available_height = (if place_below { space_below } else { space_above }).max(48.0);
1434
1435                    let popup_x = rect.x + config.offset_x;
1436                    let constrained_width = config.max_width;
1437
1438                    let mut adjusted_config = config.clone();
1439                    adjusted_config.max_width = constrained_width;
1440
1441                    let popup_y = if place_below {
1442                        rect.y + rect.h + config.offset_y
1443                    } else {
1444                        // Anchor bottom, so stays in the space above instead
1445                        // of growing down off-screen.
1446                        (rect.y - config.offset_y - available_height).max(hm)
1447                    };
1448
1449                    let content = render_dropdown_menu_content(
1450                        &th,
1451                        &items,
1452                        state.clone(),
1453                        &adjusted_config,
1454                        scroll_state.clone(),
1455                        available_height,
1456                    );
1457
1458                    let transform_origin_y = if place_below { 0.0 } else { 1.0 };
1459
1460                    let menu = Box(
1461                        Modifier::new()
1462                            .absolute()
1463                            .offset(Some(popup_x), Some(popup_y), None, None)
1464                            .scale(scale)
1465                            .alpha(alpha)
1466                            .transform_origin(0.0, transform_origin_y),
1467                    )
1468                    .child(content);
1469
1470                    let scrim = Box(Modifier::new().fill_max_size().on_pointer_down({
1471                        let s = state.clone();
1472                        move |_| s.dismiss()
1473                    }));
1474
1475                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, menu))
1476                }),
1477                901.0,
1478                false,
1479            );
1480            overlay_id.set(id);
1481        }
1482    } else {
1483        let prev = overlay_id.get();
1484        if prev != 0 {
1485            let _ = overlay.dismiss(prev);
1486            overlay_id.set(0);
1487        }
1488    }
1489
1490    Box(modifier).child(trigger)
1491}
1492
1493fn render_dropdown_menu_content(
1494    th: &Theme,
1495    items: &[DropdownMenuEntry],
1496    state: Rc<MenuState>,
1497    config: &DropdownMenuConfig,
1498    scroll_state: Rc<ScrollState>,
1499    max_height: f32,
1500) -> View {
1501    let children: Vec<View> = items
1502        .iter()
1503        .map(|entry| match entry {
1504            DropdownMenuEntry::Item(item) => {
1505                let text_color = if item.enabled {
1506                    config.item_text_color
1507                } else {
1508                    config.disabled_item_text_color
1509                };
1510                let on_click = item.on_click.clone();
1511                let state = state.clone();
1512
1513                let mut modifier = Modifier::new()
1514                    .fill_max_width()
1515                    .min_height(config.item_height.max(DDM_ITEM_MIN_HEIGHT))
1516                    .padding_values(PaddingValues {
1517                        left: DDM_ITEM_H_PAD,
1518                        right: DDM_ITEM_H_PAD,
1519                        top: 0.0,
1520                        bottom: 0.0,
1521                    })
1522                    .align_items(AlignItems::CENTER);
1523
1524                if item.enabled {
1525                    modifier = modifier
1526                        .state_colors(StateColors {
1527                            default: Color::TRANSPARENT,
1528                            hovered: th.on_surface.with_alpha_f32(0.08),
1529                            pressed: th.on_surface.with_alpha_f32(0.12),
1530                            disabled: Color::TRANSPARENT,
1531                        })
1532                        .clickable()
1533                        .on_click(move || {
1534                            on_click();
1535                            state.dismiss();
1536                        });
1537                }
1538
1539                let mut row_children: Vec<View> = Vec::new();
1540                if let Some(icon) = item.leading_icon.clone() {
1541                    row_children.push(icon);
1542                    row_children.push(Box(Modifier::new().width(DDM_ITEM_H_PAD)));
1543                }
1544                row_children.push(
1545                    Box(Modifier::new().flex_grow(1.0)).child(
1546                        Text(item.text.clone())
1547                            .color(text_color)
1548                            .size(th.typography.label_large)
1549                            .single_line(),
1550                    ),
1551                );
1552                if let Some(icon) = item.trailing_icon.clone() {
1553                    row_children.push(Box(Modifier::new().width(DDM_ITEM_H_PAD)));
1554                    row_children.push(icon);
1555                }
1556                Row(modifier).child(row_children)
1557            }
1558            DropdownMenuEntry::Divider => Box(Modifier::new()
1559                .fill_max_width()
1560                .height(1.0)
1561                .margin(12.0)
1562                .background(config.divider_color)),
1563        })
1564        .collect();
1565
1566    let binding = scroll_state.to_binding();
1567    let axis_binding = match &binding {
1568        ScrollBinding::Vertical(a) => a.clone(),
1569        _ => unreachable!(),
1570    };
1571
1572    let items_column = Box(
1573        Modifier::new()
1574            .fill_max_width()
1575            .max_height((max_height - 2.0 * DDM_VERTICAL_PADDING).max(0.0))
1576            .vertical_scroll(axis_binding),
1577    )
1578    .child(Column(Modifier::new().fill_max_width()).with_children(children));
1579
1580    let shadow_elevation = config
1581        .shadow_elevation
1582        .unwrap_or(th.elevation.level2);
1583
1584    let mut card_modifier = Modifier::new()
1585        .shadow(shadow_elevation, 0.0)
1586        .min_width(config.min_width)
1587        .max_width(config.max_width)
1588        .padding_values(PaddingValues {
1589            left: 0.0,
1590            right: 0.0,
1591            top: DDM_VERTICAL_PADDING,
1592            bottom: DDM_VERTICAL_PADDING,
1593        })
1594        .background(config.container_color)
1595        .clip_rounded(config.shape_radius.unwrap_or(th.shapes.extra_small));
1596
1597    card_modifier = apply_tonal_elevation(card_modifier, config.tonal_elevation, config.container_color);
1598
1599    if let Some((border_width, border_color, border_radius)) = config.border {
1600        card_modifier = card_modifier.border(border_width, border_color, border_radius);
1601    }
1602
1603    Box(card_modifier).child(items_column)
1604}
1605
1606/// Possible values of [`SearchBarState`].
1607#[derive(Clone, Copy, Debug, PartialEq)]
1608pub enum SearchBarValue {
1609    Collapsed,
1610    Expanded,
1611}
1612
1613/// State for `SearchBar` -> manages expanded/collapsed progress, query text,
1614/// active state, and collapsed layout coordinates for popup anchoring.
1615pub struct SearchBarState {
1616    pub query: Signal<String>,
1617    pub expanded: Signal<bool>,
1618    pub active: Signal<bool>,
1619    /// Whether this search bar expands to full-screen (vs docked).
1620    /// Used by AppBarWithSearch to hide the collapsed bar when expanded.
1621    pub expands_to_full_screen: Signal<bool>,
1622    /// Container animation (shape, size, position)
1623    anim: Rc<RefCell<AnimatedValue<f32>>>,
1624    /// Content fade animation -> fades FIRST on collapse before container shrinks
1625    content_anim: Rc<RefCell<AnimatedValue<f32>>>,
1626    /// Tracked via `on_globally_positioned` on the collapsed bar.
1627    /// Used by expanded docked variants for popup placement.
1628    pub collapsed_layout_rect: Signal<(f32, f32, f32, f32)>,
1629}
1630
1631impl Default for SearchBarState {
1632    fn default() -> Self {
1633        Self::new()
1634    }
1635}
1636
1637impl SearchBarState {
1638    pub fn new() -> Self {
1639        Self {
1640            query: signal(String::new()),
1641            expanded: signal(false),
1642            active: signal(false),
1643            expands_to_full_screen: signal(false),
1644            anim: Rc::new(RefCell::new(AnimatedValue::new(
1645                0.0,
1646                AnimationSpec::spring_gentle(),
1647            ))),
1648            content_anim: Rc::new(RefCell::new(AnimatedValue::new(
1649                0.0,
1650                AnimationSpec::spring_gentle(),
1651            ))),
1652            collapsed_layout_rect: signal((0.0, 0.0, 0.0, 0.0)),
1653        }
1654    }
1655
1656    pub fn query(&self) -> String {
1657        self.query.get()
1658    }
1659
1660    pub fn set_query(&self, q: impl Into<String>) {
1661        self.query.set(q.into());
1662    }
1663
1664    pub fn is_expanded(&self) -> bool {
1665        self.expanded.get()
1666    }
1667
1668    pub fn expand(&self) {
1669        self.expanded.set(true);
1670        self.anim.borrow_mut().set_target(1.0);
1671        self.content_anim.borrow_mut().set_target(1.0);
1672        request_frame();
1673    }
1674
1675    pub fn collapse(&self) {
1676        self.expanded.set(false);
1677        self.active.set(false);
1678        // Content fades first; container follows in progress()
1679        self.content_anim.borrow_mut().set_target(0.0);
1680        self.anim.borrow_mut().set_target(0.0);
1681        request_frame();
1682    }
1683
1684    pub fn is_active(&self) -> bool {
1685        self.active.get()
1686    }
1687
1688    pub fn activate(&self) {
1689        self.active.set(true);
1690        self.expanded.set(true);
1691        self.anim.borrow_mut().set_target(1.0);
1692        self.content_anim.borrow_mut().set_target(1.0);
1693        request_frame();
1694    }
1695
1696    pub fn deactivate(&self) {
1697        if self.expanded.get() {
1698            self.expanded.set(false);
1699            self.content_anim.borrow_mut().set_target(0.0);
1700            self.anim.borrow_mut().set_target(0.0);
1701        }
1702        self.active.set(false);
1703        FocusManager::new(vec![], None).clear_focus(false);
1704        request_frame();
1705    }
1706
1707    /// Container animation progress: 0.0 = collapsed, 1.0 = expanded.
1708    /// Ticks the underlying AnimatedValue and requests frames while animating.
1709    pub fn progress(&self) -> f32 {
1710        let mut a = self.anim.borrow_mut();
1711        let still = a.update();
1712        if still {
1713            request_frame();
1714        }
1715        a.get().clamp(0.0, 1.0)
1716    }
1717
1718    /// Content fade progress -> fades ahead of container on collapse.
1719    pub fn content_progress(&self) -> f32 {
1720        let mut a = self.content_anim.borrow_mut();
1721        let still = a.update();
1722        if still {
1723            request_frame();
1724        }
1725        a.get().clamp(0.0, 1.0)
1726    }
1727
1728    /// Whether the animation is currently running.
1729    pub fn is_animating(&self) -> bool {
1730        self.anim.borrow().is_animating() || self.content_anim.borrow().is_animating()
1731    }
1732
1733    /// Whether the search bar is currently expanded (with tolerance for spring overshoot).
1734    pub fn current_value(&self) -> SearchBarValue {
1735        if *self.anim.borrow().get() <= 0.02 {
1736            SearchBarValue::Collapsed
1737        } else {
1738            SearchBarValue::Expanded
1739        }
1740    }
1741
1742    /// Snap the container progress to a specific fraction (0.0 = collapsed, 1.0 = expanded).
1743    pub fn snap_to(&self, fraction: f32) {
1744        self.anim.borrow_mut().snap_to(fraction.clamp(0.0, 1.0));
1745        request_frame();
1746    }
1747}
1748
1749#[derive(Clone)]
1750pub struct SearchBarInputFieldConfig {
1751    pub state: Option<Rc<SearchBarState>>,
1752    pub on_search: Option<Rc<dyn Fn(String)>>,
1753    pub enabled: bool,
1754    pub text_color: Color,
1755    pub placeholder_color: Color,
1756    pub leading_icon: Option<View>,
1757    pub trailing_icon: Option<View>,
1758    pub interaction_source: Option<MutableInteractionSource>,
1759}
1760
1761impl Default for SearchBarInputFieldConfig {
1762    fn default() -> Self {
1763        let th = theme();
1764        Self {
1765            state: None,
1766            on_search: None,
1767            enabled: true,
1768            text_color: th.on_surface,
1769            placeholder_color: th.on_surface_variant,
1770            leading_icon: None,
1771            trailing_icon: None,
1772            interaction_source: None,
1773        }
1774    }
1775}
1776
1777/// Build a search bar input field with proper M3 SearchBar styling.
1778/// Equivalent to Compose Material3's `SearchBarDefaults.InputField`.
1779/// When `state` is provided, focus gain triggers expand and Escape triggers collapse.
1780/// Always renders a `UiTextField` (focusable even in collapsed state, matching CK).
1781pub fn SearchBarInputField(
1782    placeholder: String,
1783    query: String,
1784    on_query_change: Rc<dyn Fn(String)>,
1785    expanded: bool,
1786    config: SearchBarInputFieldConfig,
1787) -> View {
1788    let source: Rc<MutableInteractionSource> = config
1789        .interaction_source
1790        .clone()
1791        .map(Rc::new)
1792        .unwrap_or_else(|| Rc::new(MutableInteractionSource::new()));
1793    let focused = source.source().collect_is_focused();
1794    let state = config.state;
1795    let enabled = config.enabled;
1796
1797    let mut input_m = Modifier::new()
1798        .flex_grow(1.0)
1799        .padding(4.0)
1800        .required_width_in(SearchBarDefaults::MIN_WIDTH, SearchBarDefaults::MAX_WIDTH)
1801        .required_height_in(SearchBarDefaults::HEIGHT, SearchBarDefaults::HEIGHT)
1802        .interaction_source(&*source)
1803        .semantics(Semantics {
1804            role: Role::TextField,
1805            label: Some("Search".into()),
1806            focused: expanded || focused,
1807            enabled,
1808            selectable_group: false,
1809        })
1810        .on_key_event({
1811            let s = state.clone();
1812            move |ev| {
1813                if ev.key == Key::Escape {
1814                    if let Some(ref s) = s {
1815                        if s.is_active() {
1816                            s.deactivate();
1817                        }
1818                    }
1819                    true
1820                } else if ev.key == Key::ArrowDown || ev.key == Key::ArrowUp {
1821                    if let Some(ref s) = s {
1822                        if !s.is_expanded() {
1823                            s.activate();
1824                        }
1825                    }
1826                    true
1827                } else {
1828                    false
1829                }
1830            }
1831        });
1832    if let Some(ref s) = state {
1833        let s2 = s.clone();
1834        input_m = input_m.on_focus_changed(move |focused| {
1835            if focused {
1836                s2.activate();
1837            }
1838        });
1839    }
1840
1841    let on_qc = on_query_change.clone();
1842    let on_s = config.on_search.clone();
1843
1844    // Always render the text field (focusable even when collapsed, matching CK).
1845    let read_only = !expanded;
1846
1847    let display_color = if query.is_empty() {
1848        config.placeholder_color
1849    } else {
1850        config.text_color
1851    };
1852
1853    let tf_state = remember_with_key("SearchBarInputField_tf_state", || {
1854        RefCell::new(TextFieldState::new())
1855    });
1856    if tf_state.borrow().text != query {
1857        tf_state.borrow_mut().text = query.clone();
1858    }
1859
1860    // Build the row: [leading_icon] + text_field + [trailing_icon]
1861    let mut row_children: Vec<View> = Vec::new();
1862    if let Some(icon) = config.leading_icon {
1863        row_children.push(icon);
1864    }
1865    let on_qc2 = on_qc.clone();
1866    row_children.push(
1867        BasicTextField(
1868            tf_state.clone(),
1869            input_m,
1870            placeholder,
1871            repose_ui::TextFieldConfig {
1872                on_change: Some(Rc::new(move |text| on_qc2(text))),
1873                on_submit: on_s.clone(),
1874                enabled,
1875                read_only,
1876                line_limits: TextFieldLineLimits::SingleLine,
1877                keyboard_options: KeyboardOptions {
1878                    ime_action: ImeAction::Search,
1879                    ..KeyboardOptions::DEFAULT
1880                },
1881                ..Default::default()
1882            },
1883        )
1884        .color(display_color)
1885        .size(repose_core::locals::theme().typography.body_large),
1886    );
1887    if let Some(icon) = config.trailing_icon {
1888        row_children.push(icon);
1889    }
1890
1891    if row_children.len() == 1 {
1892        row_children.into_iter().next().unwrap()
1893    } else {
1894        Row(Modifier::new()
1895            .fill_max_width()
1896            .align_items(AlignItems::CENTER))
1897        .child(row_children)
1898    }
1899}
1900
1901/// Apply tonal elevation as a translucent primary overlay when the container
1902/// color matches the surface color. This mirrors CK's Surface tonalElevation.
1903fn apply_tonal_elevation(m: Modifier, elevation: f32, container: Color) -> Modifier {
1904    if elevation > 0.0 {
1905        let th = theme();
1906        if container == th.colors.surface {
1907            let overlay_alpha = (elevation * 4.0 + 4.0).min(24.0) / 100.0;
1908            return m.background(th.colors.primary.with_alpha_f32(overlay_alpha));
1909        }
1910    }
1911    m
1912}
1913
1914/// Record the collapsed bar's layout rect on the state. Returns a modifier
1915/// that should be applied to the collapsed bar.
1916fn track_collapsed_layout(state: &Rc<SearchBarState>) -> Modifier {
1917    let s = state.clone();
1918    Modifier::new().on_globally_positioned(move |rect| {
1919        s.collapsed_layout_rect
1920            .set((rect.x, rect.y, rect.w, rect.h));
1921    })
1922}
1923
1924
1925/// M3 Collapsed Search Bar -> renders ONLY the collapsed bar surface wrapping
1926/// the provided `input_field`. Does NOT manage expanded content.
1927///
1928/// Equivalent to CK's `SearchBar(state, inputField)` overload -> a passive
1929/// Surface that does NOT handle clicks or ripple. The click/focus→expand
1930/// behavior is managed by the `InputField` (via `SearchBarInputField`).
1931///
1932/// Pressing <kbd>Escape</kbd> deactivates the search bar (cross-platform back).
1933///
1934/// Use [`ExpandedFullScreenSearchBar`] / [`ExpandedDockedSearchBar`] for the
1935/// expanded state, or [`SearchBarWithContent`] for an all-in-one variant.
1936pub fn SearchBar(
1937    state: Rc<SearchBarState>,
1938    input_field: View,
1939    modifier: Modifier,
1940    leading_icon: Option<View>,
1941    trailing_icon: Option<View>,
1942    config: SearchBarConfig,
1943) -> View {
1944    let th = theme();
1945    let colors = config.colors;
1946
1947    let mut bar_m = modifier
1948        .fill_max_width()
1949        .height(config.height)
1950        .state_elevation(StateElevation {
1951            default: config.tonal_elevation,
1952            hovered: th.elevation.level2,
1953            pressed: th.elevation.level3,
1954            disabled: 0.0,
1955        })
1956        .shadow(config.shadow_elevation, 0.0)
1957        .padding_values(config.content_padding)
1958        .on_key_event({
1959            let s = state.clone();
1960            move |ev| {
1961                if ev.key == Key::Escape && s.is_active() {
1962                    s.deactivate();
1963                    true
1964                } else {
1965                    false
1966                }
1967            }
1968        })
1969        .on_focus_changed({
1970            let s = state.clone();
1971            move |focused| {
1972                if focused {
1973                    s.activate();
1974                }
1975            }
1976        })
1977        .semantics(Semantics {
1978            role: Role::TextField,
1979            label: Some("Search".into()),
1980            focused: state.is_active(),
1981            enabled: true,
1982            selectable_group: false,
1983        })
1984        .background(colors.container_color)
1985        .clip_rounded(config.shape_radius)
1986        .then(track_collapsed_layout(&state));
1987
1988    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, colors.container_color);
1989
1990    Box(bar_m).child(
1991        Row(Modifier::new()
1992            .fill_max_size()
1993            .align_items(AlignItems::CENTER))
1994        .child((
1995            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
1996            Box(Modifier::new().width(8.0).fill_max_height()),
1997            input_field,
1998            trailing_icon.unwrap_or(Box(Modifier::new())),
1999        )),
2000    )
2001}
2002
2003
2004/// M3 Search Bar that manages expanded content with animated width and
2005/// suggestions dropdown. Equivalent to CK's
2006/// `SearchBar(inputField, expanded, onExpandedChange, ..., content)` overload.
2007///
2008/// The bar itself is a passive surface (no click handling) -> expansion is
2009/// driven by the `InputField`'s focus tracking inside `input_field`.
2010pub fn SearchBarWithContent(
2011    input_field: View,
2012    expanded: bool,
2013    on_expanded_change: Rc<dyn Fn(bool)>,
2014    modifier: Modifier,
2015    leading_icon: Option<View>,
2016    trailing_icon: Option<View>,
2017    config: SearchBarConfig,
2018    content: View,
2019) -> View {
2020    let th = theme();
2021    let width = animate_f32(
2022        "sbwc_w",
2023        if expanded {
2024            config.expanded_width
2025        } else {
2026            config.collapsed_width
2027        },
2028        theme().motion.expand,
2029    );
2030
2031    let bar_bg = if expanded {
2032        config.colors.active_container_color
2033    } else {
2034        config.colors.container_color
2035    };
2036    let shape = if expanded {
2037        config.active_shape_radius
2038    } else {
2039        config.shape_radius
2040    };
2041
2042    let mut bar_m = modifier
2043        .clone()
2044        .width(width)
2045        .min_width(config.min_width)
2046        .max_width(config.max_width)
2047        .height(config.height)
2048        .shadow(config.shadow_elevation, 0.0)
2049        .padding_values(config.content_padding)
2050        .on_key_event({
2051            let cb = on_expanded_change.clone();
2052            move |ev| {
2053                if ev.key == Key::Escape {
2054                    cb(false);
2055                    true
2056                } else {
2057                    false
2058                }
2059            }
2060        })
2061        .background(bar_bg)
2062        .clip_rounded(shape);
2063
2064    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
2065
2066    // Content fades with separate alpha so content can fade before collapse
2067    let content_alpha = animate_f32("sbwc_a", if expanded { 1.0 } else { 0.0 }, th.motion.color);
2068
2069    let bar = Box(bar_m).child(
2070        Row(Modifier::new()
2071            .fill_max_size()
2072            .align_items(AlignItems::CENTER))
2073        .child((
2074            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
2075            Box(Modifier::new().width(8.0).fill_max_height()),
2076            input_field,
2077            trailing_icon.unwrap_or(Box(Modifier::new())),
2078        )),
2079    );
2080
2081    let show_content = expanded || content_alpha > 0.01;
2082    if show_content || expanded {
2083        Column(modifier).child((
2084            bar,
2085            Box(Modifier::new()
2086                .width(width)
2087                .max_height(SearchBarDefaults::DOCKED_HEIGHT)
2088                .alpha(content_alpha)
2089                .background(config.colors.container_color)
2090                .clip_rounded(th.shapes.extra_small))
2091            .child(content),
2092        ))
2093    } else {
2094        bar
2095    }
2096}
2097
2098/// M3 Docked Search Bar -> bounded-width variant with animated suggestions
2099/// dropdown (height + alpha).  Equivalent to CK's
2100/// `DockedSearchBar(inputField, expanded, onExpandedChange, ..., content)`.
2101/// The bar itself is a passive Surface -> expansion is driven by `InputField`.
2102pub fn DockedSearchBar(
2103    input_field: View,
2104    expanded: bool,
2105    on_expanded_change: Option<Rc<dyn Fn(bool)>>,
2106    modifier: Modifier,
2107    leading_icon: Option<View>,
2108    config: SearchBarConfig,
2109    content: View,
2110) -> View {
2111    let th = theme();
2112    let active = expanded;
2113    let colors = config.colors;
2114
2115    let content_target = if expanded {
2116        get_window_container_height() * 2.0 / 3.0
2117    } else {
2118        0.0
2119    };
2120    let content_height = animate_f32("docked_sh", content_target, theme().motion.expand);
2121    let content_alpha = animate_f32(
2122        "docked_sa",
2123        if expanded { 1.0 } else { 0.0 },
2124        theme().motion.color,
2125    );
2126    let bar_bg = if active {
2127        colors.active_container_color
2128    } else {
2129        colors.container_color
2130    };
2131
2132    let clear_btn = if active {
2133        Box(Modifier::new().size(24.0, 24.0).clickable().on_click({
2134            let cb = on_expanded_change.clone();
2135            move || {
2136                if let Some(ref cb) = cb {
2137                    cb(false);
2138                }
2139            }
2140        }))
2141        .child(Text("✕").size(16.0).color(colors.placeholder_color))
2142    } else {
2143        Box(Modifier::new())
2144    };
2145
2146    let mut bar_m = modifier
2147        .z_index(1.0)
2148        .min_width(SearchBarDefaults::MIN_WIDTH)
2149        .height(config.height)
2150        .state_elevation(StateElevation {
2151            default: if active {
2152                th.elevation.level3
2153            } else {
2154                config.tonal_elevation
2155            },
2156            hovered: th.elevation.level2,
2157            pressed: th.elevation.level3,
2158            disabled: 0.0,
2159        })
2160        .shadow(config.shadow_elevation, 0.0)
2161        .padding_values(config.content_padding)
2162        .on_key_event({
2163            let cb = on_expanded_change.clone();
2164            move |ev| {
2165                if ev.key == Key::Escape {
2166                    if let Some(ref cb) = cb {
2167                        cb(false);
2168                    }
2169                    true
2170                } else {
2171                    false
2172                }
2173            }
2174        })
2175        .background(bar_bg)
2176        .clip_rounded(config.shape_radius);
2177
2178    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
2179
2180    let bar = Box(bar_m).child(
2181        Row(Modifier::new()
2182            .fill_max_size()
2183            .align_items(AlignItems::CENTER))
2184        .child((
2185            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
2186            Box(Modifier::new().width(12.0).fill_max_height()),
2187            input_field,
2188            clear_btn,
2189        )),
2190    );
2191
2192    let show_content = expanded || content_height > 1.0;
2193    if show_content {
2194        Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
2195            bar,
2196            Box(Modifier::new()
2197                .min_width(SearchBarDefaults::MIN_WIDTH)
2198                .height(content_height)
2199                .alpha(content_alpha)
2200                .clip_rounded(th.shapes.small)
2201                .background(colors.container_color)
2202                .state_elevation(StateElevation {
2203                    default: th.elevation.level3,
2204                    hovered: th.elevation.level3,
2205                    pressed: th.elevation.level3,
2206                    disabled: 0.0,
2207                }))
2208            .child(
2209                Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
2210                    Box(Modifier::new()
2211                        .min_width(SearchBarDefaults::MIN_WIDTH)
2212                        .height(1.0)
2213                        .background(colors.divider_color)),
2214                    content,
2215                )),
2216            ),
2217        ))
2218    } else {
2219        bar
2220    }
2221}
2222
2223/// Platform-agnostic window container height. On Skiko this would read
2224/// `LocalWindowInfo`, on Android `LocalConfiguration`. Defaults to 800 dp.
2225/// The `LayoutEngine` keeps this current from the physical viewport + density.
2226pub fn set_window_container_height(h: f32) {
2227    repose_core::locals::set_window_container_height(h);
2228}
2229
2230fn get_window_container_height() -> f32 {
2231    repose_core::locals::get_window_container_height()
2232}
2233
2234/// Set the window container width (in dp) used for dropdown constraints.
2235pub fn set_window_container_width(w: f32) {
2236    repose_core::locals::set_window_container_width(w);
2237}
2238
2239fn get_window_container_width() -> f32 {
2240    repose_core::locals::get_window_container_width()
2241}
2242
2243/// M3 Expanded Full‑Screen Search Bar -> rendered in an overlay covering the
2244/// entire window. Uses the state's own `progress()` for animation.
2245/// Equivalent to CK's `ExpandedFullScreenSearchBar(state, inputField, ...)`.
2246pub fn ExpandedFullScreenSearchBar(
2247    state: Rc<SearchBarState>,
2248    overlay: OverlayHandle,
2249    input_field: View,
2250    modifier: Modifier,
2251    config: ExpandedFullScreenSearchBarConfig,
2252    content: View,
2253) -> View {
2254    // Mark as full-screen so AppBarWithSearch can hide the collapsed bar
2255    state.expands_to_full_screen.set(true);
2256
2257    let overlay_id = remember_with_key("efs_oid", || signal(0u64));
2258    let current_content = remember_state_with_key("efs_cc", || Box(Modifier::new()));
2259    *current_content.borrow_mut() = content;
2260
2261    let progress = state.progress();
2262    let _content_alpha = state.content_progress();
2263
2264    let expanded = state.is_expanded();
2265    let visible = expanded || progress > 0.01;
2266
2267    if visible {
2268        if overlay_id.get() == 0 {
2269            let input_fr = FocusRequester::new();
2270            let builder: Rc<dyn Fn() -> View> = Rc::new({
2271                let state = state.clone();
2272                let modifier = modifier.clone();
2273                let input_field = input_field.clone();
2274                let current_content = current_content.clone();
2275                let config = config.clone();
2276                let input_fr = input_fr.clone();
2277                move || {
2278                    let progress = state.progress();
2279                    let content_alpha = state.content_progress();
2280                    let alpha = progress.clamp(0.0, 1.0);
2281                    let c_alpha = content_alpha.clamp(0.0, 1.0);
2282                    let th = theme();
2283                    let content = current_content.borrow().clone();
2284
2285                    // Wrap input with focus requester and request focus (CK parity: auto-focus on expand)
2286                    let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
2287                        .child(input_field.clone());
2288                    input_fr.request_focus();
2289
2290                    let header = Box(modifier
2291                        .clone()
2292                        .fill_max_width()
2293                        .height(SearchBarDefaults::HEIGHT)
2294                        .padding_values(PaddingValues {
2295                            left: 16.0,
2296                            right: 16.0,
2297                            top: 0.0,
2298                            bottom: 0.0,
2299                        })
2300                        .background(config.colors.container_color)
2301                        .alpha(alpha))
2302                    .child(inp);
2303
2304                    let body = Box(Modifier::new()
2305                        .fill_max_width()
2306                        .flex_grow(1.0)
2307                        .alpha(c_alpha)
2308                        .background(th.surface))
2309                    .child(content);
2310
2311                    let insets = config.window_insets;
2312                    let full = Column(Modifier::new().fill_max_size().padding_values(
2313                        PaddingValues {
2314                            left: insets.left,
2315                            right: insets.right,
2316                            top: insets.top,
2317                            bottom: insets.bottom,
2318                        },
2319                    ))
2320                    .child((header, body));
2321
2322                    let scrim = Box(Modifier::new()
2323                        .fill_max_size()
2324                        .background(config.scrim_color.with_alpha((85.0 * alpha) as u8))
2325                        .on_click({
2326                            let s = state.clone();
2327                            move || s.collapse()
2328                        }));
2329
2330                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, full))
2331                }
2332            });
2333
2334            let id = overlay.show_entry(builder, 900.0, false);
2335            overlay_id.set(id);
2336        }
2337    } else {
2338        let prev = overlay_id.get();
2339        if prev != 0 {
2340            let _ = overlay.dismiss(prev);
2341            overlay_id.set(0);
2342        }
2343    }
2344
2345    Box(Modifier::new())
2346}
2347
2348/// M3 Expanded Docked Search Bar -> rendered as an overlay popup anchored below
2349/// the collapsed search bar using `collapsed_layout_rect`.
2350/// Equivalent to CK's `ExpandedDockedSearchBar(state, inputField, ...)`.
2351pub fn ExpandedDockedSearchBar(
2352    state: Rc<SearchBarState>,
2353    overlay: OverlayHandle,
2354    input_field: View,
2355    modifier: Modifier,
2356    config: ExpandedDockedSearchBarConfig,
2357    content: View,
2358) -> View {
2359    // Docked search bar does NOT expand to full-screen
2360    state.expands_to_full_screen.set(false);
2361
2362    let overlay_id = remember_with_key("eds_oid", || signal(0u64));
2363    let current_content = remember_state_with_key("eds_cc", || Box(Modifier::new()));
2364    *current_content.borrow_mut() = content;
2365
2366    let progress = state.progress();
2367    let _content_alpha = state.content_progress();
2368    let expanded = state.is_expanded();
2369    let visible = expanded || progress > 0.01;
2370
2371    if visible {
2372        if overlay_id.get() == 0 {
2373            let input_fr = FocusRequester::new();
2374            let builder: Rc<dyn Fn() -> View> = Rc::new({
2375                let state = state.clone();
2376                let modifier = modifier.clone();
2377                let input_field = input_field.clone();
2378                let current_content = current_content.clone();
2379                let config = config.clone();
2380                let input_fr = input_fr.clone();
2381                move || {
2382                    let progress = state.progress();
2383                    let content_alpha = state.content_progress();
2384                    let alpha = progress.clamp(0.0, 1.0);
2385                    let c_alpha = content_alpha.clamp(0.0, 1.0);
2386                    let th = theme();
2387                    let content = current_content.borrow().clone();
2388                    let (_cx, _cy, _cw, _ch) = state.collapsed_layout_rect.get();
2389
2390                    let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
2391                        .child(input_field.clone());
2392                    input_fr.request_focus();
2393
2394                    let header = Box(modifier
2395                        .clone()
2396                        .fill_max_width()
2397                        .height(SearchBarDefaults::HEIGHT)
2398                        .alpha(alpha)
2399                        .background(config.colors.container_color)
2400                        .clip_rounded(config.shape_radius)
2401                        .state_elevation(StateElevation {
2402                            default: th.elevation.level3,
2403                            hovered: th.elevation.level2,
2404                            pressed: th.elevation.level3,
2405                            disabled: 0.0,
2406                        }))
2407                    .child(inp);
2408
2409                    let dropdown = Box(Modifier::new()
2410                        .fill_max_width()
2411                        .max_height(get_window_container_height() * 2.0 / 3.0)
2412                        .alpha(c_alpha)
2413                        .clip_rounded(config.dropdown_shape_radius)
2414                        .background(config.colors.container_color)
2415                        .state_elevation(StateElevation {
2416                            default: th.elevation.level3,
2417                            hovered: th.elevation.level3,
2418                            pressed: th.elevation.level3,
2419                            disabled: 0.0,
2420                        }))
2421                    .child(
2422                        Column(Modifier::new().fill_max_width()).child((
2423                            Box(Modifier::new()
2424                                .fill_max_width()
2425                                .height(1.0)
2426                                .background(config.colors.divider_color)),
2427                            content,
2428                        )),
2429                    );
2430
2431                    let col = Column(Modifier::new().fill_max_width().padding_values(
2432                        PaddingValues {
2433                            left: _cx.max(16.0),
2434                            right: 16.0,
2435                            top: _cy + _ch + config.dropdown_gap_size,
2436                            bottom: 0.0,
2437                        },
2438                    ))
2439                    .child((header, dropdown));
2440
2441                    let scrim = Box(Modifier::new()
2442                        .fill_max_size()
2443                        .background(config.dropdown_scrim_color)
2444                        .on_click({
2445                            let s = state.clone();
2446                            move || s.collapse()
2447                        }));
2448
2449                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, col))
2450                }
2451            });
2452
2453            let id = overlay.show_entry(builder, 900.0, false);
2454            overlay_id.set(id);
2455        }
2456    } else {
2457        let prev = overlay_id.get();
2458        if prev != 0 {
2459            let _ = overlay.dismiss(prev);
2460            overlay_id.set(0);
2461        }
2462    }
2463
2464    Box(Modifier::new())
2465}
2466
2467/// M3 App Bar With Search -> integrates a search bar into a top app bar layout
2468/// with optional navigation icon, action buttons, scroll behavior, and window insets.
2469/// Wraps the internal `SearchBar` collapsed component.
2470pub fn AppBarWithSearch(
2471    state: Rc<SearchBarState>,
2472    input_field: View,
2473    navigation_icon: Option<View>,
2474    actions: Option<Vec<View>>,
2475    config: AppBarWithSearchConfig,
2476) -> View {
2477    let bg = config.colors.search_bar_container(config.scroll_fraction);
2478    let app_bar_bg = config.colors.app_bar_container(config.scroll_fraction);
2479
2480    let insets = config.window_insets;
2481
2482    // CK parity: when app bar container is transparent, disable tonal/shadow elevations
2483    let is_container_transparent = app_bar_bg.3 == 0;
2484    let tonal_elevation = if is_container_transparent {
2485        0.0
2486    } else {
2487        config.tonal_elevation
2488    };
2489    let shadow_elevation = if is_container_transparent {
2490        0.0
2491    } else {
2492        config.shadow_elevation
2493    };
2494
2495    // Hide the collapsed bar when full-screen expanded (CK parity via expandsToFullScreen)
2496    let hide_collapsed = state.expands_to_full_screen.get() && state.is_expanded();
2497    let collapsed_alpha = if hide_collapsed { 0.0 } else { 1.0 };
2498
2499    let bar_m = Modifier::new()
2500        .fill_max_width()
2501        .height(config.height + insets.top)
2502        .translate(0.0, config.scroll_offset)
2503        .background(app_bar_bg)
2504        .semantics(Semantics::new(Role::Container).with_selectable_group());
2505
2506    let row = Row(Modifier::new()
2507        .fill_max_size()
2508        .align_items(AlignItems::CENTER)
2509        .padding_values(PaddingValues {
2510            left: config.content_padding.left + insets.left,
2511            right: config.content_padding.right + insets.right,
2512            top: insets.top,
2513            bottom: 0.0,
2514        }))
2515    .child({
2516        let mut children: Vec<View> = Vec::new();
2517        if let Some(nav) = navigation_icon {
2518            children.push(nav);
2519            children.push(Box(Modifier::new().width(4.0)));
2520        }
2521        // Wrap input_field in collapsed SearchBar (CK parity)
2522        let sb_colors = &config.colors.search_bar_colors;
2523        let collapsed_bar = SearchBar(
2524            state.clone(),
2525            input_field,
2526            Modifier::new().flex_grow(1.0).alpha(collapsed_alpha),
2527            None,
2528            None,
2529            SearchBarConfig {
2530                height: config.height - 8.0,
2531                shape_radius: config.shape_radius,
2532                colors: SearchBarColors {
2533                    container_color: bg,
2534                    active_container_color: bg,
2535                    divider_color: sb_colors.divider_color,
2536                    content_color: sb_colors.content_color,
2537                    placeholder_color: sb_colors.placeholder_color,
2538                    scrim_color: sb_colors.scrim_color,
2539                },
2540                tonal_elevation,
2541                shadow_elevation,
2542                ..Default::default()
2543            },
2544        );
2545        children.push(Box(Modifier::new().flex_grow(1.0)).child(collapsed_bar));
2546        if let Some(acts) = actions {
2547            children.push(Spacer());
2548            for a in acts {
2549                children.push(a);
2550            }
2551        }
2552        children
2553    });
2554
2555    Box(bar_m.shadow(shadow_elevation, 0.0)).child(row)
2556}
2557
2558/// State for `ModalBottomSheet` - manages visibility and drag offset.
2559pub struct SheetState {
2560    visible: Signal<bool>,
2561    drag_offset: Signal<f32>,
2562    peek_height: Signal<f32>,
2563}
2564
2565impl SheetState {
2566    pub fn new(peek_height: f32) -> Self {
2567        Self {
2568            visible: signal(false),
2569            drag_offset: signal(0.0),
2570            peek_height: signal(peek_height),
2571        }
2572    }
2573
2574    pub fn is_visible(&self) -> bool {
2575        self.visible.get()
2576    }
2577
2578    pub fn show(&self) {
2579        self.visible.set(true);
2580    }
2581
2582    pub fn dismiss(&self) {
2583        self.visible.set(false);
2584        self.drag_offset.set(0.0);
2585    }
2586
2587    pub fn set_peek_height(&self, h: f32) {
2588        self.peek_height.set(h);
2589    }
2590}
2591
2592/// M3 Modal Bottom Sheet - slides up from the bottom with a drag handle.
2593///
2594/// Renders as an overlay so it is not clipped by parent containers.
2595/// Shows on `state.show()`, dismisses on `state.dismiss()` or scrim tap.
2596pub fn ModalBottomSheet(
2597    state: Rc<SheetState>,
2598    overlay: OverlayHandle,
2599    modifier: Modifier,
2600    content: View,
2601    config: BottomSheetConfig,
2602) -> View {
2603    let th = theme();
2604    let peek_h = state.peek_height.get().max(config.peek_height);
2605    let anim_distance = peek_h.max(48.0).max(400.0);
2606    let overlay_id = remember_with_key("mbs_oid", || signal(0u64));
2607
2608    // Drag state -> offset_at_drag_start is the anim value when the drag began
2609    let drag_anchor_y: Rc<RefCell<f32>> = remember_state_with_key("mbs_drag_y", || 0.0);
2610    let offset_at_drag_start: Rc<RefCell<f32>> = remember_state_with_key("mbs_drag_base", || 0.0);
2611    let is_dragging: Rc<RefCell<bool>> = remember_state_with_key("mbs_drag", || false);
2612
2613    // Animated offset: anim_distance px (off-screen) → 0px (visible)
2614    let anim = remember_state_with_key("mbs_anim", || {
2615        AnimatedValue::new(anim_distance, theme().motion.spring)
2616    });
2617    let last_target = remember_state_with_key("mbs_anim_target", || f32::NAN);
2618    let anim_target = if state.is_visible() {
2619        0.0
2620    } else {
2621        anim_distance
2622    };
2623
2624    {
2625        let mut a = anim.borrow_mut();
2626        let mut lt = last_target.borrow_mut();
2627        if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
2628            if state.is_visible() {
2629                a.set_spec(th.motion.spring);
2630            } else {
2631                a.set_spec(AnimationSpec::fast());
2632            }
2633            a.set_target(anim_target);
2634            *lt = anim_target;
2635        }
2636        drop(lt);
2637        let still_animating = a.update();
2638        if still_animating {
2639            request_frame();
2640        }
2641    }
2642
2643    let offset = *anim.borrow().get();
2644    let sheet_visible = state.is_visible() || offset < anim_distance - 10.0;
2645
2646    if sheet_visible {
2647        if overlay_id.get() == 0 {
2648            let builder: Rc<dyn Fn() -> View> = Rc::new({
2649                let state = state.clone();
2650                let anim = anim.clone();
2651                let modifier = modifier.clone();
2652                let content = content.clone();
2653                let drag_anchor_y = drag_anchor_y.clone();
2654                let offset_at_drag_start = offset_at_drag_start.clone();
2655                let is_dragging = is_dragging.clone();
2656                let anim_distance = anim_distance;
2657                move || {
2658                    let off = *anim.borrow().get();
2659
2660                    let sheet_body = Box(modifier
2661                        .clone()
2662                        .fill_max_width()
2663                        .max_width(dp_to_px(config.max_width))
2664                        .translate(0.0, off)
2665                        .background(config.container_color)
2666                        .clip_rounded(config.shape_radius)
2667                        .on_pointer_down({
2668                            let anim = anim.clone();
2669                            let drag_anchor_y = drag_anchor_y.clone();
2670                            let offset_at_drag_start = offset_at_drag_start.clone();
2671                            let is_dragging = is_dragging.clone();
2672                            move |ev| {
2673                                *drag_anchor_y.borrow_mut() = ev.position.y;
2674                                *offset_at_drag_start.borrow_mut() = *anim.borrow().get();
2675                                *is_dragging.borrow_mut() = true;
2676                            }
2677                        })
2678                        .on_pointer_move({
2679                            let anim = anim.clone();
2680                            let drag_anchor_y = drag_anchor_y.clone();
2681                            let offset_at_drag_start = offset_at_drag_start.clone();
2682                            let is_dragging = is_dragging.clone();
2683                            move |ev| {
2684                                if !*is_dragging.borrow() {
2685                                    return;
2686                                }
2687                                let delta = ev.position.y - *drag_anchor_y.borrow();
2688                                let start_off = *offset_at_drag_start.borrow();
2689                                let total = (start_off + delta).max(0.0);
2690                                anim.borrow_mut().snap_to(total);
2691                                request_frame();
2692                            }
2693                        })
2694                        .on_pointer_up({
2695                            let anim = anim.clone();
2696                            let is_dragging = is_dragging.clone();
2697                            let state = state.clone();
2698                            let anim_distance = anim_distance;
2699                            move |_| {
2700                                *is_dragging.borrow_mut() = false;
2701                                let current_off = *anim.borrow().get();
2702                                let threshold = anim_distance * 0.3;
2703                                if current_off > threshold {
2704                                    anim.borrow_mut().set_target(anim_distance);
2705                                    state.dismiss();
2706                                } else {
2707                                    anim.borrow_mut().set_target(0.0);
2708                                }
2709                            }
2710                        }))
2711                    .child(
2712                        Column(Modifier::new().fill_max_width()).child((
2713                            Row(Modifier::new()
2714                                .fill_max_width()
2715                                .justify_content(JustifyContent::CENTER))
2716                            .child(Box(Modifier::new()
2717                                .margin_vertical(22.0)
2718                                .width(config.drag_handle_width)
2719                                .height(config.drag_handle_height)
2720                                .background(config.drag_handle_color)
2721                                .clip_rounded(2.0))),
2722                            content.clone(),
2723                        )),
2724                    );
2725
2726                    let sheet = Box(Modifier::new()
2727                        .fill_max_size()
2728                        .justify_content(JustifyContent::CENTER)
2729                        .align_items(AlignItems::FLEX_END))
2730                    .child(sheet_body);
2731
2732                    let scrim_alpha = if state.is_visible() {
2733                        config.scrim_color.3
2734                    } else {
2735                        let t = (off / anim_distance).clamp(0.0, 1.0);
2736                        (config.scrim_color.3 as f32 * (1.0 - t)) as u8
2737                    };
2738                    let scrim = Box(Modifier::new()
2739                        .fill_max_size()
2740                        .background(config.scrim_color.with_alpha(scrim_alpha))
2741                        .on_pointer_down({
2742                            let s = state.clone();
2743                            move |_| s.dismiss()
2744                        }));
2745
2746                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, sheet))
2747                }
2748            });
2749
2750            let id = overlay.show_entry(builder, 900.0, false);
2751            overlay_id.set(id);
2752        }
2753    } else {
2754        let prev = overlay_id.get();
2755        if prev != 0 {
2756            let _ = overlay.dismiss(prev);
2757            overlay_id.set(0);
2758        }
2759    }
2760
2761    Box(Modifier::new())
2762}
2763
2764/// State for `PullToRefresh` - tracks pull progress and refresh trigger.
2765///
2766/// Connect to a [`ScrollState`](repose_ui::scroll::ScrollState) via
2767/// [`set_scroll_state`](PullToRefreshState::set_scroll_state) so that the
2768/// pull offset is automatically driven by scroll overscroll.
2769pub struct PullToRefreshState {
2770    refreshing: Signal<bool>,
2771    scroll_state: RefCell<Option<Rc<repose_ui::scroll::ScrollState>>>,
2772    threshold: f32,
2773    triggered: Cell<bool>,
2774}
2775
2776impl Default for PullToRefreshState {
2777    fn default() -> Self {
2778        Self::new()
2779    }
2780}
2781
2782impl PullToRefreshState {
2783    pub fn new() -> Self {
2784        Self {
2785            refreshing: signal(false),
2786            scroll_state: RefCell::new(None),
2787            threshold: 64.0,
2788            triggered: Cell::new(false),
2789        }
2790    }
2791
2792    /// Connect this PullToRefresh state to a scroll state.
2793    /// The pull offset is then derived from the scroll state's overscroll.
2794    pub fn set_scroll_state(&self, state: Rc<repose_ui::scroll::ScrollState>) {
2795        *self.scroll_state.borrow_mut() = Some(state);
2796    }
2797
2798    /// Set the overscroll threshold that triggers a refresh (default 64px).
2799    pub fn set_threshold(&mut self, px: f32) {
2800        self.threshold = px;
2801    }
2802
2803    pub fn is_refreshing(&self) -> bool {
2804        self.refreshing.get()
2805    }
2806
2807    pub fn set_refreshing(&self, v: bool) {
2808        self.refreshing.set(v);
2809        if !v && let Some(sc) = self.scroll_state.borrow().as_ref() {
2810            sc.set_overscroll(0.0);
2811        }
2812    }
2813
2814    /// Read the current pull offset from the connected scroll state's overscroll.
2815    pub fn pull_offset(&self) -> f32 {
2816        if let Some(sc) = self.scroll_state.borrow().as_ref() {
2817            let os = sc.overscroll_offset();
2818            if os < 0.0 { -os } else { 0.0 }
2819        } else {
2820            0.0
2821        }
2822    }
2823}
2824
2825/// Wraps scrollable content with a pull-to-refresh indicator.
2826///
2827/// Renders a small spinner at the top when the user pulls down past a threshold,
2828/// or shows the current pull offset as a visual indicator.
2829///
2830/// The `state` must be connected to a [`ScrollState`](repose_ui::scroll::ScrollState)
2831/// via [`set_scroll_state`](PullToRefreshState::set_scroll_state) for the pull
2832/// offset to be derived from the scroll overscroll automatically.
2833pub fn PullToRefresh(
2834    state: Rc<PullToRefreshState>,
2835    modifier: Modifier,
2836    on_refresh: Rc<dyn Fn()>,
2837    content: View,
2838    config: PullToRefreshConfig,
2839) -> View {
2840    let pull = state.pull_offset();
2841    let refreshing = state.is_refreshing();
2842    let threshold = config.threshold;
2843
2844    if state.triggered.get() && !refreshing && pull < threshold {
2845        state.triggered.set(false);
2846    }
2847
2848    if !refreshing && !state.triggered.get() && pull >= threshold {
2849        state.triggered.set(true);
2850        state.refreshing.set(true);
2851        (on_refresh)();
2852    }
2853
2854    let frac_key = format!("ptr_frac_{}", Rc::as_ptr(&state) as u64);
2855    let raw_frac = if refreshing {
2856        1.0
2857    } else if pull > 0.0 {
2858        pull / threshold
2859    } else {
2860        0.0
2861    };
2862    let distance_fraction = animate_f32_from(frac_key, 0.0, raw_frac, theme().motion.color);
2863
2864    let adjusted_percent = (distance_fraction.min(1.0) - 0.4).max(0.0) * 5.0 / 3.0;
2865    let overshoot_percent = (distance_fraction - 1.0).max(0.0);
2866    let linear_tension = overshoot_percent.min(2.0);
2867    let tension_percent = linear_tension - linear_tension.powi(2) / 4.0;
2868    let rotation_turns = (-0.25 + 0.4 * adjusted_percent + tension_percent) * 0.5;
2869    // rotate by 360° to convert turns → degrees, then to radians for the modifier
2870    let spinner_rotation_rad = rotation_turns * std::f32::consts::TAU;
2871
2872    // Indicator at top (pushed into view by overscroll) + content below.
2873    let indicator_h = distance_fraction * threshold;
2874    let comp_scale = adjusted_percent.min(1.0);
2875    let icon_size = if refreshing {
2876        24.0
2877    } else {
2878        (16.0 + comp_scale * 8.0).min(24.0)
2879    };
2880    let rotation = if refreshing {
2881        animate_f32_from(
2882            "ptr_spin",
2883            0.0,
2884            std::f32::consts::TAU,
2885            AnimationSpec::tween(Duration::from_millis(1000), Easing::Linear)
2886                .repeated(RepeatableSpec::infinite()),
2887        )
2888    } else {
2889        spinner_rotation_rad
2890    };
2891    let alpha = if refreshing {
2892        1.0
2893    } else if distance_fraction >= 1.0 {
2894        1.0
2895    } else {
2896        0.3
2897    };
2898    Column(modifier.align_items(config.content_alignment)).child((
2899        if distance_fraction > 0.01 {
2900            Box(Modifier::new()
2901                .fill_max_width()
2902                .height(indicator_h)
2903                .align_items(AlignItems::CENTER)
2904                .justify_content(JustifyContent::CENTER))
2905            .child(
2906                Box(Modifier::new()
2907                    .size(icon_size, icon_size)
2908                    .translate(icon_size * 0.5, icon_size * 0.5)
2909                    .rotate(rotation)
2910                    .translate(-icon_size * 0.5, -icon_size * 0.5))
2911                .child(if refreshing {
2912                    Icon(Symbol::new("refresh", '\u{E5D5}'))
2913                        .size(24.0)
2914                        .color(config.indicator_color)
2915                } else {
2916                    Icon(Symbol::new("arrow_downward", '\u{E5DB}'))
2917                        .size(icon_size)
2918                        .color(config.indicator_color.with_alpha_f32(alpha))
2919                }),
2920            )
2921        } else {
2922            Box(Modifier::new())
2923        },
2924        content,
2925    ))
2926}
2927
2928/// State for `DatePicker` - manages selected date.
2929pub struct DatePickerState {
2930    pub year: Signal<i32>,
2931    pub month: Signal<u32>, // 1-12
2932    pub day: Signal<u32>,
2933}
2934
2935impl DatePickerState {
2936    pub fn new(year: i32, month: u32, day: u32) -> Self {
2937        Self {
2938            year: signal(year),
2939            month: signal(month.clamp(1, 12)),
2940            day: signal(day.clamp(1, 31)),
2941        }
2942    }
2943
2944    pub fn selected_date(&self) -> (i32, u32, u32) {
2945        (self.year.get(), self.month.get(), self.day.get())
2946    }
2947}
2948
2949fn days_in_month(year: i32, month: u32) -> u32 {
2950    match month {
2951        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
2952        4 | 6 | 9 | 11 => 30,
2953        2 => {
2954            if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
2955                29
2956            } else {
2957                28
2958            }
2959        }
2960        _ => 30,
2961    }
2962}
2963
2964/// Day of week for the first day of the given month/year.
2965/// Returns 0=Mon ... 6=Sun using Zeller-like formula for Gregorian calendar.
2966fn first_day_of_month(year: i32, month: u32) -> u32 {
2967    let m = month as i32;
2968    let (y, adj_m) = if m <= 2 {
2969        (year - 1, m + 12)
2970    } else {
2971        (year, m)
2972    };
2973    let k = y % 100;
2974    let j = y / 100;
2975    let h = (1 + (13 * (adj_m + 1)) / 5 + k + k / 4 + j / 4 + 5 * j) % 7;
2976    // Convert Zeller's Saturday=0 to Monday=0, Sunday=6
2977    ((h + 5) % 7) as u32
2978}
2979
2980/// Simple calendar date for today-highlighting in DatePicker.
2981struct ReposeDate {
2982    year: i32,
2983    month: u32,
2984    day: u32,
2985}
2986
2987impl ReposeDate {
2988    /// Compute today's date from the system clock.
2989    fn now() -> Self {
2990        let duration = web_time::SystemTime::now()
2991            .duration_since(web_time::UNIX_EPOCH)
2992            .unwrap_or_default();
2993        let days = (duration.as_secs() / 86_400) as i64;
2994        // Howard Hinnant's civil_from_days
2995        let z = days + 719468;
2996        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
2997        let doe = (z - era * 146_097) as u64;
2998        let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
2999        let y = (yoe as i64) + era * 400;
3000        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
3001        let mp = (5 * doy + 2) / 153;
3002        let d = doy - (153 * mp + 2) / 5 + 1;
3003        let m = if mp < 10 { mp + 3 } else { mp - 9 };
3004        let y = if m <= 2 { y + 1 } else { y };
3005        Self {
3006            year: y as i32,
3007            month: m as u32,
3008            day: d as u32,
3009        }
3010    }
3011}
3012
3013const MONTH_NAMES: [&str; 12] = [
3014    "January",
3015    "February",
3016    "March",
3017    "April",
3018    "May",
3019    "June",
3020    "July",
3021    "August",
3022    "September",
3023    "October",
3024    "November",
3025    "December",
3026];
3027
3028const DOW_HEADERS: [&str; 7] = ["M", "T", "W", "T", "F", "S", "S"];
3029
3030/// Colors for [`DatePicker`].
3031#[derive(Clone)]
3032pub struct DatePickerColors {
3033    pub container_color: Color,
3034    pub header_color: Color,
3035    pub weekday_color: Color,
3036    pub day_color: Color,
3037    pub selected_day_color: Color,
3038    pub selected_day_container_color: Color,
3039    pub today_content_color: Color,
3040    pub today_border_color: Color,
3041    pub navigation_color: Color,
3042    pub year_selected_container_color: Color,
3043    pub year_selected_content_color: Color,
3044    pub year_unselected_content_color: Color,
3045}
3046
3047impl Default for DatePickerColors {
3048    fn default() -> Self {
3049        Self {
3050            container_color: DatePickerDefaults::container_color(),
3051            header_color: DatePickerDefaults::header_color(),
3052            weekday_color: DatePickerDefaults::weekday_color(),
3053            day_color: DatePickerDefaults::day_color(),
3054            selected_day_color: DatePickerDefaults::selected_day_color(),
3055            selected_day_container_color: DatePickerDefaults::selected_day_container_color(),
3056            today_content_color: DatePickerDefaults::today_content_color(),
3057            today_border_color: DatePickerDefaults::today_border_color(),
3058            navigation_color: DatePickerDefaults::header_color(),
3059            year_selected_container_color: DatePickerDefaults::year_selected_container_color(),
3060            year_selected_content_color: DatePickerDefaults::year_selected_content_color(),
3061            year_unselected_content_color: DatePickerDefaults::year_unselected_content_color(),
3062        }
3063    }
3064}
3065
3066/// Configuration for [`DatePicker`].
3067#[derive(Clone)]
3068pub struct DatePickerConfig {
3069    pub modifier: Modifier,
3070    pub colors: DatePickerColors,
3071    pub show_mode_toggle: bool,
3072}
3073
3074impl Default for DatePickerConfig {
3075    fn default() -> Self {
3076        Self {
3077            modifier: Modifier::new(),
3078            colors: DatePickerColors::default(),
3079            show_mode_toggle: true,
3080        }
3081    }
3082}
3083
3084/// M3 Date Picker dialog with month/year navigation, proper calendar grid,
3085/// today indicator, and confirm/cancel actions.
3086pub fn DatePicker(
3087    state: Rc<DatePickerState>,
3088    on_confirm: Rc<dyn Fn(i32, u32, u32)>,
3089    on_dismiss: Rc<dyn Fn()>,
3090    config: DatePickerConfig,
3091) -> View {
3092    let th = theme();
3093    let (year, month, day) = state.selected_date();
3094    let dim = days_in_month(year, month);
3095    let start_dow = first_day_of_month(year, month);
3096
3097    // Year step helpers
3098    let prev_year = {
3099        let s = state.clone();
3100        move || {
3101            s.year.set(s.year.get() - 1);
3102            let d = days_in_month(s.year.get(), s.month.get());
3103            if s.day.get() > d {
3104                s.day.set(d);
3105            }
3106        }
3107    };
3108    let next_year = {
3109        let s = state.clone();
3110        move || {
3111            s.year.set(s.year.get() + 1);
3112            let d = days_in_month(s.year.get(), s.month.get());
3113            if s.day.get() > d {
3114                s.day.set(d);
3115            }
3116        }
3117    };
3118
3119    let prev_month = {
3120        let s = state.clone();
3121        move || {
3122            if s.month.get() == 1 {
3123                s.year.set(s.year.get() - 1);
3124                s.month.set(12);
3125            } else {
3126                s.month.set(s.month.get() - 1);
3127            }
3128            let d = days_in_month(s.year.get(), s.month.get());
3129            if s.day.get() > d {
3130                s.day.set(d);
3131            }
3132        }
3133    };
3134
3135    let next_month = {
3136        let s = state.clone();
3137        move || {
3138            if s.month.get() == 12 {
3139                s.year.set(s.year.get() + 1);
3140                s.month.set(1);
3141            } else {
3142                s.month.set(s.month.get() + 1);
3143            }
3144            let d = days_in_month(s.year.get(), s.month.get());
3145            if s.day.get() > d {
3146                s.day.set(d);
3147            }
3148        }
3149    };
3150
3151    // Determine today for highlight
3152    let now = ReposeDate::now();
3153    let today = (now.year, now.month, now.day);
3154
3155    Column(config.modifier.padding(16.0)).child((
3156        // Month header
3157        Row(Modifier::new()
3158            .fill_max_width()
3159            .align_items(AlignItems::CENTER))
3160        .child((
3161            IconButton(
3162                Box(Modifier::new())
3163                    .child(Text("â—€").color(config.colors.navigation_color).size(16.0)),
3164                prev_month,
3165                IconButtonConfig::default(),
3166            ),
3167            Spacer(),
3168            Column(Modifier::new().align_items(AlignItems::CENTER)).child((
3169                Text(MONTH_NAMES[(month - 1) as usize].to_string())
3170                    .size(th.typography.title_medium)
3171                    .color(config.colors.header_color),
3172                Row(Modifier::new().gap(8.0).align_items(AlignItems::CENTER)).child((
3173                    IconButton(
3174                        Box(Modifier::new())
3175                            .child(Text("‹").color(config.colors.navigation_color).size(14.0)),
3176                        prev_year,
3177                        IconButtonConfig::default(),
3178                    ),
3179                    Text(year.to_string())
3180                        .size(th.typography.body_small)
3181                        .color(th.on_surface_variant),
3182                    IconButton(
3183                        Box(Modifier::new())
3184                            .child(Text("›").color(config.colors.navigation_color).size(14.0)),
3185                        next_year,
3186                        IconButtonConfig::default(),
3187                    ),
3188                )),
3189            )),
3190            Spacer(),
3191            IconButton(
3192                Box(Modifier::new())
3193                    .child(Text("â–¶").color(config.colors.navigation_color).size(16.0)),
3194                next_month,
3195                IconButtonConfig::default(),
3196            ),
3197        )),
3198        Box(Modifier::new().fill_max_width().height(12.0)),
3199        // Day grid
3200        Column(Modifier::new()).child({
3201            let mut rows: Vec<View> = Vec::new();
3202            // Day-of-week headers
3203            let dow_headers: Vec<View> = DOW_HEADERS
3204                .iter()
3205                .map(|d| {
3206                    Box(Modifier::new()
3207                        .width(40.0)
3208                        .height(40.0)
3209                        .align_items(AlignItems::CENTER)
3210                        .justify_content(JustifyContent::CENTER))
3211                    .child(
3212                        Text(d.to_string())
3213                            .size(th.typography.label_small)
3214                            .color(config.colors.weekday_color),
3215                    )
3216                })
3217                .collect();
3218            rows.push(Row(Modifier::new()).with_children(dow_headers));
3219
3220            // Proper calendar grid: offset by start_dow, 6 rows
3221            let total_cells = start_dow + dim;
3222            let num_rows = total_cells.div_ceil(7).min(6);
3223            for w in 0..num_rows {
3224                let mut week: Vec<View> = Vec::new();
3225                for d in 0..7 {
3226                    let cell_idx = w * 7 + d;
3227                    if cell_idx < start_dow {
3228                        week.push(Box(Modifier::new().width(40.0).height(40.0)));
3229                    } else {
3230                        let day_num = (cell_idx - start_dow + 1) as i32;
3231                        if day_num <= dim as i32 {
3232                            let is_selected = day_num == day as i32;
3233                            let is_today =
3234                                today.0 == year && today.1 == month && today.2 == day_num as u32;
3235                            let s = state.clone();
3236                            week.push(
3237                                Box(Modifier::new()
3238                                    .width(40.0)
3239                                    .height(40.0)
3240                                    .background(if is_selected {
3241                                        config.colors.selected_day_container_color
3242                                    } else {
3243                                        Color::TRANSPARENT
3244                                    })
3245                                    .clip_rounded(20.0)
3246                                    .align_items(AlignItems::CENTER)
3247                                    .justify_content(JustifyContent::CENTER)
3248                                    .clickable()
3249                                    .on_click(move || {
3250                                        s.day.set(day_num as u32);
3251                                    }))
3252                                .child({
3253                                    let mut t = Text(day_num.to_string())
3254                                        .size(th.typography.body_medium)
3255                                        .color(if is_selected {
3256                                            config.colors.selected_day_color
3257                                        } else {
3258                                            config.colors.day_color
3259                                        });
3260                                    if is_today && !is_selected {
3261                                        t = t.modifier(Modifier::new().border(
3262                                            1.0,
3263                                            config.colors.today_border_color,
3264                                            10.0,
3265                                        ));
3266                                    }
3267                                    t
3268                                }),
3269                            );
3270                        } else {
3271                            week.push(Box(Modifier::new().width(40.0).height(40.0)));
3272                        }
3273                    }
3274                }
3275                rows.push(Row(Modifier::new()).with_children(week));
3276            }
3277            rows
3278        }),
3279        Box(Modifier::new().fill_max_width().height(12.0)),
3280        // Cancel / Confirm
3281        Row(Modifier::new()
3282            .fill_max_width()
3283            .justify_content(JustifyContent::END)
3284            .gap(8.0))
3285        .child((
3286            TextButton(
3287                Modifier::new(),
3288                {
3289                    let on_dismiss = on_dismiss.clone();
3290                    move || (on_dismiss)()
3291                },
3292                ButtonConfig::default(),
3293                || Text("Cancel").size(14.0),
3294            ),
3295            Button(
3296                Modifier::new(),
3297                {
3298                    let on_confirm = on_confirm.clone();
3299                    let s = state.clone();
3300                    move || {
3301                        let (y, m, d) = s.selected_date();
3302                        on_confirm(y, m, d);
3303                    }
3304                },
3305                ButtonConfig::default(),
3306                || Text("OK").size(14.0),
3307            ),
3308        )),
3309    ))
3310}
3311
3312/// State for `TimePicker` - manages selected hour and minute.
3313pub struct TimePickerState {
3314    pub hour: Signal<u32>,
3315    pub minute: Signal<u32>,
3316    pub is_am: Signal<bool>,
3317}
3318
3319impl TimePickerState {
3320    pub fn new(hour: u32, minute: u32) -> Self {
3321        let h = hour % 12;
3322        let am = hour < 12;
3323        Self {
3324            hour: signal(if h == 0 { 12 } else { h }),
3325            minute: signal(minute.min(59)),
3326            is_am: signal(am),
3327        }
3328    }
3329
3330    pub fn selected_time(&self) -> (u32, u32) {
3331        let mut h = self.hour.get();
3332        if !self.is_am.get() {
3333            h = (h % 12) + 12;
3334        } else if h == 12 {
3335            h = 0;
3336        }
3337        (h, self.minute.get())
3338    }
3339}
3340
3341/// Layout types for [`TimePicker`].
3342#[derive(Clone, Copy, PartialEq, Debug)]
3343pub enum TimePickerLayoutType {
3344    Horizontal,
3345    Vertical,
3346}
3347
3348/// Colors for [`TimePicker`].
3349#[derive(Clone)]
3350pub struct TimePickerColors {
3351    pub clock_dial_color: Color,
3352    pub clock_dial_selected_content_color: Color,
3353    pub clock_dial_unselected_content_color: Color,
3354    pub selector_color: Color,
3355    pub container_color: Color,
3356    pub period_selector_border_color: Color,
3357    pub period_selector_selected_container_color: Color,
3358    pub period_selector_unselected_container_color: Color,
3359    pub period_selector_selected_content_color: Color,
3360    pub period_selector_unselected_content_color: Color,
3361    pub time_selector_selected_container_color: Color,
3362    pub time_selector_unselected_container_color: Color,
3363    pub time_selector_selected_content_color: Color,
3364    pub time_selector_unselected_content_color: Color,
3365}
3366
3367impl Default for TimePickerColors {
3368    fn default() -> Self {
3369        Self {
3370            clock_dial_color: TimePickerDefaults::clock_dial_color(),
3371            clock_dial_selected_content_color:
3372                TimePickerDefaults::clock_dial_selected_content_color(),
3373            clock_dial_unselected_content_color:
3374                TimePickerDefaults::clock_dial_unselected_content_color(),
3375            selector_color: TimePickerDefaults::selector_color(),
3376            container_color: TimePickerDefaults::container_color(),
3377            period_selector_border_color: TimePickerDefaults::period_selector_border_color(),
3378            period_selector_selected_container_color:
3379                TimePickerDefaults::period_selector_selected_container_color(),
3380            period_selector_unselected_container_color:
3381                TimePickerDefaults::period_selector_unselected_container_color(),
3382            period_selector_selected_content_color:
3383                TimePickerDefaults::period_selector_selected_content_color(),
3384            period_selector_unselected_content_color:
3385                TimePickerDefaults::period_selector_unselected_content_color(),
3386            time_selector_selected_container_color:
3387                TimePickerDefaults::time_selector_selected_container_color(),
3388            time_selector_unselected_container_color:
3389                TimePickerDefaults::time_selector_unselected_container_color(),
3390            time_selector_selected_content_color:
3391                TimePickerDefaults::time_selector_selected_content_color(),
3392            time_selector_unselected_content_color:
3393                TimePickerDefaults::time_selector_unselected_content_color(),
3394        }
3395    }
3396}
3397
3398/// Configuration for [`TimePicker`].
3399#[derive(Clone)]
3400pub struct TimePickerConfig {
3401    pub modifier: Modifier,
3402    pub colors: TimePickerColors,
3403    pub layout_type: TimePickerLayoutType,
3404}
3405
3406impl Default for TimePickerConfig {
3407    fn default() -> Self {
3408        Self {
3409            modifier: Modifier::new(),
3410            colors: TimePickerColors::default(),
3411            layout_type: TimePickerLayoutType::Vertical,
3412        }
3413    }
3414}
3415
3416/// M3 Time Picker - a simple time picker with hour/minute fields and AM/PM toggle.
3417pub fn TimePicker(
3418    state: Rc<TimePickerState>,
3419    on_confirm: Rc<dyn Fn(u32, u32)>,
3420    on_dismiss: Rc<dyn Fn()>,
3421    config: TimePickerConfig,
3422) -> View {
3423    let th = theme();
3424    let hour = state.hour.get();
3425    let minute = state.minute.get();
3426    let is_am = state.is_am.get();
3427
3428    let hour_str = format!("{:02}", hour);
3429    let min_str = format!("{:02}", minute);
3430
3431    Column(
3432        config
3433            .modifier
3434            .width(256.0)
3435            .padding(24.0)
3436            .align_items(AlignItems::CENTER),
3437    )
3438    .child((
3439        // Time display
3440        Row(Modifier::new().align_items(AlignItems::CENTER)).child((
3441            Box(Modifier::new()
3442                .clickable()
3443                .on_click({
3444                    let s = state.clone();
3445                    move || s.hour.set((s.hour.get() % 12) + 1)
3446                })
3447                .padding(8.0))
3448            .child(
3449                Text(hour_str)
3450                    .size(48.0)
3451                    .color(config.colors.clock_dial_unselected_content_color)
3452                    .single_line(),
3453            ),
3454            Text(":")
3455                .size(48.0)
3456                .color(config.colors.clock_dial_unselected_content_color)
3457                .single_line(),
3458            Box(Modifier::new()
3459                .clickable()
3460                .on_click({
3461                    let s = state.clone();
3462                    move || s.minute.set((s.minute.get() + 1) % 60)
3463                })
3464                .padding(8.0))
3465            .child(
3466                Text(min_str)
3467                    .size(48.0)
3468                    .color(config.colors.clock_dial_unselected_content_color)
3469                    .single_line(),
3470            ),
3471        )),
3472        Box(Modifier::new().fill_max_width().height(16.0)),
3473        // AM/PM toggle
3474        Row(Modifier::new().align_items(AlignItems::CENTER)).child((
3475            Box(Modifier::new()
3476                .padding_values(PaddingValues {
3477                    left: 12.0,
3478                    right: 12.0,
3479                    top: 4.0,
3480                    bottom: 4.0,
3481                })
3482                .background(if is_am {
3483                    config.colors.period_selector_selected_container_color
3484                } else {
3485                    Color::TRANSPARENT
3486                })
3487                .clip_rounded(8.0)
3488                .clickable()
3489                .on_click({
3490                    let s = state.clone();
3491                    move || {
3492                        if !s.is_am.get() {
3493                            s.is_am.set(true);
3494                            let h = s.hour.get();
3495                            s.hour.set(if h == 12 { 12 } else { (h + 12) % 24 });
3496                            if s.hour.get() == 0 {
3497                                s.hour.set(12);
3498                            }
3499                        }
3500                    }
3501                }))
3502            .child(Text("AM").size(th.typography.label_large).color(if is_am {
3503                config.colors.period_selector_selected_content_color
3504            } else {
3505                config.colors.period_selector_unselected_content_color
3506            })),
3507            Box(Modifier::new().width(8.0).height(1.0)),
3508            Box(Modifier::new()
3509                .padding_values(PaddingValues {
3510                    left: 12.0,
3511                    right: 12.0,
3512                    top: 4.0,
3513                    bottom: 4.0,
3514                })
3515                .background(if !is_am {
3516                    config.colors.period_selector_selected_container_color
3517                } else {
3518                    Color::TRANSPARENT
3519                })
3520                .clip_rounded(8.0)
3521                .clickable()
3522                .on_click({
3523                    let s = state.clone();
3524                    move || {
3525                        if s.is_am.get() {
3526                            s.is_am.set(false);
3527                            let h = s.hour.get();
3528                            s.hour.set(if h == 12 { 12 } else { (h + 12) % 24 });
3529                            if s.hour.get() == 0 {
3530                                s.hour.set(12);
3531                            }
3532                        }
3533                    }
3534                }))
3535            .child(Text("PM").size(th.typography.label_large).color(if !is_am {
3536                config.colors.period_selector_selected_content_color
3537            } else {
3538                config.colors.period_selector_unselected_content_color
3539            })),
3540        )),
3541        Box(Modifier::new().fill_max_width().height(16.0)),
3542        Row(Modifier::new().fill_max_width()).child((
3543            Spacer(),
3544            Box(Modifier::new().padding(8.0).clickable().on_click({
3545                let on_dismiss = on_dismiss.clone();
3546                move || on_dismiss()
3547            }))
3548            .child(
3549                Text("Cancel")
3550                    .color(config.colors.selector_color)
3551                    .size(th.typography.label_large)
3552                    .single_line(),
3553            ),
3554            Box(Modifier::new().width(8.0).height(1.0)),
3555            Box(Modifier::new().padding(8.0).clickable().on_click({
3556                let on_confirm = on_confirm.clone();
3557                let state = state.clone();
3558                move || {
3559                    let (h, m) = state.selected_time();
3560                    on_confirm(h, m);
3561                }
3562            }))
3563            .child(
3564                Text("OK")
3565                    .color(config.colors.selector_color)
3566                    .size(th.typography.label_large)
3567                    .single_line(),
3568            ),
3569        )),
3570    ))
3571}
3572
3573/// A destination entry inside a NavigationRail.
3574pub struct NavRailItem {
3575    pub icon: View,
3576    pub label: String,
3577    pub on_click: Rc<dyn Fn()>,
3578    pub badge: Option<View>,
3579    pub enabled: bool,
3580    pub interaction_source: Option<MutableInteractionSource>,
3581}
3582
3583static NAVRAIL_COUNTER: AtomicU64 = AtomicU64::new(0);
3584static FILTERCHIP_COUNTER: AtomicU64 = AtomicU64::new(0);
3585
3586/// M3 Navigation Rail - a compact vertical navigation sidebar.
3587///
3588/// Typically placed on the left side of the screen. Contains navigation items
3589/// (icon + label) with animated selection indicator.
3590pub fn NavigationRail(
3591    selected_index: usize,
3592    items: Vec<NavRailItem>,
3593    header: Option<View>,
3594    fab: Option<View>,
3595    config: NavigationRailConfig,
3596) -> View {
3597    let th = theme();
3598    let id = remember(|| NAVRAIL_COUNTER.fetch_add(1, Ordering::Relaxed));
3599    let default_effects = AnimationSpec::spring_crit(40.0);
3600
3601    let mut top_children: Vec<View> = Vec::new();
3602    let mut item_views: Vec<View> = Vec::new();
3603
3604    let has_header = header.is_some();
3605    let has_fab = fab.is_some();
3606
3607    if let Some(h) = header {
3608        top_children.push(
3609            Box(Modifier::new()
3610                .padding_values(PaddingValues {
3611                    left: 12.0,
3612                    right: 12.0,
3613                    top: 12.0,
3614                    bottom: 12.0,
3615                })
3616                .align_self(AlignSelf::CENTER))
3617            .child(h),
3618        );
3619    }
3620
3621    if let Some(f) = fab {
3622        top_children.push(
3623            Box(Modifier::new()
3624                .padding_values(PaddingValues {
3625                    left: 12.0,
3626                    right: 12.0,
3627                    top: 8.0,
3628                    bottom: 8.0,
3629                })
3630                .align_self(AlignSelf::CENTER))
3631            .child(f),
3632        );
3633    }
3634
3635    if has_header || has_fab {
3636        top_children.push(Box(Modifier::new()
3637            .fill_max_width()
3638            .height(1.0)
3639            .background(th.outline_variant)));
3640    }
3641
3642    for (i, item) in items.into_iter().enumerate() {
3643        let selected = i == selected_index;
3644        let is_enabled = item.enabled;
3645
3646        let fg = animate_color(
3647            format!("nr_fg_{}_{}", id, i),
3648            if selected {
3649                config.selected_icon_color
3650            } else {
3651                config.unselected_icon_color
3652            },
3653            default_effects,
3654        );
3655        let fg_label = animate_color(
3656            format!("nr_fl_{}_{}", id, i),
3657            if selected {
3658                config.selected_text_color
3659            } else {
3660                config.unselected_text_color
3661            },
3662            default_effects,
3663        );
3664        let bg = animate_color(
3665            format!("nr_bg_{}_{}", id, i),
3666            if selected {
3667                config.indicator_color
3668            } else {
3669                Color::TRANSPARENT
3670            },
3671            default_effects,
3672        );
3673
3674        let cb = item.on_click.clone();
3675        let nr_source: Rc<MutableInteractionSource> = item
3676            .interaction_source
3677            .clone()
3678            .map(Rc::new)
3679            .unwrap_or_else(|| remember(MutableInteractionSource::new));
3680
3681        let mut item_m = Modifier::new()
3682            .fill_max_width()
3683            .padding_values(PaddingValues {
3684                left: 4.0,
3685                right: 4.0,
3686                top: 4.0,
3687                bottom: 4.0,
3688            })
3689            .align_items(AlignItems::CENTER)
3690            .justify_content(JustifyContent::CENTER)
3691            .background(bg)
3692            .state_colors(StateColors {
3693                default: Color::TRANSPARENT,
3694                hovered: th.on_surface.with_alpha_f32(0.08),
3695                pressed: th.on_surface.with_alpha_f32(0.12),
3696                disabled: Color::TRANSPARENT,
3697            })
3698            .clip_rounded(config.item_radius)
3699            .interaction_source(&*nr_source)
3700            .semantics(Semantics::new(Role::Tab).with_label(&item.label));
3701
3702        if is_enabled {
3703            item_m = item_m.clickable().on_click({
3704                let cb = cb.clone();
3705                move || cb()
3706            });
3707        }
3708
3709        item_views.push(
3710            Column(item_m).child((
3711                Column(Modifier::new()).child((
3712                    Box(Modifier::new().size(24.0, 24.0))
3713                        .child(with_content_color(fg, move || item.icon)),
3714                    item.badge
3715                        .map(|b| {
3716                            Box(Modifier::new()
3717                                .absolute()
3718                                .offset(None, None, None, Some(0.0)))
3719                            .child(b)
3720                        })
3721                        .unwrap_or(Box(Modifier::new())),
3722                )),
3723                Box(Modifier::new().fill_max_width().height(4.0)),
3724                Text(item.label)
3725                    .color(fg_label)
3726                    .size(th.typography.label_medium)
3727                    .single_line(),
3728            )),
3729        );
3730    }
3731
3732    Column(
3733        Modifier::new()
3734            .width(config.width)
3735            .fill_max_height()
3736            .background(config.container_color)
3737            .align_items(AlignItems::CENTER)
3738            .semantics(Semantics::new(Role::Container).with_selectable_group())
3739            .then(config.modifier),
3740    )
3741    .child((
3742        Column(Modifier::new()).with_children(top_children),
3743        Box(Modifier::new().flex_grow(1.0)).child(
3744            Column(
3745                Modifier::new()
3746                    .fill_max_size()
3747                    .justify_content(JustifyContent::SPACE_BETWEEN)
3748                    .align_items(AlignItems::CENTER),
3749            )
3750            .with_children(item_views),
3751        ),
3752    ))
3753}
3754
3755/// Direction for the dismiss action.
3756#[derive(Clone, Copy, Debug, PartialEq)]
3757pub enum DismissDirection {
3758    StartToEnd,
3759    EndToStart,
3760    Both,
3761}
3762
3763/// Resolved state for swipe-to-dismiss.
3764#[derive(Clone, Copy, Debug, PartialEq)]
3765pub enum DismissValue {
3766    Default,
3767    DismissedToStart,
3768    DismissedToEnd,
3769}
3770
3771/// State for `SwipeToDismiss` - backed by a generic `SwipeableState<DismissValue>`.
3772pub struct SwipeToDismissState {
3773    swipeable: repose_core::SwipeableState<DismissValue>,
3774    dismissed_offset: f32,
3775}
3776
3777impl Default for SwipeToDismissState {
3778    fn default() -> Self {
3779        Self::new()
3780    }
3781}
3782
3783impl SwipeToDismissState {
3784    pub fn new() -> Self {
3785        Self::with_config(SwipeToDismissConfig::default())
3786    }
3787
3788    pub fn with_config(config: SwipeToDismissConfig) -> Self {
3789        let one_third = 1.0 / 3.0;
3790        let positional_threshold = (config.dismiss_threshold * one_third) / config.dismissed_offset;
3791        let mut anchors = vec![(0.0, DismissValue::Default)];
3792        if config.enable_dismiss_from_end_to_start {
3793            anchors.push((-config.dismissed_offset, DismissValue::DismissedToStart));
3794        }
3795        if config.enable_dismiss_from_start_to_end {
3796            anchors.push((config.dismissed_offset, DismissValue::DismissedToEnd));
3797        }
3798        // Sort by offset for correct clamp/nearest/next-anchor logic.
3799        anchors.sort_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap());
3800        let swipeable = repose_core::SwipeableState::new(
3801            anchors,
3802            repose_core::SwipeableConfig {
3803                animation_spec: config.animation_spec.clone(),
3804                positional_threshold,
3805                ..Default::default()
3806            },
3807        );
3808        // Start at the default position (not anchors[0], which may be negative).
3809        swipeable.snap_to(0.0);
3810        Self {
3811            swipeable,
3812            dismissed_offset: config.dismissed_offset,
3813        }
3814    }
3815
3816    /// Current animated offset in pixels.
3817    pub fn offset(&self) -> f32 {
3818        self.swipeable.offset()
3819    }
3820
3821    /// Snap instantly to an offset (used during active drag).
3822    pub fn set_offset_instant(&self, off: f32) {
3823        self.swipeable.snap_to(off);
3824    }
3825
3826    /// Whether the current position is past the dismiss threshold.
3827    pub fn is_dismissed(&self) -> bool {
3828        self.swipeable.current_value() != DismissValue::Default
3829    }
3830
3831    /// Animate to the dismissed position.
3832    pub fn dismiss(&self) {
3833        self.swipeable.animate_to(&DismissValue::DismissedToStart);
3834    }
3835
3836    /// Animate to the dismissed position with custom offset.
3837    pub fn dismiss_to(&self, offset: f32) {
3838        let value = if offset < 0.0 {
3839            DismissValue::DismissedToStart
3840        } else {
3841            DismissValue::DismissedToEnd
3842        };
3843        self.swipeable.animate_to(&value);
3844    }
3845
3846    /// Animate back to origin.
3847    pub fn reset(&self) {
3848        self.swipeable.animate_to(&DismissValue::Default);
3849    }
3850
3851    /// Fire the dismiss callback once when the spring settles past a given threshold.
3852    fn try_handle_dismiss_with_threshold(&self, on_dismiss: &Option<Rc<dyn Fn()>>, threshold: f32) {
3853        if !self.swipeable.is_animating() {
3854            let val = self.swipeable.current_value();
3855            if val != DismissValue::Default {
3856                if let Some(cb) = on_dismiss {
3857                    cb();
3858                }
3859            }
3860        }
3861    }
3862}
3863
3864/// M3 SwipeToDismiss - wraps content that can be swiped to reveal
3865/// a `background` action view. On release past the threshold the content
3866/// springs to the dismissed position and `on_dismiss` fires **once**.
3867///
3868/// The gesture logic uses `SwipeableState<DismissValue>` internally, so it
3869/// supports both left and right dismiss directions based on the config.
3870pub fn SwipeToDismiss(
3871    state: Rc<SwipeToDismissState>,
3872    on_dismiss: Option<Rc<dyn Fn()>>,
3873    background: View,
3874    content: View,
3875    modifier: Modifier,
3876    config: SwipeToDismissConfig,
3877) -> View {
3878    let offset = state.offset();
3879    state.try_handle_dismiss_with_threshold(&on_dismiss, config.dismiss_threshold);
3880
3881    let s1 = state.swipeable.clone();
3882    let s2 = state.swipeable.clone();
3883    let s3 = state.swipeable.clone();
3884    let on_down = { move |e: PointerEvent| s1.on_pointer_down(e.position.x) };
3885    let on_move = { move |e: PointerEvent| s2.on_pointer_move(e.position.x) };
3886    let on_up = { move |_e: PointerEvent| s3.on_pointer_up() };
3887
3888    let display_offset = offset
3889        .max(-config.dismissed_offset)
3890        .min(config.dismissed_offset);
3891
3892    let content_modifier = {
3893        let mut m = Modifier::new()
3894            .fill_max_width()
3895            .translate(display_offset, 0.0);
3896        if config.gestures_enabled {
3897            m = m
3898                .on_pointer_down(on_down)
3899                .on_pointer_move(on_move)
3900                .on_pointer_up(on_up);
3901        }
3902        m
3903    };
3904
3905    Column(modifier.fill_max_width()).child((
3906        Box(Modifier::new().fill_max_size().absolute()).child(background),
3907        Box(content_modifier).child(content),
3908    ))
3909}
3910
3911/// M3 Carousel - a horizontally scrolling container with peek edges.
3912///
3913/// Uses a `LazyRow` internally. The first and last items are partially visible
3914/// (peek) to indicate there is more scrollable content.
3915/// Configuration for [`Carousel`].
3916#[derive(Clone, Debug)]
3917pub struct CarouselConfig {
3918    pub modifier: Modifier,
3919}
3920
3921impl Default for CarouselConfig {
3922    fn default() -> Self {
3923        Self {
3924            modifier: Modifier::new(),
3925        }
3926    }
3927}
3928
3929/// M3 Carousel - a horizontally scrolling container with peek edges.
3930///
3931/// Uses a `LazyRow` internally. The first and last items are partially visible
3932/// (peek) to indicate there is more scrollable content.
3933pub fn Carousel<T, F>(
3934    items: Vec<T>,
3935    item_width: f32,
3936    peek_amount: f32,
3937    state: Rc<LazyRowState>,
3938    item_builder: F,
3939    config: CarouselConfig,
3940) -> View
3941where
3942    T: Clone + 'static,
3943    F: Fn(T, usize) -> View + 'static,
3944{
3945    let padded_modifier = config.modifier.padding_values(PaddingValues {
3946        left: peek_amount,
3947        right: peek_amount,
3948        top: 0.0,
3949        bottom: 0.0,
3950    });
3951
3952    LazyRow(
3953        items,
3954        item_width,
3955        item_builder,
3956        LazyRowConfig {
3957            state,
3958            modifier: padded_modifier,
3959            ..Default::default()
3960        },
3961    )
3962}