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