Skip to main content

repose_material/material3/
mod.rs

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