Skip to main content

repose_material/material3/
mod.rs

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