Skip to main content

repose_material/material3/
mod.rs

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