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
1794/// M3 Collapsed Search Bar -> renders ONLY the collapsed bar surface wrapping
1795/// the provided `input_field`. Does NOT manage expanded content.
1796///
1797/// Equivalent to CK's `SearchBar(state, inputField)` overload -> a passive
1798/// Surface that does NOT handle clicks or ripple. The click/focus→expand
1799/// behavior is managed by the `InputField` (via `SearchBarInputField`).
1800///
1801/// Pressing <kbd>Escape</kbd> deactivates the search bar (cross-platform back).
1802///
1803/// Use [`ExpandedFullScreenSearchBar`] / [`ExpandedDockedSearchBar`] for the
1804/// expanded state, or [`SearchBarWithContent`] for an all-in-one variant.
1805pub fn SearchBar(
1806    state: Rc<SearchBarState>,
1807    input_field: View,
1808    modifier: Modifier,
1809    leading_icon: Option<View>,
1810    trailing_icon: Option<View>,
1811    config: SearchBarConfig,
1812) -> View {
1813    let th = theme();
1814    let colors = config.colors;
1815
1816    let mut bar_m = modifier
1817        .fill_max_width()
1818        .height(config.height)
1819        .state_elevation(StateElevation {
1820            default: config.tonal_elevation,
1821            hovered: th.elevation.level2,
1822            pressed: th.elevation.level3,
1823            disabled: 0.0,
1824        })
1825        .shadow(config.shadow_elevation, 0.0)
1826        .padding_values(config.content_padding)
1827        .on_key_event({
1828            let s = state.clone();
1829            move |ev| {
1830                if ev.key == Key::Escape && s.is_active() {
1831                    s.deactivate();
1832                    true
1833                } else {
1834                    false
1835                }
1836            }
1837        })
1838        .on_focus_changed({
1839            let s = state.clone();
1840            move |focused| {
1841                if focused {
1842                    s.activate();
1843                }
1844            }
1845        })
1846        .semantics(Semantics {
1847            role: Role::TextField,
1848            label: Some("Search".into()),
1849            focused: state.is_active(),
1850            enabled: true,
1851            selectable_group: false,
1852        })
1853        .background(colors.container_color)
1854        .clip_rounded(config.shape_radius)
1855        .then(track_collapsed_layout(&state));
1856
1857    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, colors.container_color);
1858
1859    Box(bar_m).child(
1860        Row(Modifier::new()
1861            .fill_max_size()
1862            .align_items(AlignItems::CENTER))
1863        .child((
1864            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
1865            Box(Modifier::new().width(8.0).fill_max_height()),
1866            input_field,
1867            trailing_icon.unwrap_or(Box(Modifier::new())),
1868        )),
1869    )
1870}
1871
1872
1873/// M3 Search Bar that manages expanded content with animated width and
1874/// suggestions dropdown. Equivalent to CK's
1875/// `SearchBar(inputField, expanded, onExpandedChange, ..., content)` overload.
1876///
1877/// The bar itself is a passive surface (no click handling) -> expansion is
1878/// driven by the `InputField`'s focus tracking inside `input_field`.
1879pub fn SearchBarWithContent(
1880    input_field: View,
1881    expanded: bool,
1882    on_expanded_change: Rc<dyn Fn(bool)>,
1883    modifier: Modifier,
1884    leading_icon: Option<View>,
1885    trailing_icon: Option<View>,
1886    config: SearchBarConfig,
1887    content: View,
1888) -> View {
1889    let th = theme();
1890    let width = animate_f32(
1891        "sbwc_w",
1892        if expanded {
1893            config.expanded_width
1894        } else {
1895            config.collapsed_width
1896        },
1897        theme().motion.expand,
1898    );
1899
1900    let bar_bg = if expanded {
1901        config.colors.active_container_color
1902    } else {
1903        config.colors.container_color
1904    };
1905    let shape = if expanded {
1906        config.active_shape_radius
1907    } else {
1908        config.shape_radius
1909    };
1910
1911    let mut bar_m = modifier
1912        .clone()
1913        .width(width)
1914        .min_width(config.min_width)
1915        .max_width(config.max_width)
1916        .height(config.height)
1917        .shadow(config.shadow_elevation, 0.0)
1918        .padding_values(config.content_padding)
1919        .on_key_event({
1920            let cb = on_expanded_change.clone();
1921            move |ev| {
1922                if ev.key == Key::Escape {
1923                    cb(false);
1924                    true
1925                } else {
1926                    false
1927                }
1928            }
1929        })
1930        .background(bar_bg)
1931        .clip_rounded(shape);
1932
1933    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
1934
1935    // Content fades with separate alpha so content can fade before collapse
1936    let content_alpha = animate_f32("sbwc_a", if expanded { 1.0 } else { 0.0 }, th.motion.color);
1937
1938    let bar = Box(bar_m).child(
1939        Row(Modifier::new()
1940            .fill_max_size()
1941            .align_items(AlignItems::CENTER))
1942        .child((
1943            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
1944            Box(Modifier::new().width(8.0).fill_max_height()),
1945            input_field,
1946            trailing_icon.unwrap_or(Box(Modifier::new())),
1947        )),
1948    );
1949
1950    let show_content = expanded || content_alpha > 0.01;
1951    if show_content || expanded {
1952        Stack(modifier).child((
1953            bar,
1954            Box(Modifier::new()
1955                .width(width)
1956                .max_height(SearchBarDefaults::DOCKED_HEIGHT)
1957                .alpha(content_alpha)
1958                .background(config.colors.container_color)
1959                .clip_rounded(th.shapes.extra_small))
1960            .child(content),
1961        ))
1962    } else {
1963        bar
1964    }
1965}
1966
1967/// M3 Docked Search Bar -> bounded-width variant with animated suggestions
1968/// dropdown (height + alpha).  Equivalent to CK's
1969/// `DockedSearchBar(inputField, expanded, onExpandedChange, ..., content)`.
1970/// The bar itself is a passive Surface -> expansion is driven by `InputField`.
1971pub fn DockedSearchBar(
1972    input_field: View,
1973    expanded: bool,
1974    on_expanded_change: Option<Rc<dyn Fn(bool)>>,
1975    modifier: Modifier,
1976    leading_icon: Option<View>,
1977    config: SearchBarConfig,
1978    content: View,
1979) -> View {
1980    let th = theme();
1981    let active = expanded;
1982    let colors = config.colors;
1983
1984    let content_target = if expanded {
1985        get_window_container_height() * 2.0 / 3.0
1986    } else {
1987        0.0
1988    };
1989    let content_height = animate_f32("docked_sh", content_target, theme().motion.expand);
1990    let content_alpha = animate_f32(
1991        "docked_sa",
1992        if expanded { 1.0 } else { 0.0 },
1993        theme().motion.color,
1994    );
1995    let bar_bg = if active {
1996        colors.active_container_color
1997    } else {
1998        colors.container_color
1999    };
2000
2001    let clear_btn = if active {
2002        Box(Modifier::new().size(24.0, 24.0).clickable().on_click({
2003            let cb = on_expanded_change.clone();
2004            move || {
2005                if let Some(ref cb) = cb {
2006                    cb(false);
2007                }
2008            }
2009        }))
2010        .child(Text("✕").size(16.0).color(colors.placeholder_color))
2011    } else {
2012        Box(Modifier::new())
2013    };
2014
2015    let mut bar_m = modifier
2016        .z_index(1.0)
2017        .min_width(SearchBarDefaults::MIN_WIDTH)
2018        .height(config.height)
2019        .state_elevation(StateElevation {
2020            default: if active {
2021                th.elevation.level3
2022            } else {
2023                config.tonal_elevation
2024            },
2025            hovered: th.elevation.level2,
2026            pressed: th.elevation.level3,
2027            disabled: 0.0,
2028        })
2029        .shadow(config.shadow_elevation, 0.0)
2030        .padding_values(config.content_padding)
2031        .on_key_event({
2032            let cb = on_expanded_change.clone();
2033            move |ev| {
2034                if ev.key == Key::Escape {
2035                    if let Some(ref cb) = cb {
2036                        cb(false);
2037                    }
2038                    true
2039                } else {
2040                    false
2041                }
2042            }
2043        })
2044        .background(bar_bg)
2045        .clip_rounded(config.shape_radius);
2046
2047    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
2048
2049    let bar = Box(bar_m).child(
2050        Row(Modifier::new()
2051            .fill_max_size()
2052            .align_items(AlignItems::CENTER))
2053        .child((
2054            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
2055            Box(Modifier::new().width(12.0).fill_max_height()),
2056            input_field,
2057            clear_btn,
2058        )),
2059    );
2060
2061    let show_content = expanded || content_height > 1.0;
2062    if show_content {
2063        Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
2064            bar,
2065            Box(Modifier::new()
2066                .min_width(SearchBarDefaults::MIN_WIDTH)
2067                .height(content_height)
2068                .alpha(content_alpha)
2069                .clip_rounded(th.shapes.small)
2070                .background(colors.container_color)
2071                .state_elevation(StateElevation {
2072                    default: th.elevation.level3,
2073                    hovered: th.elevation.level3,
2074                    pressed: th.elevation.level3,
2075                    disabled: 0.0,
2076                }))
2077            .child(
2078                Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
2079                    Box(Modifier::new()
2080                        .min_width(SearchBarDefaults::MIN_WIDTH)
2081                        .height(1.0)
2082                        .background(colors.divider_color)),
2083                    content,
2084                )),
2085            ),
2086        ))
2087    } else {
2088        bar
2089    }
2090}
2091
2092/// Platform-agnostic window container height. On Skiko this would read
2093/// `LocalWindowInfo`, on Android `LocalConfiguration`. Defaults to 800 dp.
2094/// Override via [`set_window_container_height`] if needed.
2095use std::sync::Mutex;
2096static WINDOW_CONTAINER_HEIGHT: Mutex<f32> = Mutex::new(800.0);
2097
2098/// Set the window container height (in dp) used for search bar constraints.
2099pub fn set_window_container_height(h: f32) {
2100    if let Ok(mut v) = WINDOW_CONTAINER_HEIGHT.lock() {
2101        *v = h;
2102    }
2103}
2104
2105fn get_window_container_height() -> f32 {
2106    WINDOW_CONTAINER_HEIGHT.lock().map(|v| *v).unwrap_or(800.0)
2107}
2108
2109/// M3 Expanded Full‑Screen Search Bar -> rendered in an overlay covering the
2110/// entire window. Uses the state's own `progress()` for animation.
2111/// Equivalent to CK's `ExpandedFullScreenSearchBar(state, inputField, ...)`.
2112pub fn ExpandedFullScreenSearchBar(
2113    state: Rc<SearchBarState>,
2114    overlay: OverlayHandle,
2115    input_field: View,
2116    modifier: Modifier,
2117    config: ExpandedFullScreenSearchBarConfig,
2118    content: View,
2119) -> View {
2120    // Mark as full-screen so AppBarWithSearch can hide the collapsed bar
2121    state.expands_to_full_screen.set(true);
2122
2123    let overlay_id = remember_with_key("efs_oid", || signal(0u64));
2124    let current_content = remember_state_with_key("efs_cc", || Box(Modifier::new()));
2125    *current_content.borrow_mut() = content;
2126
2127    let progress = state.progress();
2128    let _content_alpha = state.content_progress();
2129
2130    let expanded = state.is_expanded();
2131    let visible = expanded || progress > 0.01;
2132
2133    if visible {
2134        if overlay_id.get() == 0 {
2135            let input_fr = FocusRequester::new();
2136            let builder: Rc<dyn Fn() -> View> = Rc::new({
2137                let state = state.clone();
2138                let modifier = modifier.clone();
2139                let input_field = input_field.clone();
2140                let current_content = current_content.clone();
2141                let config = config.clone();
2142                let input_fr = input_fr.clone();
2143                move || {
2144                    let progress = state.progress();
2145                    let content_alpha = state.content_progress();
2146                    let alpha = progress.clamp(0.0, 1.0);
2147                    let c_alpha = content_alpha.clamp(0.0, 1.0);
2148                    let th = theme();
2149                    let content = current_content.borrow().clone();
2150
2151                    // Wrap input with focus requester and request focus (CK parity: auto-focus on expand)
2152                    let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
2153                        .child(input_field.clone());
2154                    input_fr.request_focus();
2155
2156                    let header = Box(modifier
2157                        .clone()
2158                        .fill_max_width()
2159                        .height(SearchBarDefaults::HEIGHT)
2160                        .padding_values(PaddingValues {
2161                            left: 16.0,
2162                            right: 16.0,
2163                            top: 0.0,
2164                            bottom: 0.0,
2165                        })
2166                        .background(config.colors.container_color)
2167                        .alpha(alpha))
2168                    .child(inp);
2169
2170                    let body = Box(Modifier::new()
2171                        .fill_max_width()
2172                        .flex_grow(1.0)
2173                        .alpha(c_alpha)
2174                        .background(th.surface))
2175                    .child(content);
2176
2177                    let insets = config.window_insets;
2178                    let full = Column(Modifier::new().fill_max_size().padding_values(
2179                        PaddingValues {
2180                            left: insets.left,
2181                            right: insets.right,
2182                            top: insets.top,
2183                            bottom: insets.bottom,
2184                        },
2185                    ))
2186                    .child((header, body));
2187
2188                    let scrim = Box(Modifier::new()
2189                        .fill_max_size()
2190                        .background(config.scrim_color.with_alpha((85.0 * alpha) as u8))
2191                        .on_click({
2192                            let s = state.clone();
2193                            move || s.collapse()
2194                        }));
2195
2196                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, full))
2197                }
2198            });
2199
2200            let id = overlay.show_entry(builder, 900.0, false);
2201            overlay_id.set(id);
2202        }
2203    } else {
2204        let prev = overlay_id.get();
2205        if prev != 0 {
2206            let _ = overlay.dismiss(prev);
2207            overlay_id.set(0);
2208        }
2209    }
2210
2211    Box(Modifier::new())
2212}
2213
2214/// M3 Expanded Docked Search Bar -> rendered as an overlay popup anchored below
2215/// the collapsed search bar using `collapsed_layout_rect`.
2216/// Equivalent to CK's `ExpandedDockedSearchBar(state, inputField, ...)`.
2217pub fn ExpandedDockedSearchBar(
2218    state: Rc<SearchBarState>,
2219    overlay: OverlayHandle,
2220    input_field: View,
2221    modifier: Modifier,
2222    config: ExpandedDockedSearchBarConfig,
2223    content: View,
2224) -> View {
2225    // Docked search bar does NOT expand to full-screen
2226    state.expands_to_full_screen.set(false);
2227
2228    let overlay_id = remember_with_key("eds_oid", || signal(0u64));
2229    let current_content = remember_state_with_key("eds_cc", || Box(Modifier::new()));
2230    *current_content.borrow_mut() = content;
2231
2232    let progress = state.progress();
2233    let _content_alpha = state.content_progress();
2234    let expanded = state.is_expanded();
2235    let visible = expanded || progress > 0.01;
2236
2237    if visible {
2238        if overlay_id.get() == 0 {
2239            let input_fr = FocusRequester::new();
2240            let builder: Rc<dyn Fn() -> View> = Rc::new({
2241                let state = state.clone();
2242                let modifier = modifier.clone();
2243                let input_field = input_field.clone();
2244                let current_content = current_content.clone();
2245                let config = config.clone();
2246                let input_fr = input_fr.clone();
2247                move || {
2248                    let progress = state.progress();
2249                    let content_alpha = state.content_progress();
2250                    let alpha = progress.clamp(0.0, 1.0);
2251                    let c_alpha = content_alpha.clamp(0.0, 1.0);
2252                    let th = theme();
2253                    let content = current_content.borrow().clone();
2254                    let (_cx, _cy, _cw, _ch) = state.collapsed_layout_rect.get();
2255
2256                    let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
2257                        .child(input_field.clone());
2258                    input_fr.request_focus();
2259
2260                    let header = Box(modifier
2261                        .clone()
2262                        .fill_max_width()
2263                        .height(SearchBarDefaults::HEIGHT)
2264                        .alpha(alpha)
2265                        .background(config.colors.container_color)
2266                        .clip_rounded(config.shape_radius)
2267                        .state_elevation(StateElevation {
2268                            default: th.elevation.level3,
2269                            hovered: th.elevation.level2,
2270                            pressed: th.elevation.level3,
2271                            disabled: 0.0,
2272                        }))
2273                    .child(inp);
2274
2275                    let dropdown = Box(Modifier::new()
2276                        .fill_max_width()
2277                        .max_height(get_window_container_height() * 2.0 / 3.0)
2278                        .alpha(c_alpha)
2279                        .clip_rounded(config.dropdown_shape_radius)
2280                        .background(config.colors.container_color)
2281                        .state_elevation(StateElevation {
2282                            default: th.elevation.level3,
2283                            hovered: th.elevation.level3,
2284                            pressed: th.elevation.level3,
2285                            disabled: 0.0,
2286                        }))
2287                    .child(
2288                        Column(Modifier::new().fill_max_width()).child((
2289                            Box(Modifier::new()
2290                                .fill_max_width()
2291                                .height(1.0)
2292                                .background(config.colors.divider_color)),
2293                            content,
2294                        )),
2295                    );
2296
2297                    let col = Column(Modifier::new().fill_max_width().padding_values(
2298                        PaddingValues {
2299                            left: _cx.max(16.0),
2300                            right: 16.0,
2301                            top: _cy + _ch + config.dropdown_gap_size,
2302                            bottom: 0.0,
2303                        },
2304                    ))
2305                    .child((header, dropdown));
2306
2307                    let scrim = Box(Modifier::new()
2308                        .fill_max_size()
2309                        .background(config.dropdown_scrim_color)
2310                        .on_click({
2311                            let s = state.clone();
2312                            move || s.collapse()
2313                        }));
2314
2315                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, col))
2316                }
2317            });
2318
2319            let id = overlay.show_entry(builder, 900.0, false);
2320            overlay_id.set(id);
2321        }
2322    } else {
2323        let prev = overlay_id.get();
2324        if prev != 0 {
2325            let _ = overlay.dismiss(prev);
2326            overlay_id.set(0);
2327        }
2328    }
2329
2330    Box(Modifier::new())
2331}
2332
2333/// M3 App Bar With Search -> integrates a search bar into a top app bar layout
2334/// with optional navigation icon, action buttons, scroll behavior, and window insets.
2335/// Wraps the internal `SearchBar` collapsed component.
2336pub fn AppBarWithSearch(
2337    state: Rc<SearchBarState>,
2338    input_field: View,
2339    navigation_icon: Option<View>,
2340    actions: Option<Vec<View>>,
2341    config: AppBarWithSearchConfig,
2342) -> View {
2343    let bg = config.colors.search_bar_container(config.scroll_fraction);
2344    let app_bar_bg = config.colors.app_bar_container(config.scroll_fraction);
2345
2346    let insets = config.window_insets;
2347
2348    // CK parity: when app bar container is transparent, disable tonal/shadow elevations
2349    let is_container_transparent = app_bar_bg.3 == 0;
2350    let tonal_elevation = if is_container_transparent {
2351        0.0
2352    } else {
2353        config.tonal_elevation
2354    };
2355    let shadow_elevation = if is_container_transparent {
2356        0.0
2357    } else {
2358        config.shadow_elevation
2359    };
2360
2361    // Hide the collapsed bar when full-screen expanded (CK parity via expandsToFullScreen)
2362    let hide_collapsed = state.expands_to_full_screen.get() && state.is_expanded();
2363    let collapsed_alpha = if hide_collapsed { 0.0 } else { 1.0 };
2364
2365    let bar_m = Modifier::new()
2366        .fill_max_width()
2367        .height(config.height + insets.top)
2368        .translate(0.0, config.scroll_offset)
2369        .background(app_bar_bg)
2370        .semantics(Semantics::new(Role::Container).with_selectable_group());
2371
2372    let row = Row(Modifier::new()
2373        .fill_max_size()
2374        .align_items(AlignItems::CENTER)
2375        .padding_values(PaddingValues {
2376            left: config.content_padding.left + insets.left,
2377            right: config.content_padding.right + insets.right,
2378            top: insets.top,
2379            bottom: 0.0,
2380        }))
2381    .child({
2382        let mut children: Vec<View> = Vec::new();
2383        if let Some(nav) = navigation_icon {
2384            children.push(nav);
2385            children.push(Box(Modifier::new().width(4.0)));
2386        }
2387        // Wrap input_field in collapsed SearchBar (CK parity)
2388        let sb_colors = &config.colors.search_bar_colors;
2389        let collapsed_bar = SearchBar(
2390            state.clone(),
2391            input_field,
2392            Modifier::new().flex_grow(1.0).alpha(collapsed_alpha),
2393            None,
2394            None,
2395            SearchBarConfig {
2396                height: config.height - 8.0,
2397                shape_radius: config.shape_radius,
2398                colors: SearchBarColors {
2399                    container_color: bg,
2400                    active_container_color: bg,
2401                    divider_color: sb_colors.divider_color,
2402                    content_color: sb_colors.content_color,
2403                    placeholder_color: sb_colors.placeholder_color,
2404                    scrim_color: sb_colors.scrim_color,
2405                },
2406                tonal_elevation,
2407                shadow_elevation,
2408                ..Default::default()
2409            },
2410        );
2411        children.push(Box(Modifier::new().flex_grow(1.0)).child(collapsed_bar));
2412        if let Some(acts) = actions {
2413            children.push(Spacer());
2414            for a in acts {
2415                children.push(a);
2416            }
2417        }
2418        children
2419    });
2420
2421    Box(bar_m.shadow(shadow_elevation, 0.0)).child(row)
2422}
2423
2424/// State for `ModalBottomSheet` - manages visibility and drag offset.
2425pub struct SheetState {
2426    visible: Signal<bool>,
2427    drag_offset: Signal<f32>,
2428    peek_height: Signal<f32>,
2429}
2430
2431impl SheetState {
2432    pub fn new(peek_height: f32) -> Self {
2433        Self {
2434            visible: signal(false),
2435            drag_offset: signal(0.0),
2436            peek_height: signal(peek_height),
2437        }
2438    }
2439
2440    pub fn is_visible(&self) -> bool {
2441        self.visible.get()
2442    }
2443
2444    pub fn show(&self) {
2445        self.visible.set(true);
2446    }
2447
2448    pub fn dismiss(&self) {
2449        self.visible.set(false);
2450        self.drag_offset.set(0.0);
2451    }
2452
2453    pub fn set_peek_height(&self, h: f32) {
2454        self.peek_height.set(h);
2455    }
2456}
2457
2458/// M3 Modal Bottom Sheet - slides up from the bottom with a drag handle.
2459///
2460/// Renders as an overlay so it is not clipped by parent containers.
2461/// Shows on `state.show()`, dismisses on `state.dismiss()` or scrim tap.
2462pub fn ModalBottomSheet(
2463    state: Rc<SheetState>,
2464    overlay: OverlayHandle,
2465    modifier: Modifier,
2466    content: View,
2467    config: BottomSheetConfig,
2468) -> View {
2469    let th = theme();
2470    let peek_h = state.peek_height.get().max(config.peek_height);
2471    let anim_distance = peek_h.max(48.0).max(400.0);
2472    let overlay_id = remember_with_key("mbs_oid", || signal(0u64));
2473
2474    // Drag state -> offset_at_drag_start is the anim value when the drag began
2475    let drag_anchor_y: Rc<RefCell<f32>> = remember_state_with_key("mbs_drag_y", || 0.0);
2476    let offset_at_drag_start: Rc<RefCell<f32>> = remember_state_with_key("mbs_drag_base", || 0.0);
2477    let is_dragging: Rc<RefCell<bool>> = remember_state_with_key("mbs_drag", || false);
2478
2479    // Animated offset: anim_distance px (off-screen) → 0px (visible)
2480    let anim = remember_state_with_key("mbs_anim", || {
2481        AnimatedValue::new(anim_distance, theme().motion.spring)
2482    });
2483    let last_target = remember_state_with_key("mbs_anim_target", || f32::NAN);
2484    let anim_target = if state.is_visible() {
2485        0.0
2486    } else {
2487        anim_distance
2488    };
2489
2490    {
2491        let mut a = anim.borrow_mut();
2492        let mut lt = last_target.borrow_mut();
2493        if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
2494            if state.is_visible() {
2495                a.set_spec(th.motion.spring);
2496            } else {
2497                a.set_spec(AnimationSpec::fast());
2498            }
2499            a.set_target(anim_target);
2500            *lt = anim_target;
2501        }
2502        drop(lt);
2503        let still_animating = a.update();
2504        if still_animating {
2505            request_frame();
2506        }
2507    }
2508
2509    let offset = *anim.borrow().get();
2510    let sheet_visible = state.is_visible() || offset < anim_distance - 10.0;
2511
2512    if sheet_visible {
2513        if overlay_id.get() == 0 {
2514            let builder: Rc<dyn Fn() -> View> = Rc::new({
2515                let state = state.clone();
2516                let anim = anim.clone();
2517                let modifier = modifier.clone();
2518                let content = content.clone();
2519                let drag_anchor_y = drag_anchor_y.clone();
2520                let offset_at_drag_start = offset_at_drag_start.clone();
2521                let is_dragging = is_dragging.clone();
2522                let anim_distance = anim_distance;
2523                move || {
2524                    let off = *anim.borrow().get();
2525
2526                    let sheet_body = Box(modifier
2527                        .clone()
2528                        .fill_max_width()
2529                        .max_width(dp_to_px(config.max_width))
2530                        .translate(0.0, off)
2531                        .background(config.container_color)
2532                        .clip_rounded(config.shape_radius)
2533                        .on_pointer_down({
2534                            let anim = anim.clone();
2535                            let drag_anchor_y = drag_anchor_y.clone();
2536                            let offset_at_drag_start = offset_at_drag_start.clone();
2537                            let is_dragging = is_dragging.clone();
2538                            move |ev| {
2539                                *drag_anchor_y.borrow_mut() = ev.position.y;
2540                                *offset_at_drag_start.borrow_mut() = *anim.borrow().get();
2541                                *is_dragging.borrow_mut() = true;
2542                            }
2543                        })
2544                        .on_pointer_move({
2545                            let anim = anim.clone();
2546                            let drag_anchor_y = drag_anchor_y.clone();
2547                            let offset_at_drag_start = offset_at_drag_start.clone();
2548                            let is_dragging = is_dragging.clone();
2549                            move |ev| {
2550                                if !*is_dragging.borrow() {
2551                                    return;
2552                                }
2553                                let delta = ev.position.y - *drag_anchor_y.borrow();
2554                                let start_off = *offset_at_drag_start.borrow();
2555                                let total = (start_off + delta).max(0.0);
2556                                anim.borrow_mut().snap_to(total);
2557                                request_frame();
2558                            }
2559                        })
2560                        .on_pointer_up({
2561                            let anim = anim.clone();
2562                            let is_dragging = is_dragging.clone();
2563                            let state = state.clone();
2564                            let anim_distance = anim_distance;
2565                            move |_| {
2566                                *is_dragging.borrow_mut() = false;
2567                                let current_off = *anim.borrow().get();
2568                                let threshold = anim_distance * 0.3;
2569                                if current_off > threshold {
2570                                    anim.borrow_mut().set_target(anim_distance);
2571                                    state.dismiss();
2572                                } else {
2573                                    anim.borrow_mut().set_target(0.0);
2574                                }
2575                            }
2576                        }))
2577                    .child(
2578                        Column(Modifier::new().fill_max_width()).child((
2579                            Row(Modifier::new()
2580                                .fill_max_width()
2581                                .justify_content(JustifyContent::CENTER))
2582                            .child(Box(Modifier::new()
2583                                .margin_vertical(22.0)
2584                                .width(config.drag_handle_width)
2585                                .height(config.drag_handle_height)
2586                                .background(config.drag_handle_color)
2587                                .clip_rounded(2.0))),
2588                            content.clone(),
2589                        )),
2590                    );
2591
2592                    let sheet = Box(Modifier::new()
2593                        .fill_max_size()
2594                        .justify_content(JustifyContent::CENTER)
2595                        .align_items(AlignItems::FLEX_END))
2596                    .child(sheet_body);
2597
2598                    let scrim_alpha = if state.is_visible() {
2599                        config.scrim_color.3
2600                    } else {
2601                        let t = (off / anim_distance).clamp(0.0, 1.0);
2602                        (config.scrim_color.3 as f32 * (1.0 - t)) as u8
2603                    };
2604                    let scrim = Box(Modifier::new()
2605                        .fill_max_size()
2606                        .background(config.scrim_color.with_alpha(scrim_alpha))
2607                        .on_pointer_down({
2608                            let s = state.clone();
2609                            move |_| s.dismiss()
2610                        }));
2611
2612                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, sheet))
2613                }
2614            });
2615
2616            let id = overlay.show_entry(builder, 900.0, false);
2617            overlay_id.set(id);
2618        }
2619    } else {
2620        let prev = overlay_id.get();
2621        if prev != 0 {
2622            let _ = overlay.dismiss(prev);
2623            overlay_id.set(0);
2624        }
2625    }
2626
2627    Box(Modifier::new())
2628}
2629
2630/// State for `PullToRefresh` - tracks pull progress and refresh trigger.
2631///
2632/// Connect to a [`ScrollState`](repose_ui::scroll::ScrollState) via
2633/// [`set_scroll_state`](PullToRefreshState::set_scroll_state) so that the
2634/// pull offset is automatically driven by scroll overscroll.
2635pub struct PullToRefreshState {
2636    refreshing: Signal<bool>,
2637    scroll_state: RefCell<Option<Rc<repose_ui::scroll::ScrollState>>>,
2638    threshold: f32,
2639    triggered: Cell<bool>,
2640}
2641
2642impl Default for PullToRefreshState {
2643    fn default() -> Self {
2644        Self::new()
2645    }
2646}
2647
2648impl PullToRefreshState {
2649    pub fn new() -> Self {
2650        Self {
2651            refreshing: signal(false),
2652            scroll_state: RefCell::new(None),
2653            threshold: 64.0,
2654            triggered: Cell::new(false),
2655        }
2656    }
2657
2658    /// Connect this PullToRefresh state to a scroll state.
2659    /// The pull offset is then derived from the scroll state's overscroll.
2660    pub fn set_scroll_state(&self, state: Rc<repose_ui::scroll::ScrollState>) {
2661        *self.scroll_state.borrow_mut() = Some(state);
2662    }
2663
2664    /// Set the overscroll threshold that triggers a refresh (default 64px).
2665    pub fn set_threshold(&mut self, px: f32) {
2666        self.threshold = px;
2667    }
2668
2669    pub fn is_refreshing(&self) -> bool {
2670        self.refreshing.get()
2671    }
2672
2673    pub fn set_refreshing(&self, v: bool) {
2674        self.refreshing.set(v);
2675        if !v && let Some(sc) = self.scroll_state.borrow().as_ref() {
2676            sc.set_overscroll(0.0);
2677        }
2678    }
2679
2680    /// Read the current pull offset from the connected scroll state's overscroll.
2681    pub fn pull_offset(&self) -> f32 {
2682        if let Some(sc) = self.scroll_state.borrow().as_ref() {
2683            let os = sc.overscroll_offset();
2684            if os < 0.0 { -os } else { 0.0 }
2685        } else {
2686            0.0
2687        }
2688    }
2689}
2690
2691/// Wraps scrollable content with a pull-to-refresh indicator.
2692///
2693/// Renders a small spinner at the top when the user pulls down past a threshold,
2694/// or shows the current pull offset as a visual indicator.
2695///
2696/// The `state` must be connected to a [`ScrollState`](repose_ui::scroll::ScrollState)
2697/// via [`set_scroll_state`](PullToRefreshState::set_scroll_state) for the pull
2698/// offset to be derived from the scroll overscroll automatically.
2699pub fn PullToRefresh(
2700    state: Rc<PullToRefreshState>,
2701    modifier: Modifier,
2702    on_refresh: Rc<dyn Fn()>,
2703    content: View,
2704    config: PullToRefreshConfig,
2705) -> View {
2706    let pull = state.pull_offset();
2707    let refreshing = state.is_refreshing();
2708    let threshold = config.threshold;
2709
2710    if state.triggered.get() && !refreshing && pull < threshold {
2711        state.triggered.set(false);
2712    }
2713
2714    if !refreshing && !state.triggered.get() && pull >= threshold {
2715        state.triggered.set(true);
2716        state.refreshing.set(true);
2717        (on_refresh)();
2718    }
2719
2720    let frac_key = format!("ptr_frac_{}", Rc::as_ptr(&state) as u64);
2721    let raw_frac = if refreshing {
2722        1.0
2723    } else if pull > 0.0 {
2724        pull / threshold
2725    } else {
2726        0.0
2727    };
2728    let distance_fraction = animate_f32_from(frac_key, 0.0, raw_frac, theme().motion.color);
2729
2730    let adjusted_percent = (distance_fraction.min(1.0) - 0.4).max(0.0) * 5.0 / 3.0;
2731    let overshoot_percent = (distance_fraction - 1.0).max(0.0);
2732    let linear_tension = overshoot_percent.min(2.0);
2733    let tension_percent = linear_tension - linear_tension.powi(2) / 4.0;
2734    let rotation_turns = (-0.25 + 0.4 * adjusted_percent + tension_percent) * 0.5;
2735    // rotate by 360° to convert turns → degrees, then to radians for the modifier
2736    let spinner_rotation_rad = rotation_turns * std::f32::consts::TAU;
2737
2738    // Indicator at top (pushed into view by overscroll) + content below.
2739    let indicator_h = distance_fraction * threshold;
2740    let comp_scale = adjusted_percent.min(1.0);
2741    let icon_size = if refreshing {
2742        24.0
2743    } else {
2744        (16.0 + comp_scale * 8.0).min(24.0)
2745    };
2746    let rotation = if refreshing {
2747        animate_f32_from(
2748            "ptr_spin",
2749            0.0,
2750            std::f32::consts::TAU,
2751            AnimationSpec::tween(Duration::from_millis(1000), Easing::Linear)
2752                .repeated(RepeatableSpec::infinite()),
2753        )
2754    } else {
2755        spinner_rotation_rad
2756    };
2757    let alpha = if refreshing {
2758        1.0
2759    } else if distance_fraction >= 1.0 {
2760        1.0
2761    } else {
2762        0.3
2763    };
2764    Column(modifier.align_items(config.content_alignment)).child((
2765        if distance_fraction > 0.01 {
2766            Box(Modifier::new()
2767                .fill_max_width()
2768                .height(indicator_h)
2769                .align_items(AlignItems::CENTER)
2770                .justify_content(JustifyContent::CENTER))
2771            .child(
2772                Box(Modifier::new()
2773                    .size(icon_size, icon_size)
2774                    .translate(icon_size * 0.5, icon_size * 0.5)
2775                    .rotate(rotation)
2776                    .translate(-icon_size * 0.5, -icon_size * 0.5))
2777                .child(if refreshing {
2778                    Icon(Symbol::new("refresh", '\u{E5D5}'))
2779                        .size(24.0)
2780                        .color(config.indicator_color)
2781                } else {
2782                    Icon(Symbol::new("arrow_downward", '\u{E5DB}'))
2783                        .size(icon_size)
2784                        .color(config.indicator_color.with_alpha_f32(alpha))
2785                }),
2786            )
2787        } else {
2788            Box(Modifier::new())
2789        },
2790        content,
2791    ))
2792}
2793
2794/// State for `DatePicker` - manages selected date.
2795pub struct DatePickerState {
2796    pub year: Signal<i32>,
2797    pub month: Signal<u32>, // 1-12
2798    pub day: Signal<u32>,
2799}
2800
2801impl DatePickerState {
2802    pub fn new(year: i32, month: u32, day: u32) -> Self {
2803        Self {
2804            year: signal(year),
2805            month: signal(month.clamp(1, 12)),
2806            day: signal(day.clamp(1, 31)),
2807        }
2808    }
2809
2810    pub fn selected_date(&self) -> (i32, u32, u32) {
2811        (self.year.get(), self.month.get(), self.day.get())
2812    }
2813}
2814
2815fn days_in_month(year: i32, month: u32) -> u32 {
2816    match month {
2817        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
2818        4 | 6 | 9 | 11 => 30,
2819        2 => {
2820            if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
2821                29
2822            } else {
2823                28
2824            }
2825        }
2826        _ => 30,
2827    }
2828}
2829
2830/// Day of week for the first day of the given month/year.
2831/// Returns 0=Mon ... 6=Sun using Zeller-like formula for Gregorian calendar.
2832fn first_day_of_month(year: i32, month: u32) -> u32 {
2833    let m = month as i32;
2834    let (y, adj_m) = if m <= 2 {
2835        (year - 1, m + 12)
2836    } else {
2837        (year, m)
2838    };
2839    let k = y % 100;
2840    let j = y / 100;
2841    let h = (1 + (13 * (adj_m + 1)) / 5 + k + k / 4 + j / 4 + 5 * j) % 7;
2842    // Convert Zeller's Saturday=0 to Monday=0, Sunday=6
2843    ((h + 5) % 7) as u32
2844}
2845
2846/// Simple calendar date for today-highlighting in DatePicker.
2847struct ReposeDate {
2848    year: i32,
2849    month: u32,
2850    day: u32,
2851}
2852
2853impl ReposeDate {
2854    /// Compute today's date from the system clock.
2855    fn now() -> Self {
2856        let duration = web_time::SystemTime::now()
2857            .duration_since(web_time::UNIX_EPOCH)
2858            .unwrap_or_default();
2859        let days = (duration.as_secs() / 86_400) as i64;
2860        // Howard Hinnant's civil_from_days
2861        let z = days + 719468;
2862        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
2863        let doe = (z - era * 146_097) as u64;
2864        let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
2865        let y = (yoe as i64) + era * 400;
2866        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2867        let mp = (5 * doy + 2) / 153;
2868        let d = doy - (153 * mp + 2) / 5 + 1;
2869        let m = if mp < 10 { mp + 3 } else { mp - 9 };
2870        let y = if m <= 2 { y + 1 } else { y };
2871        Self {
2872            year: y as i32,
2873            month: m as u32,
2874            day: d as u32,
2875        }
2876    }
2877}
2878
2879const MONTH_NAMES: [&str; 12] = [
2880    "January",
2881    "February",
2882    "March",
2883    "April",
2884    "May",
2885    "June",
2886    "July",
2887    "August",
2888    "September",
2889    "October",
2890    "November",
2891    "December",
2892];
2893
2894const DOW_HEADERS: [&str; 7] = ["M", "T", "W", "T", "F", "S", "S"];
2895
2896/// Colors for [`DatePicker`].
2897#[derive(Clone)]
2898pub struct DatePickerColors {
2899    pub container_color: Color,
2900    pub header_color: Color,
2901    pub weekday_color: Color,
2902    pub day_color: Color,
2903    pub selected_day_color: Color,
2904    pub selected_day_container_color: Color,
2905    pub today_content_color: Color,
2906    pub today_border_color: Color,
2907    pub navigation_color: Color,
2908    pub year_selected_container_color: Color,
2909    pub year_selected_content_color: Color,
2910    pub year_unselected_content_color: Color,
2911}
2912
2913impl Default for DatePickerColors {
2914    fn default() -> Self {
2915        Self {
2916            container_color: DatePickerDefaults::container_color(),
2917            header_color: DatePickerDefaults::header_color(),
2918            weekday_color: DatePickerDefaults::weekday_color(),
2919            day_color: DatePickerDefaults::day_color(),
2920            selected_day_color: DatePickerDefaults::selected_day_color(),
2921            selected_day_container_color: DatePickerDefaults::selected_day_container_color(),
2922            today_content_color: DatePickerDefaults::today_content_color(),
2923            today_border_color: DatePickerDefaults::today_border_color(),
2924            navigation_color: DatePickerDefaults::header_color(),
2925            year_selected_container_color: DatePickerDefaults::year_selected_container_color(),
2926            year_selected_content_color: DatePickerDefaults::year_selected_content_color(),
2927            year_unselected_content_color: DatePickerDefaults::year_unselected_content_color(),
2928        }
2929    }
2930}
2931
2932/// Configuration for [`DatePicker`].
2933#[derive(Clone)]
2934pub struct DatePickerConfig {
2935    pub modifier: Modifier,
2936    pub colors: DatePickerColors,
2937    pub show_mode_toggle: bool,
2938}
2939
2940impl Default for DatePickerConfig {
2941    fn default() -> Self {
2942        Self {
2943            modifier: Modifier::new(),
2944            colors: DatePickerColors::default(),
2945            show_mode_toggle: true,
2946        }
2947    }
2948}
2949
2950/// M3 Date Picker dialog with month/year navigation, proper calendar grid,
2951/// today indicator, and confirm/cancel actions.
2952pub fn DatePicker(
2953    state: Rc<DatePickerState>,
2954    on_confirm: Rc<dyn Fn(i32, u32, u32)>,
2955    on_dismiss: Rc<dyn Fn()>,
2956    config: DatePickerConfig,
2957) -> View {
2958    let th = theme();
2959    let (year, month, day) = state.selected_date();
2960    let dim = days_in_month(year, month);
2961    let start_dow = first_day_of_month(year, month);
2962
2963    // Year step helpers
2964    let prev_year = {
2965        let s = state.clone();
2966        move || {
2967            s.year.set(s.year.get() - 1);
2968            let d = days_in_month(s.year.get(), s.month.get());
2969            if s.day.get() > d {
2970                s.day.set(d);
2971            }
2972        }
2973    };
2974    let next_year = {
2975        let s = state.clone();
2976        move || {
2977            s.year.set(s.year.get() + 1);
2978            let d = days_in_month(s.year.get(), s.month.get());
2979            if s.day.get() > d {
2980                s.day.set(d);
2981            }
2982        }
2983    };
2984
2985    let prev_month = {
2986        let s = state.clone();
2987        move || {
2988            if s.month.get() == 1 {
2989                s.year.set(s.year.get() - 1);
2990                s.month.set(12);
2991            } else {
2992                s.month.set(s.month.get() - 1);
2993            }
2994            let d = days_in_month(s.year.get(), s.month.get());
2995            if s.day.get() > d {
2996                s.day.set(d);
2997            }
2998        }
2999    };
3000
3001    let next_month = {
3002        let s = state.clone();
3003        move || {
3004            if s.month.get() == 12 {
3005                s.year.set(s.year.get() + 1);
3006                s.month.set(1);
3007            } else {
3008                s.month.set(s.month.get() + 1);
3009            }
3010            let d = days_in_month(s.year.get(), s.month.get());
3011            if s.day.get() > d {
3012                s.day.set(d);
3013            }
3014        }
3015    };
3016
3017    // Determine today for highlight
3018    let now = ReposeDate::now();
3019    let today = (now.year, now.month, now.day);
3020
3021    Column(config.modifier.padding(16.0)).child((
3022        // Month header
3023        Row(Modifier::new()
3024            .fill_max_width()
3025            .align_items(AlignItems::CENTER))
3026        .child((
3027            IconButton(
3028                Box(Modifier::new())
3029                    .child(Text("â—€").color(config.colors.navigation_color).size(16.0)),
3030                prev_month,
3031                IconButtonConfig::default(),
3032            ),
3033            Spacer(),
3034            Column(Modifier::new().align_items(AlignItems::CENTER)).child((
3035                Text(MONTH_NAMES[(month - 1) as usize].to_string())
3036                    .size(th.typography.title_medium)
3037                    .color(config.colors.header_color),
3038                Row(Modifier::new().gap(8.0).align_items(AlignItems::CENTER)).child((
3039                    IconButton(
3040                        Box(Modifier::new())
3041                            .child(Text("‹").color(config.colors.navigation_color).size(14.0)),
3042                        prev_year,
3043                        IconButtonConfig::default(),
3044                    ),
3045                    Text(year.to_string())
3046                        .size(th.typography.body_small)
3047                        .color(th.on_surface_variant),
3048                    IconButton(
3049                        Box(Modifier::new())
3050                            .child(Text("›").color(config.colors.navigation_color).size(14.0)),
3051                        next_year,
3052                        IconButtonConfig::default(),
3053                    ),
3054                )),
3055            )),
3056            Spacer(),
3057            IconButton(
3058                Box(Modifier::new())
3059                    .child(Text("â–¶").color(config.colors.navigation_color).size(16.0)),
3060                next_month,
3061                IconButtonConfig::default(),
3062            ),
3063        )),
3064        Box(Modifier::new().fill_max_width().height(12.0)),
3065        // Day grid
3066        Column(Modifier::new()).child({
3067            let mut rows: Vec<View> = Vec::new();
3068            // Day-of-week headers
3069            let dow_headers: Vec<View> = DOW_HEADERS
3070                .iter()
3071                .map(|d| {
3072                    Box(Modifier::new()
3073                        .width(40.0)
3074                        .height(40.0)
3075                        .align_items(AlignItems::CENTER)
3076                        .justify_content(JustifyContent::CENTER))
3077                    .child(
3078                        Text(d.to_string())
3079                            .size(th.typography.label_small)
3080                            .color(config.colors.weekday_color),
3081                    )
3082                })
3083                .collect();
3084            rows.push(Row(Modifier::new()).with_children(dow_headers));
3085
3086            // Proper calendar grid: offset by start_dow, 6 rows
3087            let total_cells = start_dow + dim;
3088            let num_rows = total_cells.div_ceil(7).min(6);
3089            for w in 0..num_rows {
3090                let mut week: Vec<View> = Vec::new();
3091                for d in 0..7 {
3092                    let cell_idx = w * 7 + d;
3093                    if cell_idx < start_dow {
3094                        week.push(Box(Modifier::new().width(40.0).height(40.0)));
3095                    } else {
3096                        let day_num = (cell_idx - start_dow + 1) as i32;
3097                        if day_num <= dim as i32 {
3098                            let is_selected = day_num == day as i32;
3099                            let is_today =
3100                                today.0 == year && today.1 == month && today.2 == day_num as u32;
3101                            let s = state.clone();
3102                            week.push(
3103                                Box(Modifier::new()
3104                                    .width(40.0)
3105                                    .height(40.0)
3106                                    .background(if is_selected {
3107                                        config.colors.selected_day_container_color
3108                                    } else {
3109                                        Color::TRANSPARENT
3110                                    })
3111                                    .clip_rounded(20.0)
3112                                    .align_items(AlignItems::CENTER)
3113                                    .justify_content(JustifyContent::CENTER)
3114                                    .clickable()
3115                                    .on_click(move || {
3116                                        s.day.set(day_num as u32);
3117                                    }))
3118                                .child({
3119                                    let mut t = Text(day_num.to_string())
3120                                        .size(th.typography.body_medium)
3121                                        .color(if is_selected {
3122                                            config.colors.selected_day_color
3123                                        } else {
3124                                            config.colors.day_color
3125                                        });
3126                                    if is_today && !is_selected {
3127                                        t = t.modifier(Modifier::new().border(
3128                                            1.0,
3129                                            config.colors.today_border_color,
3130                                            10.0,
3131                                        ));
3132                                    }
3133                                    t
3134                                }),
3135                            );
3136                        } else {
3137                            week.push(Box(Modifier::new().width(40.0).height(40.0)));
3138                        }
3139                    }
3140                }
3141                rows.push(Row(Modifier::new()).with_children(week));
3142            }
3143            rows
3144        }),
3145        Box(Modifier::new().fill_max_width().height(12.0)),
3146        // Cancel / Confirm
3147        Row(Modifier::new()
3148            .fill_max_width()
3149            .justify_content(JustifyContent::END)
3150            .gap(8.0))
3151        .child((
3152            TextButton(
3153                Modifier::new(),
3154                {
3155                    let on_dismiss = on_dismiss.clone();
3156                    move || (on_dismiss)()
3157                },
3158                ButtonConfig::default(),
3159                || Text("Cancel").size(14.0),
3160            ),
3161            Button(
3162                Modifier::new(),
3163                {
3164                    let on_confirm = on_confirm.clone();
3165                    let s = state.clone();
3166                    move || {
3167                        let (y, m, d) = s.selected_date();
3168                        on_confirm(y, m, d);
3169                    }
3170                },
3171                ButtonConfig::default(),
3172                || Text("OK").size(14.0),
3173            ),
3174        )),
3175    ))
3176}
3177
3178/// State for `TimePicker` - manages selected hour and minute.
3179pub struct TimePickerState {
3180    pub hour: Signal<u32>,
3181    pub minute: Signal<u32>,
3182    pub is_am: Signal<bool>,
3183}
3184
3185impl TimePickerState {
3186    pub fn new(hour: u32, minute: u32) -> Self {
3187        let h = hour % 12;
3188        let am = hour < 12;
3189        Self {
3190            hour: signal(if h == 0 { 12 } else { h }),
3191            minute: signal(minute.min(59)),
3192            is_am: signal(am),
3193        }
3194    }
3195
3196    pub fn selected_time(&self) -> (u32, u32) {
3197        let mut h = self.hour.get();
3198        if !self.is_am.get() {
3199            h = (h % 12) + 12;
3200        } else if h == 12 {
3201            h = 0;
3202        }
3203        (h, self.minute.get())
3204    }
3205}
3206
3207/// Layout types for [`TimePicker`].
3208#[derive(Clone, Copy, PartialEq, Debug)]
3209pub enum TimePickerLayoutType {
3210    Horizontal,
3211    Vertical,
3212}
3213
3214/// Colors for [`TimePicker`].
3215#[derive(Clone)]
3216pub struct TimePickerColors {
3217    pub clock_dial_color: Color,
3218    pub clock_dial_selected_content_color: Color,
3219    pub clock_dial_unselected_content_color: Color,
3220    pub selector_color: Color,
3221    pub container_color: Color,
3222    pub period_selector_border_color: Color,
3223    pub period_selector_selected_container_color: Color,
3224    pub period_selector_unselected_container_color: Color,
3225    pub period_selector_selected_content_color: Color,
3226    pub period_selector_unselected_content_color: Color,
3227    pub time_selector_selected_container_color: Color,
3228    pub time_selector_unselected_container_color: Color,
3229    pub time_selector_selected_content_color: Color,
3230    pub time_selector_unselected_content_color: Color,
3231}
3232
3233impl Default for TimePickerColors {
3234    fn default() -> Self {
3235        Self {
3236            clock_dial_color: TimePickerDefaults::clock_dial_color(),
3237            clock_dial_selected_content_color:
3238                TimePickerDefaults::clock_dial_selected_content_color(),
3239            clock_dial_unselected_content_color:
3240                TimePickerDefaults::clock_dial_unselected_content_color(),
3241            selector_color: TimePickerDefaults::selector_color(),
3242            container_color: TimePickerDefaults::container_color(),
3243            period_selector_border_color: TimePickerDefaults::period_selector_border_color(),
3244            period_selector_selected_container_color:
3245                TimePickerDefaults::period_selector_selected_container_color(),
3246            period_selector_unselected_container_color:
3247                TimePickerDefaults::period_selector_unselected_container_color(),
3248            period_selector_selected_content_color:
3249                TimePickerDefaults::period_selector_selected_content_color(),
3250            period_selector_unselected_content_color:
3251                TimePickerDefaults::period_selector_unselected_content_color(),
3252            time_selector_selected_container_color:
3253                TimePickerDefaults::time_selector_selected_container_color(),
3254            time_selector_unselected_container_color:
3255                TimePickerDefaults::time_selector_unselected_container_color(),
3256            time_selector_selected_content_color:
3257                TimePickerDefaults::time_selector_selected_content_color(),
3258            time_selector_unselected_content_color:
3259                TimePickerDefaults::time_selector_unselected_content_color(),
3260        }
3261    }
3262}
3263
3264/// Configuration for [`TimePicker`].
3265#[derive(Clone)]
3266pub struct TimePickerConfig {
3267    pub modifier: Modifier,
3268    pub colors: TimePickerColors,
3269    pub layout_type: TimePickerLayoutType,
3270}
3271
3272impl Default for TimePickerConfig {
3273    fn default() -> Self {
3274        Self {
3275            modifier: Modifier::new(),
3276            colors: TimePickerColors::default(),
3277            layout_type: TimePickerLayoutType::Vertical,
3278        }
3279    }
3280}
3281
3282/// M3 Time Picker - a simple time picker with hour/minute fields and AM/PM toggle.
3283pub fn TimePicker(
3284    state: Rc<TimePickerState>,
3285    on_confirm: Rc<dyn Fn(u32, u32)>,
3286    on_dismiss: Rc<dyn Fn()>,
3287    config: TimePickerConfig,
3288) -> View {
3289    let th = theme();
3290    let hour = state.hour.get();
3291    let minute = state.minute.get();
3292    let is_am = state.is_am.get();
3293
3294    let hour_str = format!("{:02}", hour);
3295    let min_str = format!("{:02}", minute);
3296
3297    Column(
3298        config
3299            .modifier
3300            .width(256.0)
3301            .padding(24.0)
3302            .align_items(AlignItems::CENTER),
3303    )
3304    .child((
3305        // Time display
3306        Row(Modifier::new().align_items(AlignItems::CENTER)).child((
3307            Box(Modifier::new()
3308                .clickable()
3309                .on_click({
3310                    let s = state.clone();
3311                    move || s.hour.set((s.hour.get() % 12) + 1)
3312                })
3313                .padding(8.0))
3314            .child(
3315                Text(hour_str)
3316                    .size(48.0)
3317                    .color(config.colors.clock_dial_unselected_content_color)
3318                    .single_line(),
3319            ),
3320            Text(":")
3321                .size(48.0)
3322                .color(config.colors.clock_dial_unselected_content_color)
3323                .single_line(),
3324            Box(Modifier::new()
3325                .clickable()
3326                .on_click({
3327                    let s = state.clone();
3328                    move || s.minute.set((s.minute.get() + 1) % 60)
3329                })
3330                .padding(8.0))
3331            .child(
3332                Text(min_str)
3333                    .size(48.0)
3334                    .color(config.colors.clock_dial_unselected_content_color)
3335                    .single_line(),
3336            ),
3337        )),
3338        Box(Modifier::new().fill_max_width().height(16.0)),
3339        // AM/PM toggle
3340        Row(Modifier::new().align_items(AlignItems::CENTER)).child((
3341            Box(Modifier::new()
3342                .padding_values(PaddingValues {
3343                    left: 12.0,
3344                    right: 12.0,
3345                    top: 4.0,
3346                    bottom: 4.0,
3347                })
3348                .background(if is_am {
3349                    config.colors.period_selector_selected_container_color
3350                } else {
3351                    Color::TRANSPARENT
3352                })
3353                .clip_rounded(8.0)
3354                .clickable()
3355                .on_click({
3356                    let s = state.clone();
3357                    move || {
3358                        if !s.is_am.get() {
3359                            s.is_am.set(true);
3360                            let h = s.hour.get();
3361                            s.hour.set(if h == 12 { 12 } else { (h + 12) % 24 });
3362                            if s.hour.get() == 0 {
3363                                s.hour.set(12);
3364                            }
3365                        }
3366                    }
3367                }))
3368            .child(Text("AM").size(th.typography.label_large).color(if is_am {
3369                config.colors.period_selector_selected_content_color
3370            } else {
3371                config.colors.period_selector_unselected_content_color
3372            })),
3373            Box(Modifier::new().width(8.0).height(1.0)),
3374            Box(Modifier::new()
3375                .padding_values(PaddingValues {
3376                    left: 12.0,
3377                    right: 12.0,
3378                    top: 4.0,
3379                    bottom: 4.0,
3380                })
3381                .background(if !is_am {
3382                    config.colors.period_selector_selected_container_color
3383                } else {
3384                    Color::TRANSPARENT
3385                })
3386                .clip_rounded(8.0)
3387                .clickable()
3388                .on_click({
3389                    let s = state.clone();
3390                    move || {
3391                        if s.is_am.get() {
3392                            s.is_am.set(false);
3393                            let h = s.hour.get();
3394                            s.hour.set(if h == 12 { 12 } else { (h + 12) % 24 });
3395                            if s.hour.get() == 0 {
3396                                s.hour.set(12);
3397                            }
3398                        }
3399                    }
3400                }))
3401            .child(Text("PM").size(th.typography.label_large).color(if !is_am {
3402                config.colors.period_selector_selected_content_color
3403            } else {
3404                config.colors.period_selector_unselected_content_color
3405            })),
3406        )),
3407        Box(Modifier::new().fill_max_width().height(16.0)),
3408        Row(Modifier::new().fill_max_width()).child((
3409            Spacer(),
3410            Box(Modifier::new().padding(8.0).clickable().on_click({
3411                let on_dismiss = on_dismiss.clone();
3412                move || on_dismiss()
3413            }))
3414            .child(
3415                Text("Cancel")
3416                    .color(config.colors.selector_color)
3417                    .size(th.typography.label_large)
3418                    .single_line(),
3419            ),
3420            Box(Modifier::new().width(8.0).height(1.0)),
3421            Box(Modifier::new().padding(8.0).clickable().on_click({
3422                let on_confirm = on_confirm.clone();
3423                let state = state.clone();
3424                move || {
3425                    let (h, m) = state.selected_time();
3426                    on_confirm(h, m);
3427                }
3428            }))
3429            .child(
3430                Text("OK")
3431                    .color(config.colors.selector_color)
3432                    .size(th.typography.label_large)
3433                    .single_line(),
3434            ),
3435        )),
3436    ))
3437}
3438
3439/// A destination entry inside a NavigationRail.
3440pub struct NavRailItem {
3441    pub icon: View,
3442    pub label: String,
3443    pub on_click: Rc<dyn Fn()>,
3444    pub badge: Option<View>,
3445    pub enabled: bool,
3446    pub interaction_source: Option<MutableInteractionSource>,
3447}
3448
3449static NAVRAIL_COUNTER: AtomicU64 = AtomicU64::new(0);
3450static FILTERCHIP_COUNTER: AtomicU64 = AtomicU64::new(0);
3451
3452/// M3 Navigation Rail - a compact vertical navigation sidebar.
3453///
3454/// Typically placed on the left side of the screen. Contains navigation items
3455/// (icon + label) with animated selection indicator.
3456pub fn NavigationRail(
3457    selected_index: usize,
3458    items: Vec<NavRailItem>,
3459    header: Option<View>,
3460    fab: Option<View>,
3461    config: NavigationRailConfig,
3462) -> View {
3463    let th = theme();
3464    let id = remember(|| NAVRAIL_COUNTER.fetch_add(1, Ordering::Relaxed));
3465    let default_effects = AnimationSpec::spring_crit(40.0);
3466
3467    let mut top_children: Vec<View> = Vec::new();
3468    let mut item_views: Vec<View> = Vec::new();
3469
3470    let has_header = header.is_some();
3471    let has_fab = fab.is_some();
3472
3473    if let Some(h) = header {
3474        top_children.push(
3475            Box(Modifier::new()
3476                .padding_values(PaddingValues {
3477                    left: 12.0,
3478                    right: 12.0,
3479                    top: 12.0,
3480                    bottom: 12.0,
3481                })
3482                .align_self(AlignSelf::CENTER))
3483            .child(h),
3484        );
3485    }
3486
3487    if let Some(f) = fab {
3488        top_children.push(
3489            Box(Modifier::new()
3490                .padding_values(PaddingValues {
3491                    left: 12.0,
3492                    right: 12.0,
3493                    top: 8.0,
3494                    bottom: 8.0,
3495                })
3496                .align_self(AlignSelf::CENTER))
3497            .child(f),
3498        );
3499    }
3500
3501    if has_header || has_fab {
3502        top_children.push(Box(Modifier::new()
3503            .fill_max_width()
3504            .height(1.0)
3505            .background(th.outline_variant)));
3506    }
3507
3508    for (i, item) in items.into_iter().enumerate() {
3509        let selected = i == selected_index;
3510        let is_enabled = item.enabled;
3511
3512        let fg = animate_color(
3513            format!("nr_fg_{}_{}", id, i),
3514            if selected {
3515                config.selected_icon_color
3516            } else {
3517                config.unselected_icon_color
3518            },
3519            default_effects,
3520        );
3521        let fg_label = animate_color(
3522            format!("nr_fl_{}_{}", id, i),
3523            if selected {
3524                config.selected_text_color
3525            } else {
3526                config.unselected_text_color
3527            },
3528            default_effects,
3529        );
3530        let bg = animate_color(
3531            format!("nr_bg_{}_{}", id, i),
3532            if selected {
3533                config.indicator_color
3534            } else {
3535                Color::TRANSPARENT
3536            },
3537            default_effects,
3538        );
3539
3540        let cb = item.on_click.clone();
3541        let nr_source: Rc<MutableInteractionSource> = item
3542            .interaction_source
3543            .clone()
3544            .map(Rc::new)
3545            .unwrap_or_else(|| remember(MutableInteractionSource::new));
3546
3547        let mut item_m = Modifier::new()
3548            .fill_max_width()
3549            .padding_values(PaddingValues {
3550                left: 4.0,
3551                right: 4.0,
3552                top: 4.0,
3553                bottom: 4.0,
3554            })
3555            .align_items(AlignItems::CENTER)
3556            .justify_content(JustifyContent::CENTER)
3557            .background(bg)
3558            .state_colors(StateColors {
3559                default: Color::TRANSPARENT,
3560                hovered: th.on_surface.with_alpha_f32(0.08),
3561                pressed: th.on_surface.with_alpha_f32(0.12),
3562                disabled: Color::TRANSPARENT,
3563            })
3564            .clip_rounded(config.item_radius)
3565            .interaction_source(&*nr_source)
3566            .semantics(Semantics::new(Role::Tab).with_label(&item.label));
3567
3568        if is_enabled {
3569            item_m = item_m.clickable().on_click({
3570                let cb = cb.clone();
3571                move || cb()
3572            });
3573        }
3574
3575        item_views.push(
3576            Column(item_m).child((
3577                Stack(Modifier::new()).child((
3578                    Box(Modifier::new().size(24.0, 24.0))
3579                        .child(with_content_color(fg, move || item.icon)),
3580                    item.badge
3581                        .map(|b| {
3582                            Box(Modifier::new()
3583                                .absolute()
3584                                .offset(None, None, None, Some(0.0)))
3585                            .child(b)
3586                        })
3587                        .unwrap_or(Box(Modifier::new())),
3588                )),
3589                Box(Modifier::new().fill_max_width().height(4.0)),
3590                Text(item.label)
3591                    .color(fg_label)
3592                    .size(th.typography.label_medium)
3593                    .single_line(),
3594            )),
3595        );
3596    }
3597
3598    Column(
3599        Modifier::new()
3600            .width(config.width)
3601            .fill_max_height()
3602            .background(config.container_color)
3603            .align_items(AlignItems::CENTER)
3604            .semantics(Semantics::new(Role::Container).with_selectable_group())
3605            .then(config.modifier),
3606    )
3607    .child((
3608        Column(Modifier::new()).with_children(top_children),
3609        Box(Modifier::new().flex_grow(1.0)).child(
3610            Column(
3611                Modifier::new()
3612                    .fill_max_size()
3613                    .justify_content(JustifyContent::SPACE_BETWEEN)
3614                    .align_items(AlignItems::CENTER),
3615            )
3616            .with_children(item_views),
3617        ),
3618    ))
3619}
3620
3621/// Direction for the dismiss action.
3622#[derive(Clone, Copy, Debug, PartialEq)]
3623pub enum DismissDirection {
3624    StartToEnd,
3625    EndToStart,
3626    Both,
3627}
3628
3629/// Resolved state for swipe-to-dismiss.
3630#[derive(Clone, Copy, Debug, PartialEq)]
3631pub enum DismissValue {
3632    Default,
3633    DismissedToStart,
3634    DismissedToEnd,
3635}
3636
3637/// State for `SwipeToDismiss` - backed by a generic `SwipeableState<DismissValue>`.
3638pub struct SwipeToDismissState {
3639    swipeable: repose_core::SwipeableState<DismissValue>,
3640    dismissed_offset: f32,
3641}
3642
3643impl Default for SwipeToDismissState {
3644    fn default() -> Self {
3645        Self::new()
3646    }
3647}
3648
3649impl SwipeToDismissState {
3650    pub fn new() -> Self {
3651        Self::with_config(SwipeToDismissConfig::default())
3652    }
3653
3654    pub fn with_config(config: SwipeToDismissConfig) -> Self {
3655        let one_third = 1.0 / 3.0;
3656        let positional_threshold = (config.dismiss_threshold * one_third) / config.dismissed_offset;
3657        let mut anchors = vec![(0.0, DismissValue::Default)];
3658        if config.enable_dismiss_from_end_to_start {
3659            anchors.push((-config.dismissed_offset, DismissValue::DismissedToStart));
3660        }
3661        if config.enable_dismiss_from_start_to_end {
3662            anchors.push((config.dismissed_offset, DismissValue::DismissedToEnd));
3663        }
3664        // Sort by offset for correct clamp/nearest/next-anchor logic.
3665        anchors.sort_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap());
3666        let swipeable = repose_core::SwipeableState::new(
3667            anchors,
3668            repose_core::SwipeableConfig {
3669                animation_spec: config.animation_spec.clone(),
3670                positional_threshold,
3671                ..Default::default()
3672            },
3673        );
3674        // Start at the default position (not anchors[0], which may be negative).
3675        swipeable.snap_to(0.0);
3676        Self {
3677            swipeable,
3678            dismissed_offset: config.dismissed_offset,
3679        }
3680    }
3681
3682    /// Current animated offset in pixels.
3683    pub fn offset(&self) -> f32 {
3684        self.swipeable.offset()
3685    }
3686
3687    /// Snap instantly to an offset (used during active drag).
3688    pub fn set_offset_instant(&self, off: f32) {
3689        self.swipeable.snap_to(off);
3690    }
3691
3692    /// Whether the current position is past the dismiss threshold.
3693    pub fn is_dismissed(&self) -> bool {
3694        self.swipeable.current_value() != DismissValue::Default
3695    }
3696
3697    /// Animate to the dismissed position.
3698    pub fn dismiss(&self) {
3699        self.swipeable.animate_to(&DismissValue::DismissedToStart);
3700    }
3701
3702    /// Animate to the dismissed position with custom offset.
3703    pub fn dismiss_to(&self, offset: f32) {
3704        let value = if offset < 0.0 {
3705            DismissValue::DismissedToStart
3706        } else {
3707            DismissValue::DismissedToEnd
3708        };
3709        self.swipeable.animate_to(&value);
3710    }
3711
3712    /// Animate back to origin.
3713    pub fn reset(&self) {
3714        self.swipeable.animate_to(&DismissValue::Default);
3715    }
3716
3717    /// Fire the dismiss callback once when the spring settles past a given threshold.
3718    fn try_handle_dismiss_with_threshold(&self, on_dismiss: &Option<Rc<dyn Fn()>>, threshold: f32) {
3719        if !self.swipeable.is_animating() {
3720            let val = self.swipeable.current_value();
3721            if val != DismissValue::Default {
3722                if let Some(cb) = on_dismiss {
3723                    cb();
3724                }
3725            }
3726        }
3727    }
3728}
3729
3730/// M3 SwipeToDismiss - wraps content that can be swiped to reveal
3731/// a `background` action view. On release past the threshold the content
3732/// springs to the dismissed position and `on_dismiss` fires **once**.
3733///
3734/// The gesture logic uses `SwipeableState<DismissValue>` internally, so it
3735/// supports both left and right dismiss directions based on the config.
3736pub fn SwipeToDismiss(
3737    state: Rc<SwipeToDismissState>,
3738    on_dismiss: Option<Rc<dyn Fn()>>,
3739    background: View,
3740    content: View,
3741    modifier: Modifier,
3742    config: SwipeToDismissConfig,
3743) -> View {
3744    let offset = state.offset();
3745    state.try_handle_dismiss_with_threshold(&on_dismiss, config.dismiss_threshold);
3746
3747    let s1 = state.swipeable.clone();
3748    let s2 = state.swipeable.clone();
3749    let s3 = state.swipeable.clone();
3750    let on_down = { move |e: PointerEvent| s1.on_pointer_down(e.position.x) };
3751    let on_move = { move |e: PointerEvent| s2.on_pointer_move(e.position.x) };
3752    let on_up = { move |_e: PointerEvent| s3.on_pointer_up() };
3753
3754    let display_offset = offset
3755        .max(-config.dismissed_offset)
3756        .min(config.dismissed_offset);
3757
3758    let content_modifier = {
3759        let mut m = Modifier::new()
3760            .fill_max_width()
3761            .translate(display_offset, 0.0);
3762        if config.gestures_enabled {
3763            m = m
3764                .on_pointer_down(on_down)
3765                .on_pointer_move(on_move)
3766                .on_pointer_up(on_up);
3767        }
3768        m
3769    };
3770
3771    Stack(modifier.fill_max_width()).child((
3772        Box(Modifier::new().fill_max_size().absolute()).child(background),
3773        Box(content_modifier).child(content),
3774    ))
3775}
3776
3777/// M3 Carousel - a horizontally scrolling container with peek edges.
3778///
3779/// Uses a `LazyRow` internally. The first and last items are partially visible
3780/// (peek) to indicate there is more scrollable content.
3781/// Configuration for [`Carousel`].
3782#[derive(Clone, Debug)]
3783pub struct CarouselConfig {
3784    pub modifier: Modifier,
3785}
3786
3787impl Default for CarouselConfig {
3788    fn default() -> Self {
3789        Self {
3790            modifier: Modifier::new(),
3791        }
3792    }
3793}
3794
3795/// M3 Carousel - a horizontally scrolling container with peek edges.
3796///
3797/// Uses a `LazyRow` internally. The first and last items are partially visible
3798/// (peek) to indicate there is more scrollable content.
3799pub fn Carousel<T, F>(
3800    items: Vec<T>,
3801    item_width: f32,
3802    peek_amount: f32,
3803    state: Rc<LazyRowState>,
3804    item_builder: F,
3805    config: CarouselConfig,
3806) -> View
3807where
3808    T: Clone + 'static,
3809    F: Fn(T, usize) -> View + 'static,
3810{
3811    let padded_modifier = config.modifier.padding_values(PaddingValues {
3812        left: peek_amount,
3813        right: peek_amount,
3814        top: 0.0,
3815        bottom: 0.0,
3816    });
3817
3818    LazyRow(
3819        items,
3820        item_width,
3821        item_builder,
3822        LazyRowConfig {
3823            state,
3824            modifier: padded_modifier,
3825            ..Default::default()
3826        },
3827    )
3828}