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
12use std::cell::{Cell, RefCell};
13use std::rc::Rc;
14use std::sync::atomic::{AtomicU64, Ordering};
15use web_time::Duration;
16
17use crate::{Icon, Symbol};
18use repose_core::animation::{AnimationSpec, Easing, RepeatableSpec};
19use repose_core::*;
20use repose_ui::lazy::{LazyRow, LazyRowState};
21use repose_ui::{
22 Box, Column, Row, Spacer, Stack, Text, TextField, TextStyle, ViewExt, ZStack,
23 anim::{animate_color, animate_f32, animate_f32_from},
24 overlay::OverlayHandle,
25 overlay::SnackbarAction,
26 overlay::snackbar_is_dismissing,
27};
28
29pub(crate) fn alert_dialog_body(
30 title: View,
31 text: View,
32 confirm_button: View,
33 dismiss_button: Option<View>,
34) -> View {
35 Column(Modifier::new()).child((
36 title,
37 Box(Modifier::new().fill_max_width().height(16.0)),
38 text,
39 Spacer(),
40 Row(Modifier::new()).child((
41 dismiss_button.unwrap_or(Box(Modifier::new())),
42 Spacer(),
43 confirm_button,
44 )),
45 ))
46}
47
48pub fn AlertDialog(
49 visible: bool,
50 on_dismiss: impl Fn() + 'static,
51 title: View,
52 text: View,
53 confirm_button: View,
54 dismiss_button: Option<View>,
55) -> View {
56 if !visible {
57 return Box(Modifier::new());
58 }
59
60 let th = theme();
61 ZStack(Modifier::new().fill_max_size()).child((
62 Box(Modifier::new()
63 .fill_max_size()
64 .background(th.scrim.with_alpha(170))
65 .clickable()
66 .on_pointer_down(move |_| on_dismiss())),
67 Box(Modifier::new()
68 .min_width(280.0)
69 .max_width(560.0)
70 .padding(24.0)
71 .background(th.surface_container_high)
72 .clip_rounded(th.shapes.extra_large))
73 .child(alert_dialog_body(
74 title,
75 text,
76 confirm_button,
77 dismiss_button,
78 )),
79 ))
80}
81
82static BOTTOMSHEET_COUNTER: AtomicU64 = AtomicU64::new(0);
83
84pub fn BottomSheet(
85 visible: bool,
86 on_dismiss: impl Fn() + 'static,
87 modifier: Modifier,
88 content: View,
89 config: BottomSheetConfig, ) -> View {
91 let th = theme();
92 let id = remember(|| BOTTOMSHEET_COUNTER.fetch_add(1, Ordering::Relaxed));
93
94 let opacity = animate_f32_from(
95 format!("bs_opacity_{id}"),
96 if visible { 0.0 } else { 1.0 },
97 if visible { 1.0 } else { 0.0 },
98 th.motion.layout,
99 );
100
101 let keep = visible || opacity > 0.01;
102 if keep {
103 Column(Modifier::new()).child((
104 Box(modifier.alpha(opacity)).child(content),
105 Box(Modifier::new()
106 .width(1.0)
107 .height(0.0)
108 .fill_max_width()
109 .alpha(opacity)
110 .hit_passthrough()
111 .on_pointer_down(move |_| on_dismiss())),
112 ))
113 } else {
114 Box(Modifier::new())
115 }
116}
117
118static NAVBAR_COUNTER: AtomicU64 = AtomicU64::new(0);
119
120pub fn NavigationBar(
123 selected_index: usize,
124 items: Vec<NavItem>,
125 config: NavigationBarConfig,
126) -> View {
127 let th = theme();
128 let id = remember(|| NAVBAR_COUNTER.fetch_add(1, Ordering::Relaxed));
129 let spec = th.motion.shape;
130 Row(Modifier::new()
131 .fill_max_size()
132 .min_height(config.height)
133 .background(config.container_color)
134 .padding(8.0)
135 .then(config.modifier))
136 .child(
137 items
138 .into_iter()
139 .enumerate()
140 .map(|(i, item)| {
141 let selected = i == selected_index;
142 let fg = animate_color(
143 format!("nb_fg_{}_{}", id, i),
144 if selected {
145 config.selected_icon_color
146 } else {
147 config.unselected_icon_color
148 },
149 spec,
150 );
151 let bg_alpha = animate_f32(
152 format!("nb_bg_{}_{}", id, i),
153 if selected { 1.0 } else { 0.0 },
154 spec,
155 );
156 let indicator_bg = config
157 .indicator_color
158 .with_alpha_f32(bg_alpha * config.indicator_opacity);
159 let cb = item.on_click.clone();
160
161 Column(
162 Modifier::new()
163 .flex_grow(1.0)
164 .padding_values(PaddingValues {
165 left: config.item_horizontal_padding,
166 right: config.item_horizontal_padding,
167 top: config.item_vertical_padding,
168 bottom: config.item_vertical_padding,
169 })
170 .align_items(AlignItems::Center)
171 .justify_content(JustifyContent::Center)
172 .background(indicator_bg)
173 .clip_rounded(config.indicator_radius)
174 .state_colors(StateColors {
175 default: Color::TRANSPARENT,
176 hovered: th.on_surface.with_alpha_f32(0.08),
177 pressed: th.on_surface.with_alpha_f32(0.12),
178 disabled: Color::TRANSPARENT,
179 })
180 .clickable()
181 .on_pointer_down(move |_| cb()),
182 )
183 .child((
184 item.icon,
185 Text(item.label)
186 .color(fg)
187 .size(th.typography.label_medium)
188 .single_line(),
189 ))
190 })
191 .collect::<Vec<_>>(),
192 )
193}
194
195pub struct NavItem {
196 pub icon: View,
197 pub label: String,
198 pub on_click: Rc<dyn Fn()>,
199}
200
201pub fn Card(modifier: Modifier, content: View) -> View {
202 let config = CardConfig::default();
203 Box(modifier
204 .background(config.container_color)
205 .clip_rounded(config.shape_radius)
206 .then(config.modifier))
207 .child(Column(Modifier::new().fill_max_size()).child(content))
208}
209
210pub fn ElevatedCard(modifier: Modifier, content: View) -> View {
211 let th = theme();
212 let config = CardConfig {
213 container_color: CardDefaults::elevated_container_color(),
214 ..Default::default()
215 };
216 Box(modifier
217 .state_elevation(StateElevation {
218 default: config.tonal_elevation,
219 hovered: th.elevation.level2,
220 pressed: th.elevation.level3,
221 disabled: 0.0,
222 })
223 .background(config.container_color)
224 .clip_rounded(config.shape_radius))
225 .child(Column(Modifier::new().fill_max_size()).child(content))
226}
227
228pub fn OutlinedCard(modifier: Modifier, content: View) -> View {
229 let config = CardConfig {
230 container_color: CardDefaults::outlined_container_color(),
231 ..Default::default()
232 };
233 Box(modifier
234 .background(config.container_color)
235 .clip_rounded(config.shape_radius)
236 .border(
237 1.0,
238 CardDefaults::outlined_border_color(),
239 config.shape_radius,
240 ))
241 .child(Column(Modifier::new().fill_max_size()).child(content))
242}
243
244fn card_state_colors(bg: Color) -> StateColors {
245 let th = theme();
246 StateColors {
247 default: Color::TRANSPARENT,
248 hovered: th.on_surface.with_alpha_f32(0.08).composite_over(bg),
249 pressed: th.on_surface.with_alpha_f32(0.12).composite_over(bg),
250 disabled: th.on_surface.with_alpha_f32(0.12).composite_over(bg),
251 }
252}
253
254pub fn ClickableCard(on_click: impl Fn() + 'static, modifier: Modifier, content: View) -> View {
255 let th = theme();
256 let bg = th.surface_container_highest;
257 Box(modifier
258 .state_colors(card_state_colors(bg))
259 .clickable()
260 .on_pointer_down(move |_| on_click())
261 .background(bg)
262 .clip_rounded(th.shapes.medium))
263 .child(Column(Modifier::new().fill_max_size()).child(content))
264}
265
266pub fn ClickableElevatedCard(
267 on_click: impl Fn() + 'static,
268 modifier: Modifier,
269 content: View,
270) -> View {
271 let th = theme();
272 let bg = th.surface;
273 Box(modifier
274 .state_colors(card_state_colors(bg))
275 .state_elevation(StateElevation {
276 default: th.elevation.level1,
277 hovered: th.elevation.level2,
278 pressed: th.elevation.level3,
279 disabled: 0.0,
280 })
281 .clickable()
282 .on_pointer_down(move |_| on_click())
283 .background(bg)
284 .clip_rounded(th.shapes.medium))
285 .child(Column(Modifier::new().fill_max_size()).child(content))
286}
287
288pub fn ClickableOutlinedCard(
289 on_click: impl Fn() + 'static,
290 modifier: Modifier,
291 content: View,
292) -> View {
293 let th = theme();
294 let bg = th.surface;
295 Box(modifier
296 .state_colors(card_state_colors(bg))
297 .clickable()
298 .on_pointer_down(move |_| on_click())
299 .background(bg)
300 .clip_rounded(th.shapes.medium)
301 .border(1.0, th.outline_variant, th.shapes.medium))
302 .child(Column(Modifier::new().fill_max_size()).child(content))
303}
304
305pub fn Snackbar(
306 message: impl Into<String>,
307 action: Option<SnackbarAction>,
308 modifier: Modifier,
309 config: SnackbarConfig,
310) -> View {
311 let msg = message.into();
312 let th = theme();
313 let bg = config.container_color;
314 let fg = config.content_color;
315 let action_color = config.action_color;
316
317 let dismissing = snackbar_is_dismissing();
318
319 let slide_target = if dismissing { 80.0 } else { 0.0 };
320 let slide = animate_f32_from("snackbar_slide", 80.0, slide_target, th.motion.overlay);
321
322 let alpha_target = if dismissing { 0.0 } else { 1.0 };
323 let alpha = animate_f32_from("snackbar_alpha", 0.0, alpha_target, th.motion.overlay);
324
325 let snackbar = Box(Modifier::new()
326 .translate(0.0, slide)
327 .alpha(alpha)
328 .min_height(48.0)
329 .min_width(280.0)
330 .max_width(600.0)
331 .background(bg)
332 .clip_rounded(th.shapes.small))
333 .child(
334 Row(Modifier::new()
335 .fill_max_width()
336 .padding_values(PaddingValues {
337 left: 16.0,
338 right: 8.0,
339 top: 0.0,
340 bottom: 0.0,
341 })
342 .align_items(repose_core::AlignItems::Center))
343 .child((
344 Text(msg)
345 .modifier(Modifier::new().padding_values(PaddingValues {
346 left: 0.0,
347 right: 0.0,
348 top: 14.0,
349 bottom: 14.0,
350 }))
351 .color(fg)
352 .size(th.typography.body_medium)
353 .max_lines(2)
354 .overflow_ellipsize(),
355 Spacer(),
356 action
357 .map(|a| {
358 let label = a.label.clone();
359 TextButton(
360 Modifier::new(),
361 move || (a.on_click)(),
362 ButtonConfig::default(),
363 || {
364 Text(label)
365 .color(action_color)
366 .size(th.typography.label_large)
367 .single_line()
368 },
369 )
370 })
371 .unwrap_or(Box(Modifier::new())),
372 )),
373 );
374
375 Box(Modifier::new()
376 .absolute()
377 .offset_bottom(0.0)
378 .fill_max_width()
379 .justify_content(repose_core::JustifyContent::Center)
380 .then(modifier))
381 .child(snackbar)
382}
383
384pub fn FilterChip(
385 selected: bool,
386 on_click: impl Fn() + 'static,
387 label: View,
388 leading_icon: Option<View>,
389 trailing_icon: Option<View>,
390 config: ChipConfig,
391) -> View {
392 let th = theme();
393 let id = remember(|| FILTERCHIP_COUNTER.fetch_add(1, Ordering::Relaxed));
394 let spec = th.motion.color;
395
396 let bg = animate_color(
397 format!("fc_bg_{}", id),
398 if selected {
399 config.selected_container_color
400 } else {
401 config.container_color
402 },
403 spec,
404 );
405 let label_color = animate_color(
406 format!("fc_lc_{}", id),
407 if selected {
408 config.selected_content_color
409 } else {
410 config.content_color
411 },
412 spec,
413 );
414 let border_color = if selected {
415 Color::TRANSPARENT
416 } else {
417 config.border_color
418 };
419 let leading_color = animate_color(
420 format!("fc_lic_{}", id),
421 if selected {
422 config.selected_content_color
423 } else {
424 config.content_color
425 },
426 spec,
427 );
428
429 Box(Modifier::new()
430 .state_colors(StateColors {
431 default: Color::TRANSPARENT,
432 hovered: th.on_surface.with_alpha_f32(0.08).composite_over(bg),
433 pressed: th.on_surface.with_alpha_f32(0.12).composite_over(bg),
434 disabled: Color::TRANSPARENT,
435 })
436 .padding_values(PaddingValues {
437 left: config.horizontal_padding,
438 right: config.horizontal_padding,
439 top: 8.0,
440 bottom: 8.0,
441 })
442 .clickable()
443 .on_pointer_down(move |_| on_click())
444 .background(bg)
445 .clip_rounded(config.shape_radius)
446 .border(1.0, border_color, config.shape_radius))
447 .child(
448 Row(Modifier::new().align_items(AlignItems::Center)).child((
449 leading_icon
450 .map(|v| {
451 Box(Modifier::new().padding_values(PaddingValues {
452 left: 0.0,
453 right: 8.0,
454 top: 0.0,
455 bottom: 0.0,
456 }))
457 .child(with_content_color(leading_color, move || v))
458 })
459 .unwrap_or(Box(Modifier::new())),
460 with_content_color(label_color, move || label),
461 trailing_icon
462 .map(|v| {
463 Box(Modifier::new().padding_values(PaddingValues {
464 left: 8.0,
465 right: 0.0,
466 top: 0.0,
467 bottom: 0.0,
468 }))
469 .child(with_content_color(config.content_color, move || v))
470 })
471 .unwrap_or(Box(Modifier::new())),
472 )),
473 )
474}
475
476pub fn SuggestionChip(on_click: impl Fn() + 'static, label: View, icon: Option<View>) -> View {
477 let th = theme();
478 Box(Modifier::new()
479 .state_colors(StateColors {
480 default: Color::TRANSPARENT,
481 hovered: th.on_surface.with_alpha_f32(0.08),
482 pressed: th.on_surface.with_alpha_f32(0.12),
483 disabled: Color::TRANSPARENT,
484 })
485 .padding_values(PaddingValues {
486 left: 16.0,
487 right: 16.0,
488 top: 8.0,
489 bottom: 8.0,
490 })
491 .clickable()
492 .on_pointer_down(move |_| on_click())
493 .background(Color::TRANSPARENT)
494 .clip_rounded(8.0)
495 .border(1.0, th.outline_variant, 8.0))
496 .child(
497 Row(Modifier::new().align_items(AlignItems::Center)).child((
498 icon.map(|v| {
499 Box(Modifier::new().padding_values(PaddingValues {
500 left: 0.0,
501 right: 8.0,
502 top: 0.0,
503 bottom: 0.0,
504 }))
505 .child(with_content_color(th.primary, move || v))
506 })
507 .unwrap_or(Box(Modifier::new())),
508 with_content_color(th.on_surface_variant, move || label),
509 )),
510 )
511}
512
513pub fn InputChip(
514 selected: bool,
515 on_click: impl Fn() + 'static,
516 label: View,
517 leading_icon: Option<View>,
518 avatar: Option<View>,
519 trailing_icon: Option<View>,
520) -> View {
521 let th = theme();
522 let id = remember(|| FILTERCHIP_COUNTER.fetch_add(1, Ordering::Relaxed));
523 let spec = th.motion.color;
524
525 let bg = animate_color(
526 format!("ic_bg_{}", id),
527 if selected {
528 th.secondary_container
529 } else {
530 th.surface
531 },
532 spec,
533 );
534 let label_color = animate_color(
535 format!("ic_lc_{}", id),
536 if selected {
537 th.on_secondary_container
538 } else {
539 th.on_surface_variant
540 },
541 spec,
542 );
543 let border_color = if selected {
544 Color::TRANSPARENT
545 } else {
546 th.outline_variant
547 };
548 let leading_color = animate_color(
549 format!("ic_lic_{}", id),
550 if selected {
551 th.primary
552 } else {
553 th.on_surface_variant
554 },
555 spec,
556 );
557
558 Box(Modifier::new()
559 .state_colors(StateColors {
560 default: Color::TRANSPARENT,
561 hovered: th.on_surface.with_alpha_f32(0.08).composite_over(bg),
562 pressed: th.on_surface.with_alpha_f32(0.12).composite_over(bg),
563 disabled: Color::TRANSPARENT,
564 })
565 .padding_values(PaddingValues {
566 left: 16.0,
567 right: 16.0,
568 top: 8.0,
569 bottom: 8.0,
570 })
571 .clickable()
572 .on_pointer_down(move |_| on_click())
573 .background(bg)
574 .clip_rounded(8.0)
575 .border(1.0, border_color, 8.0))
576 .child(
577 Row(Modifier::new().align_items(AlignItems::Center)).child((
578 avatar
579 .or(leading_icon)
580 .map(|v| {
581 Box(Modifier::new().padding_values(PaddingValues {
582 left: 0.0,
583 right: 8.0,
584 top: 0.0,
585 bottom: 0.0,
586 }))
587 .child(with_content_color(leading_color, move || v))
588 })
589 .unwrap_or(Box(Modifier::new())),
590 with_content_color(label_color, move || label),
591 trailing_icon
592 .map(|v| {
593 Box(Modifier::new().padding_values(PaddingValues {
594 left: 8.0,
595 right: 0.0,
596 top: 0.0,
597 bottom: 0.0,
598 }))
599 .child(with_content_color(th.on_surface_variant, move || v))
600 })
601 .unwrap_or(Box(Modifier::new())),
602 )),
603 )
604}
605
606pub fn Scaffold(
607 top_bar: Option<View>,
608 bottom_bar: Option<View>,
609 floating_action_button: Option<View>,
610 content: impl Fn(PaddingValues) -> View,
611) -> View {
612 let insets = window_insets();
613
614 let content_padding = PaddingValues {
615 top: if top_bar.is_some() { 64.0 } else { insets.top },
616 bottom: if bottom_bar.is_some() {
617 80.0 + insets.bottom + insets.ime_bottom
618 } else {
619 insets.bottom + insets.ime_bottom
620 },
621 left: insets.left,
622 right: insets.right,
623 };
624
625 Stack(Modifier::new().fill_max_size()).child((
626 Box(Modifier::new()
627 .fill_max_size()
628 .padding_values(PaddingValues {
629 top: if top_bar.is_some() {
630 64.0 + insets.top
631 } else {
632 0.0
633 },
634 bottom: if bottom_bar.is_some() {
635 80.0 + insets.bottom + insets.ime_bottom
636 } else {
637 insets.bottom + insets.ime_bottom
638 },
639 ..Default::default()
640 }))
641 .child(content(content_padding)),
642 if let Some(bar) = top_bar {
643 Box(Modifier::new()
644 .absolute()
645 .offset(Some(0.0), Some(insets.top), Some(0.0), None))
646 .child(bar)
647 } else {
648 Box(Modifier::new())
649 },
650 if let Some(bar) = bottom_bar {
651 Box(Modifier::new().absolute().offset(
652 Some(0.0),
653 None,
654 Some(insets.bottom + insets.ime_bottom),
655 Some(0.0),
656 ))
657 .child(bar)
658 } else {
659 Box(Modifier::new())
660 },
661 if let Some(fab) = floating_action_button {
662 Box(Modifier::new().absolute().offset(
663 None,
664 None,
665 Some(16.0 + insets.bottom + insets.ime_bottom),
666 Some(16.0),
667 ))
668 .child(fab)
669 } else {
670 Box(Modifier::new())
671 },
672 ))
673}
674
675pub struct TooltipState {
677 visible: Signal<bool>,
678}
679
680impl TooltipState {
681 pub fn new() -> Rc<Self> {
682 Rc::new(Self {
683 visible: signal(false),
684 })
685 }
686
687 pub fn is_visible(&self) -> bool {
688 self.visible.get()
689 }
690
691 pub fn show(&self) {
692 self.visible.set(true);
693 }
694
695 pub fn dismiss(&self) {
696 self.visible.set(false);
697 }
698}
699
700pub fn TooltipBox(
711 text: impl Into<String>,
712 state: Rc<TooltipState>,
713 modifier: Modifier,
714 content: View,
715) -> View {
716 let text: Rc<str> = Rc::from(text.into());
717 let th = theme();
718 let spec = th.motion.overlay;
719
720 let alpha = animate_f32(
721 "tooltip_alpha",
722 if state.is_visible() { 1.0 } else { 0.0 },
723 spec,
724 );
725
726 let tooltip_visible = state.is_visible() || alpha > 0.01;
727 let scale = 0.92 + 0.08 * alpha;
728
729 Stack(modifier).child((
730 Box(Modifier::new().fill_max_size()).child(content),
731 if tooltip_visible {
732 Box(Modifier::new()
733 .background(th.inverse_surface)
734 .clip_rounded(th.shapes.extra_small)
735 .padding_values(PaddingValues {
736 left: 8.0,
737 right: 8.0,
738 top: 4.0,
739 bottom: 4.0,
740 })
741 .absolute()
742 .offset(None, Some(-28.0), None, None)
743 .align_self(AlignSelf::Center)
744 .render_z_index(10000.0)
745 .alpha(alpha)
746 .scale(scale))
747 .child(
748 Text((*text).to_string())
749 .color(th.inverse_on_surface)
750 .size(th.typography.label_medium)
751 .single_line(),
752 )
753 } else {
754 Box(Modifier::new())
755 },
756 ))
757}
758
759pub struct DrawerState {
761 visible: Signal<bool>,
762}
763
764impl DrawerState {
765 pub fn new() -> Rc<Self> {
766 Rc::new(Self {
767 visible: signal(false),
768 })
769 }
770
771 pub fn is_open(&self) -> bool {
772 self.visible.get()
773 }
774
775 pub fn open(&self) {
776 self.visible.set(true);
777 }
778
779 pub fn dismiss(&self) {
780 self.visible.set(false);
781 }
782}
783
784pub fn ModalNavigationDrawer(
786 drawer_state: Rc<DrawerState>,
787 drawer_content: View,
788 content: View,
789) -> View {
790 let th = theme();
791
792 let drawer_offset = animate_f32(
793 "modal_drawer_offset",
794 if drawer_state.is_open() { 0.0 } else { -360.0 },
795 theme().motion.spring,
796 );
797
798 ZStack(Modifier::new().fill_max_size()).child((
799 Box(Modifier::new().fill_max_size()).child(content),
800 if drawer_state.is_open() {
801 Box(Modifier::new()
802 .fill_max_size()
803 .background(th.scrim.with_alpha(82))
804 .clickable()
805 .on_pointer_down({
806 let ds = drawer_state.clone();
807 move |_| ds.dismiss()
808 }))
809 .child(Box(Modifier::new()))
810 } else {
811 Box(Modifier::new())
812 },
813 Box(Modifier::new()
814 .absolute()
815 .offset(Some(drawer_offset), Some(0.0), None, Some(0.0))
816 .fill_max_height()
817 .width(300.0)
818 .background(th.surface_container_low)
819 .clip_rounded(th.shapes.large))
820 .child(drawer_content),
821 ))
822}
823
824pub fn NavigationDrawerItem(
826 label: View,
827 selected: bool,
828 on_click: impl Fn() + 'static,
829 icon: Option<View>,
830 badge: Option<View>,
831) -> View {
832 let th = theme();
833 let id = remember(|| FILTERCHIP_COUNTER.fetch_add(1, Ordering::Relaxed));
834 let spec = th.motion.color;
835 let bg = animate_color(
836 format!("ndi_bg_{}", id),
837 if selected {
838 th.secondary_container
839 } else {
840 Color::TRANSPARENT
841 },
842 spec,
843 );
844 let fg = animate_color(
845 format!("ndi_fg_{}", id),
846 if selected {
847 th.on_secondary_container
848 } else {
849 th.on_surface_variant
850 },
851 spec,
852 );
853
854 Box(Modifier::new()
855 .fill_max_width()
856 .padding_values(PaddingValues {
857 left: 12.0,
858 right: 12.0,
859 top: 0.0,
860 bottom: 0.0,
861 })
862 .min_height(56.0)
863 .clickable()
864 .on_pointer_down(move |_| on_click())
865 .background(bg)
866 .clip_rounded(28.0))
867 .child(with_content_color(fg, || {
868 Row(Modifier::new()
869 .align_items(AlignItems::Center)
870 .padding_values(PaddingValues {
871 left: 16.0,
872 right: 24.0,
873 top: 0.0,
874 bottom: 0.0,
875 }))
876 .child((
877 icon.unwrap_or(Box(Modifier::new().width(24.0).height(24.0))),
878 Box(Modifier::new().width(12.0).height(1.0)),
879 Box(Modifier::new().flex_grow(1.0)).child(label),
880 badge.unwrap_or(Box(Modifier::new())),
881 ))
882 }))
883}
884
885#[derive(Clone)]
887pub struct DropdownMenuItem {
888 pub text: String,
889 pub leading_icon: Option<View>,
890 pub trailing_icon: Option<View>,
891 pub on_click: Rc<dyn Fn()>,
892 pub enabled: bool,
893}
894
895impl DropdownMenuItem {
896 pub fn new(text: impl Into<String>, on_click: impl Fn() + 'static) -> Self {
897 Self {
898 text: text.into(),
899 leading_icon: None,
900 trailing_icon: None,
901 on_click: Rc::new(on_click),
902 enabled: true,
903 }
904 }
905
906 pub fn leading_icon(mut self, icon: View) -> Self {
907 self.leading_icon = Some(icon);
908 self
909 }
910
911 pub fn trailing_icon(mut self, icon: View) -> Self {
912 self.trailing_icon = Some(icon);
913 self
914 }
915
916 pub fn disabled(mut self) -> Self {
917 self.enabled = false;
918 self
919 }
920}
921
922pub struct MenuDivider;
924
925pub struct MenuState {
927 visible: Signal<bool>,
928 anchor: Signal<Option<Vec2>>,
929}
930
931impl Default for MenuState {
932 fn default() -> Self {
933 Self::new()
934 }
935}
936
937impl MenuState {
938 pub fn new() -> Self {
939 Self {
940 visible: signal(false),
941 anchor: signal(None),
942 }
943 }
944
945 pub fn is_open(&self) -> bool {
946 self.visible.get()
947 }
948
949 pub fn open(&self) {
950 self.visible.set(true);
951 }
952
953 pub fn open_at(&self, screen_pos: Vec2) {
954 self.anchor.set(Some(screen_pos));
955 self.visible.set(true);
956 }
957
958 pub fn dismiss(&self) {
959 self.visible.set(false);
960 }
961}
962
963static DROPDOWN_COUNTER: AtomicU64 = AtomicU64::new(0);
964
965pub fn DropdownMenu(
972 state: Rc<MenuState>,
973 overlay: OverlayHandle,
974 modifier: Modifier,
975 trigger: View,
976 items: Vec<DropdownMenuEntry>,
977 config: DropdownMenuConfig,
978) -> View {
979 let th = theme();
980 let ddm_id = remember(|| DROPDOWN_COUNTER.fetch_add(1, Ordering::Relaxed));
981 let overlay_id = remember_with_key(format!("ddm_oid_{}", ddm_id), || signal(0u64));
982
983 let anim = remember_state_with_key(format!("ddm_anim_{}", ddm_id), || {
985 AnimatedValue::new(0.0, theme().motion.overlay)
986 });
987 let last_target = remember_state_with_key(format!("ddm_lt_{}", ddm_id), || f32::NAN);
988 let anim_target = if state.is_open() { 1.0 } else { 0.0 };
989
990 {
991 let mut a = anim.borrow_mut();
992 let mut lt = last_target.borrow_mut();
993 if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
994 a.set_target(anim_target);
995 *lt = anim_target;
996 }
997 drop(lt);
998 if a.update() {
999 request_frame();
1000 }
1001 }
1002
1003 let progress = *anim.borrow().get();
1004 let menu_visible = state.is_open() || progress > 0.01;
1005
1006 if menu_visible {
1008 if overlay_id.get() == 0 {
1009 let scrim = Box(Modifier::new().fill_max_size().absolute().on_pointer_down({
1010 let s = state.clone();
1011 move |_| s.dismiss()
1012 }));
1013 let id = overlay.show_with(scrim, 899.0, true);
1014 overlay_id.set(id);
1015 }
1016 } else {
1017 let prev = overlay_id.get();
1018 if prev != 0 {
1019 let _ = overlay.dismiss(prev);
1020 overlay_id.set(0);
1021 }
1022 }
1023
1024 let scale = 0.92 + 0.08 * progress;
1025 let alpha = progress;
1026
1027 Stack(modifier).child((
1028 trigger,
1029 if menu_visible {
1030 Box(Modifier::new()
1031 .absolute()
1032 .offset(None, Some(40.0), None, None)
1033 .render_z_index(900.0)
1034 .scale(scale)
1035 .alpha(alpha))
1036 .child(render_dropdown_menu_content(
1037 &th,
1038 &items,
1039 state.clone(),
1040 &config,
1041 ))
1042 } else {
1043 Box(Modifier::new())
1044 },
1045 ))
1046}
1047
1048#[derive(Clone)]
1050pub enum DropdownMenuEntry {
1051 Item(DropdownMenuItem),
1052 Divider,
1053}
1054
1055fn render_dropdown_menu_content(
1056 th: &Theme,
1057 items: &[DropdownMenuEntry],
1058 state: Rc<MenuState>,
1059 config: &DropdownMenuConfig,
1060) -> View {
1061 let children: Vec<View> = items
1062 .iter()
1063 .map(|entry| match entry {
1064 DropdownMenuEntry::Item(item) => {
1065 let text_color = if item.enabled {
1066 config.item_text_color
1067 } else {
1068 config.disabled_item_text_color
1069 };
1070 let on_click = item.on_click.clone();
1071 let state = state.clone();
1072 let mut modifier = Modifier::new()
1073 .fill_max_width()
1074 .min_height(40.0)
1075 .padding_values(PaddingValues {
1076 left: 12.0,
1077 right: 12.0,
1078 top: 0.0,
1079 bottom: 0.0,
1080 })
1081 .align_items(AlignItems::Center);
1082
1083 if item.enabled {
1084 modifier = modifier
1085 .state_colors(StateColors {
1086 default: Color::TRANSPARENT,
1087 hovered: th.on_surface.with_alpha_f32(0.08),
1088 pressed: th.on_surface.with_alpha_f32(0.12),
1089 disabled: Color::TRANSPARENT,
1090 })
1091 .clickable()
1092 .on_pointer_down(move |_| {
1093 on_click();
1094 state.dismiss();
1095 });
1096 }
1097
1098 Row(modifier).child((
1099 item.leading_icon
1100 .clone()
1101 .unwrap_or(Box(Modifier::new().width(24.0).height(24.0))),
1102 Box(Modifier::new().width(12.0).fill_max_height()),
1103 Box(Modifier::new().flex_grow(1.0)).child(
1104 Text(item.text.clone())
1105 .color(text_color)
1106 .size(th.typography.body_large)
1107 .single_line(),
1108 ),
1109 item.trailing_icon.clone().unwrap_or(Box(Modifier::new())),
1110 ))
1111 }
1112 DropdownMenuEntry::Divider => Box(Modifier::new()
1113 .fill_max_width()
1114 .height(1.0)
1115 .margin(12.0)
1116 .background(config.divider_color)),
1117 })
1118 .collect();
1119
1120 Box(Modifier::new()
1121 .state_elevation(StateElevation {
1122 default: th.elevation.level2,
1123 hovered: th.elevation.level3,
1124 pressed: th.elevation.level3,
1125 disabled: 0.0,
1126 })
1127 .min_width(config.min_width)
1128 .padding(4.0)
1129 .background(config.container_color)
1130 .clip_rounded(th.shapes.small))
1131 .child(Column(Modifier::new()).with_children(children))
1132}
1133
1134pub struct SearchBarState {
1136 pub query: Signal<String>,
1137 pub expanded: Signal<bool>,
1138 pub active: Signal<bool>,
1139}
1140
1141impl Default for SearchBarState {
1142 fn default() -> Self {
1143 Self::new()
1144 }
1145}
1146
1147impl SearchBarState {
1148 pub fn new() -> Self {
1149 Self {
1150 query: signal(String::new()),
1151 expanded: signal(false),
1152 active: signal(false),
1153 }
1154 }
1155
1156 pub fn query(&self) -> String {
1157 self.query.get()
1158 }
1159
1160 pub fn set_query(&self, q: String) {
1161 self.query.set(q);
1162 }
1163
1164 pub fn is_expanded(&self) -> bool {
1165 self.expanded.get()
1166 }
1167
1168 pub fn expand(&self) {
1169 self.expanded.set(true);
1170 }
1171
1172 pub fn collapse(&self) {
1173 self.expanded.set(false);
1174 self.active.set(false);
1175 }
1176
1177 pub fn is_active(&self) -> bool {
1178 self.active.get()
1179 }
1180
1181 pub fn activate(&self) {
1182 self.active.set(true);
1183 self.expanded.set(true);
1184 }
1185
1186 pub fn deactivate(&self) {
1187 self.active.set(false);
1188 }
1189}
1190
1191pub fn SearchBar(
1194 state: Rc<SearchBarState>,
1195 modifier: Modifier,
1196 leading_icon: Option<View>,
1197 trailing_icon: Option<View>,
1198 placeholder: impl Into<String>,
1199 on_query_change: Option<Rc<dyn Fn(String)>>,
1200 content: View,
1201) -> View {
1202 let th = theme();
1203 let placeholder = placeholder.into();
1204 let expanded = state.is_expanded();
1205 let query = state.query();
1206 let active = state.is_active();
1207
1208 let width = animate_f32(
1209 "searchbar_width",
1210 if expanded { 360.0 } else { 240.0 },
1211 theme().motion.expand,
1212 );
1213
1214 let input_field: View = if active {
1215 TextField(
1216 placeholder.clone(),
1217 query.clone(),
1218 Modifier::new().flex_grow(1.0).padding(4.0),
1219 Some({
1220 let s = state.clone();
1221 let cb = on_query_change.clone();
1222 move |text| {
1223 s.set_query(text);
1224 if let Some(ref cb) = cb {
1225 cb(s.query());
1226 }
1227 }
1228 }),
1229 None::<fn(String)>,
1230 )
1231 .color(th.on_surface)
1232 .size(th.typography.body_large)
1233 } else {
1234 Box(Modifier::new().flex_grow(1.0)).child(
1235 Text(if query.is_empty() {
1236 placeholder.clone()
1237 } else {
1238 query.clone()
1239 })
1240 .color(if query.is_empty() {
1241 th.on_surface_variant
1242 } else {
1243 th.on_surface
1244 })
1245 .size(th.typography.body_large)
1246 .single_line(),
1247 )
1248 };
1249
1250 let bar_modifier = modifier.clone();
1251 let bar_bg = if active {
1252 th.surface_container_high
1253 } else {
1254 th.surface_container
1255 };
1256 let bar = Box(bar_modifier
1257 .width(width)
1258 .height(56.0)
1259 .state_elevation(StateElevation {
1260 default: if active { th.elevation.level3 } else { 0.0 },
1261 hovered: th.elevation.level2,
1262 pressed: th.elevation.level3,
1263 disabled: 0.0,
1264 })
1265 .padding_values(PaddingValues {
1266 left: 16.0,
1267 right: 16.0,
1268 top: 0.0,
1269 bottom: 0.0,
1270 })
1271 .clickable()
1272 .on_pointer_down({
1273 let s = state.clone();
1274 move |_| s.activate()
1275 })
1276 .background(bar_bg)
1277 .clip_rounded(th.shapes.large))
1278 .child(
1279 Row(Modifier::new()
1280 .fill_max_size()
1281 .align_items(AlignItems::Center))
1282 .child((
1283 leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
1284 Box(Modifier::new().width(8.0).fill_max_height()),
1285 input_field,
1286 trailing_icon.unwrap_or(Box(Modifier::new())),
1287 )),
1288 );
1289
1290 if expanded {
1291 Stack(modifier).child((
1292 bar,
1293 Box(Modifier::new()
1294 .width(width)
1295 .max_height(400.0)
1296 .background(th.surface_container)
1297 .clip_rounded(th.shapes.small))
1298 .child(content),
1299 ))
1300 } else {
1301 bar
1302 }
1303}
1304
1305pub fn DockedSearchBar(
1308 state: Rc<SearchBarState>,
1309 modifier: Modifier,
1310 leading_icon: Option<View>,
1311 placeholder: impl Into<String>,
1312 on_query_change: Option<Rc<dyn Fn(String)>>,
1313 content: View,
1314) -> View {
1315 let th = theme();
1316 let placeholder = placeholder.into();
1317 let expanded = state.is_expanded();
1318 let query = state.query();
1319 let active = state.is_active();
1320
1321 let content_target = if expanded { 400.0 } else { 0.0 };
1322 let content_height = animate_f32("docked_sh", content_target, theme().motion.expand);
1323 let content_alpha = animate_f32(
1324 "docked_sa",
1325 if expanded { 1.0 } else { 0.0 },
1326 theme().motion.color,
1327 );
1328
1329 let input_field: View = if active {
1330 TextField(
1331 placeholder.clone(),
1332 query.clone(),
1333 Modifier::new().flex_grow(1.0),
1334 Some({
1335 let s = state.clone();
1336 let cb = on_query_change.clone();
1337 move |text| {
1338 s.set_query(text);
1339 if let Some(ref cb) = cb {
1340 cb(s.query());
1341 }
1342 }
1343 }),
1344 None::<fn(String)>,
1345 )
1346 .color(th.on_surface)
1347 .size(th.typography.body_large)
1348 } else {
1349 Box(Modifier::new().flex_grow(1.0)).child(if query.is_empty() {
1350 Text(placeholder.clone())
1351 .color(th.on_surface_variant)
1352 .size(th.typography.body_large)
1353 .single_line()
1354 } else {
1355 Text(query.clone())
1356 .color(th.on_surface)
1357 .size(th.typography.body_large)
1358 .single_line()
1359 })
1360 };
1361
1362 let bar_bg = if active {
1363 th.surface_container_high
1364 } else {
1365 th.surface_container
1366 };
1367 let bar = Box(modifier
1368 .fill_max_width()
1369 .height(56.0)
1370 .state_elevation(StateElevation {
1371 default: if active { th.elevation.level3 } else { 0.0 },
1372 hovered: th.elevation.level2,
1373 pressed: th.elevation.level3,
1374 disabled: 0.0,
1375 })
1376 .padding_values(PaddingValues {
1377 left: 16.0,
1378 right: 16.0,
1379 top: 0.0,
1380 bottom: 0.0,
1381 })
1382 .clickable()
1383 .on_pointer_down({
1384 let s = state.clone();
1385 move |_| {
1386 if !s.is_active() {
1387 s.activate()
1388 }
1389 }
1390 })
1391 .background(bar_bg)
1392 .clip_rounded(th.shapes.large))
1393 .child(
1394 Row(Modifier::new()
1395 .fill_max_size()
1396 .align_items(AlignItems::Center))
1397 .child((
1398 leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
1399 Box(Modifier::new().width(12.0).fill_max_height()),
1400 input_field,
1401 if active {
1402 Box(Modifier::new()
1403 .size(24.0, 24.0)
1404 .clickable()
1405 .on_pointer_down({
1406 let s = state.clone();
1407 move |_| {
1408 s.set_query(String::new());
1409 s.collapse();
1410 }
1411 }))
1412 .child(Text("✕").size(16.0).color(th.on_surface_variant))
1413 } else {
1414 Box(Modifier::new())
1415 },
1416 )),
1417 );
1418
1419 let show_content = expanded || content_height > 1.0;
1420 if show_content {
1421 Column(Modifier::new().fill_max_width()).child((
1422 bar,
1423 Box(Modifier::new()
1424 .fill_max_width()
1425 .height(content_height)
1426 .alpha(content_alpha)
1427 .clip_rounded(th.shapes.small)
1428 .background(th.surface_container)
1429 .state_elevation(StateElevation {
1430 default: th.elevation.level3,
1431 hovered: th.elevation.level3,
1432 pressed: th.elevation.level3,
1433 disabled: 0.0,
1434 }))
1435 .child(
1436 Column(Modifier::new().fill_max_width()).child((
1437 Box(Modifier::new()
1438 .fill_max_width()
1439 .height(1.0)
1440 .background(th.outline_variant)),
1441 content,
1442 )),
1443 ),
1444 ))
1445 } else {
1446 bar
1447 }
1448}
1449
1450pub struct SheetState {
1452 visible: Signal<bool>,
1453 drag_offset: Signal<f32>,
1454 peek_height: Signal<f32>,
1455}
1456
1457impl SheetState {
1458 pub fn new(peek_height: f32) -> Self {
1459 Self {
1460 visible: signal(false),
1461 drag_offset: signal(0.0),
1462 peek_height: signal(peek_height),
1463 }
1464 }
1465
1466 pub fn is_visible(&self) -> bool {
1467 self.visible.get()
1468 }
1469
1470 pub fn show(&self) {
1471 self.visible.set(true);
1472 }
1473
1474 pub fn dismiss(&self) {
1475 self.visible.set(false);
1476 self.drag_offset.set(0.0);
1477 }
1478
1479 pub fn set_peek_height(&self, h: f32) {
1480 self.peek_height.set(h);
1481 }
1482}
1483
1484pub fn ModalBottomSheet(
1489 state: Rc<SheetState>,
1490 overlay: OverlayHandle,
1491 modifier: Modifier,
1492 content: View,
1493 config: BottomSheetConfig,
1494) -> View {
1495 let th = theme();
1496 let peek_h = state.peek_height.get().max(config.peek_height);
1497 let anim_distance = peek_h.max(48.0).max(400.0);
1498 let overlay_id = remember_with_key("mbs_oid", || signal(0u64));
1499
1500 let anim = remember_state_with_key("mbs_anim", || {
1502 AnimatedValue::new(anim_distance, theme().motion.spring)
1503 });
1504 let last_target = remember_state_with_key("mbs_anim_target", || f32::NAN);
1505 let anim_target = if state.is_visible() {
1506 0.0
1507 } else {
1508 anim_distance
1509 };
1510
1511 {
1512 let mut a = anim.borrow_mut();
1513 let mut lt = last_target.borrow_mut();
1514 if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
1515 if state.is_visible() {
1516 a.set_spec(th.motion.spring);
1517 } else {
1518 a.set_spec(AnimationSpec::fast());
1519 }
1520 a.set_target(anim_target);
1521 *lt = anim_target;
1522 }
1523 drop(lt);
1524 let still_animating = a.update();
1525 if still_animating {
1526 request_frame();
1527 }
1528 }
1529
1530 let offset = *anim.borrow().get();
1531 let sheet_visible = state.is_visible() || offset < anim_distance - 10.0;
1532
1533 if sheet_visible {
1534 if overlay_id.get() == 0 {
1535 let builder: Rc<dyn Fn() -> View> = Rc::new({
1536 let state = state.clone();
1537 let anim = anim.clone();
1538 let modifier = modifier.clone();
1539 let content = content.clone();
1540 move || {
1541 let off = *anim.borrow().get();
1542
1543 let sheet_body = Box(modifier
1544 .clone()
1545 .fill_max_width()
1546 .max_width(dp_to_px(config.max_width))
1547 .translate(0.0, off)
1548 .background(config.container_color)
1549 .clip_rounded(config.shape_radius))
1550 .child(
1551 Column(Modifier::new().fill_max_width()).child((
1552 Row(Modifier::new()
1553 .fill_max_width()
1554 .justify_content(JustifyContent::Center))
1555 .child(Box(Modifier::new()
1556 .margin_vertical(22.0)
1557 .width(config.drag_handle_width)
1558 .height(config.drag_handle_height)
1559 .background(config.drag_handle_color)
1560 .clip_rounded(2.0))),
1561 content.clone(),
1562 )),
1563 );
1564
1565 let sheet = Box(Modifier::new()
1566 .fill_max_size()
1567 .justify_content(JustifyContent::Center)
1568 .align_items(AlignItems::FlexEnd))
1569 .child(sheet_body);
1570
1571 let scrim_alpha = if state.is_visible() {
1572 config.scrim_color.3
1573 } else {
1574 let t = (off / anim_distance).clamp(0.0, 1.0);
1575 (config.scrim_color.3 as f32 * (1.0 - t)) as u8
1576 };
1577 let scrim = Box(Modifier::new()
1578 .fill_max_size()
1579 .background(config.scrim_color.with_alpha(scrim_alpha))
1580 .on_pointer_down({
1581 let s = state.clone();
1582 move |_| s.dismiss()
1583 }));
1584
1585 ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, sheet))
1586 }
1587 });
1588
1589 let id = overlay.show_entry(builder, 900.0, false);
1590 overlay_id.set(id);
1591 }
1592 } else {
1593 let prev = overlay_id.get();
1594 if prev != 0 {
1595 let _ = overlay.dismiss(prev);
1596 overlay_id.set(0);
1597 }
1598 }
1599
1600 Box(Modifier::new())
1601}
1602
1603pub struct PullToRefreshState {
1609 refreshing: Signal<bool>,
1610 scroll_state: RefCell<Option<Rc<repose_ui::scroll::ScrollState>>>,
1611 threshold: f32,
1612 triggered: Cell<bool>,
1613}
1614
1615impl Default for PullToRefreshState {
1616 fn default() -> Self {
1617 Self::new()
1618 }
1619}
1620
1621impl PullToRefreshState {
1622 pub fn new() -> Self {
1623 Self {
1624 refreshing: signal(false),
1625 scroll_state: RefCell::new(None),
1626 threshold: 64.0,
1627 triggered: Cell::new(false),
1628 }
1629 }
1630
1631 pub fn set_scroll_state(&self, state: Rc<repose_ui::scroll::ScrollState>) {
1634 *self.scroll_state.borrow_mut() = Some(state);
1635 }
1636
1637 pub fn set_threshold(&mut self, px: f32) {
1639 self.threshold = px;
1640 }
1641
1642 pub fn is_refreshing(&self) -> bool {
1643 self.refreshing.get()
1644 }
1645
1646 pub fn set_refreshing(&self, v: bool) {
1647 self.refreshing.set(v);
1648 if !v && let Some(sc) = self.scroll_state.borrow().as_ref() {
1649 sc.set_overscroll(0.0);
1650 }
1651 }
1652
1653 pub fn pull_offset(&self) -> f32 {
1655 if let Some(sc) = self.scroll_state.borrow().as_ref() {
1656 let os = sc.overscroll_offset();
1657 if os < 0.0 { -os } else { 0.0 }
1658 } else {
1659 0.0
1660 }
1661 }
1662}
1663
1664pub fn PullToRefresh(
1673 state: Rc<PullToRefreshState>,
1674 modifier: Modifier,
1675 on_refresh: Rc<dyn Fn()>,
1676 content: View,
1677 config: PullToRefreshConfig,
1678) -> View {
1679 let pull = state.pull_offset();
1680 let refreshing = state.is_refreshing();
1681 let threshold = config.threshold;
1682
1683 if state.triggered.get() && !refreshing && pull < threshold {
1684 state.triggered.set(false);
1685 }
1686
1687 if !refreshing && !state.triggered.get() && pull >= threshold {
1688 state.triggered.set(true);
1689 state.refreshing.set(true);
1690 (on_refresh)();
1691 }
1692
1693 let frac_key = format!("ptr_frac_{}", Rc::as_ptr(&state) as u64);
1694 let raw_frac = if refreshing {
1695 1.0
1696 } else if pull > 0.0 {
1697 (pull / threshold).min(1.0)
1698 } else {
1699 0.0
1700 };
1701 let distance_fraction = animate_f32_from(frac_key, 0.0, raw_frac, theme().motion.color);
1702
1703 let indicator_h = distance_fraction * threshold;
1705 let icon_size = if refreshing {
1706 24.0
1707 } else {
1708 (16.0 + distance_fraction * 8.0).min(24.0)
1709 };
1710 let rotation = if refreshing {
1711 animate_f32_from(
1712 "ptr_spin",
1713 0.0,
1714 std::f32::consts::TAU,
1715 AnimationSpec::tween(Duration::from_millis(1000), Easing::Linear)
1716 .repeated(RepeatableSpec::infinite()),
1717 )
1718 } else {
1719 (distance_fraction * 180.0).to_radians()
1720 };
1721 Column(modifier).child((
1722 if distance_fraction > 0.01 {
1723 Box(Modifier::new()
1724 .fill_max_width()
1725 .height(indicator_h)
1726 .align_items(AlignItems::Center)
1727 .justify_content(JustifyContent::Center))
1728 .child(
1729 Box(Modifier::new()
1730 .size(icon_size, icon_size)
1731 .translate(icon_size * 0.5, icon_size * 0.5)
1732 .rotate(rotation)
1733 .translate(-icon_size * 0.5, -icon_size * 0.5))
1734 .child(if refreshing {
1735 Icon(Symbol::new("refresh", '\u{E5D5}'))
1736 .size(24.0)
1737 .color(config.indicator_color)
1738 } else {
1739 Icon(Symbol::new("arrow_downward", '\u{E5DB}'))
1740 .size(icon_size)
1741 .color(
1742 config
1743 .indicator_color
1744 .with_alpha_f32(distance_fraction.min(1.0)),
1745 )
1746 }),
1747 )
1748 } else {
1749 Box(Modifier::new())
1750 },
1751 content,
1752 ))
1753}
1754
1755pub struct DatePickerState {
1757 pub year: Signal<i32>,
1758 pub month: Signal<u32>, pub day: Signal<u32>,
1760}
1761
1762impl DatePickerState {
1763 pub fn new(year: i32, month: u32, day: u32) -> Self {
1764 Self {
1765 year: signal(year),
1766 month: signal(month.clamp(1, 12)),
1767 day: signal(day.clamp(1, 31)),
1768 }
1769 }
1770
1771 pub fn selected_date(&self) -> (i32, u32, u32) {
1772 (self.year.get(), self.month.get(), self.day.get())
1773 }
1774}
1775
1776fn days_in_month(year: i32, month: u32) -> u32 {
1777 match month {
1778 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1779 4 | 6 | 9 | 11 => 30,
1780 2 => {
1781 if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
1782 29
1783 } else {
1784 28
1785 }
1786 }
1787 _ => 30,
1788 }
1789}
1790
1791fn first_day_of_month(year: i32, month: u32) -> u32 {
1794 let m = month as i32;
1795 let (y, adj_m) = if m <= 2 {
1796 (year - 1, m + 12)
1797 } else {
1798 (year, m)
1799 };
1800 let k = y % 100;
1801 let j = y / 100;
1802 let h = (1 + (13 * (adj_m + 1)) / 5 + k + k / 4 + j / 4 + 5 * j) % 7;
1803 ((h + 5) % 7) as u32
1805}
1806
1807struct ReposeDate {
1809 year: i32,
1810 month: u32,
1811 day: u32,
1812}
1813
1814impl ReposeDate {
1815 fn now() -> Self {
1817 let duration = web_time::SystemTime::now()
1818 .duration_since(web_time::UNIX_EPOCH)
1819 .unwrap_or_default();
1820 let days = (duration.as_secs() / 86_400) as i64;
1821 let z = days + 719468;
1823 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1824 let doe = (z - era * 146_097) as u64;
1825 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
1826 let y = (yoe as i64) + era * 400;
1827 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1828 let mp = (5 * doy + 2) / 153;
1829 let d = doy - (153 * mp + 2) / 5 + 1;
1830 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1831 let y = if m <= 2 { y + 1 } else { y };
1832 Self {
1833 year: y as i32,
1834 month: m as u32,
1835 day: d as u32,
1836 }
1837 }
1838}
1839
1840const MONTH_NAMES: [&str; 12] = [
1841 "January",
1842 "February",
1843 "March",
1844 "April",
1845 "May",
1846 "June",
1847 "July",
1848 "August",
1849 "September",
1850 "October",
1851 "November",
1852 "December",
1853];
1854
1855const DOW_HEADERS: [&str; 7] = ["M", "T", "W", "T", "F", "S", "S"];
1856
1857pub fn DatePicker(
1860 state: Rc<DatePickerState>,
1861 on_confirm: Rc<dyn Fn(i32, u32, u32)>,
1862 on_dismiss: Rc<dyn Fn()>,
1863) -> View {
1864 let th = theme();
1865 let (year, month, day) = state.selected_date();
1866 let dim = days_in_month(year, month);
1867 let start_dow = first_day_of_month(year, month);
1868
1869 let prev_year = {
1871 let s = state.clone();
1872 move || {
1873 s.year.set(s.year.get() - 1);
1874 let d = days_in_month(s.year.get(), s.month.get());
1875 if s.day.get() > d {
1876 s.day.set(d);
1877 }
1878 }
1879 };
1880 let next_year = {
1881 let s = state.clone();
1882 move || {
1883 s.year.set(s.year.get() + 1);
1884 let d = days_in_month(s.year.get(), s.month.get());
1885 if s.day.get() > d {
1886 s.day.set(d);
1887 }
1888 }
1889 };
1890
1891 let prev_month = {
1892 let s = state.clone();
1893 move || {
1894 if s.month.get() == 1 {
1895 s.year.set(s.year.get() - 1);
1896 s.month.set(12);
1897 } else {
1898 s.month.set(s.month.get() - 1);
1899 }
1900 let d = days_in_month(s.year.get(), s.month.get());
1901 if s.day.get() > d {
1902 s.day.set(d);
1903 }
1904 }
1905 };
1906
1907 let next_month = {
1908 let s = state.clone();
1909 move || {
1910 if s.month.get() == 12 {
1911 s.year.set(s.year.get() + 1);
1912 s.month.set(1);
1913 } else {
1914 s.month.set(s.month.get() + 1);
1915 }
1916 let d = days_in_month(s.year.get(), s.month.get());
1917 if s.day.get() > d {
1918 s.day.set(d);
1919 }
1920 }
1921 };
1922
1923 let now = ReposeDate::now();
1925 let today = (now.year, now.month, now.day);
1926
1927 Column(Modifier::new().padding(16.0)).child((
1928 Row(Modifier::new()
1930 .fill_max_width()
1931 .align_items(AlignItems::Center))
1932 .child((
1933 IconButton(
1934 Box(Modifier::new()).child(Text("◀").color(th.on_surface).size(16.0)),
1935 prev_month,
1936 IconButtonConfig::default(),
1937 ),
1938 Spacer(),
1939 Column(Modifier::new().align_items(AlignItems::Center)).child((
1940 Text(MONTH_NAMES[(month - 1) as usize].to_string())
1941 .size(th.typography.title_medium)
1942 .color(th.on_surface),
1943 Row(Modifier::new().gap(8.0).align_items(AlignItems::Center)).child((
1944 IconButton(
1945 Box(Modifier::new())
1946 .child(Text("‹").color(th.on_surface_variant).size(14.0)),
1947 prev_year,
1948 IconButtonConfig::default(),
1949 ),
1950 Text(year.to_string())
1951 .size(th.typography.body_small)
1952 .color(th.on_surface_variant),
1953 IconButton(
1954 Box(Modifier::new())
1955 .child(Text("›").color(th.on_surface_variant).size(14.0)),
1956 next_year,
1957 IconButtonConfig::default(),
1958 ),
1959 )),
1960 )),
1961 Spacer(),
1962 IconButton(
1963 Box(Modifier::new()).child(Text("▶").color(th.on_surface).size(16.0)),
1964 next_month,
1965 IconButtonConfig::default(),
1966 ),
1967 )),
1968 Box(Modifier::new().fill_max_width().height(12.0)),
1969 Column(Modifier::new()).child({
1971 let mut rows: Vec<View> = Vec::new();
1972 let dow_headers: Vec<View> = DOW_HEADERS
1974 .iter()
1975 .map(|d| {
1976 Box(Modifier::new()
1977 .width(40.0)
1978 .height(40.0)
1979 .align_items(AlignItems::Center)
1980 .justify_content(JustifyContent::Center))
1981 .child(
1982 Text(d.to_string())
1983 .size(th.typography.label_small)
1984 .color(th.on_surface_variant),
1985 )
1986 })
1987 .collect();
1988 rows.push(Row(Modifier::new()).with_children(dow_headers));
1989
1990 let total_cells = start_dow + dim;
1992 let num_rows = total_cells.div_ceil(7).min(6);
1993 for w in 0..num_rows {
1994 let mut week: Vec<View> = Vec::new();
1995 for d in 0..7 {
1996 let cell_idx = w * 7 + d;
1997 if cell_idx < start_dow {
1998 week.push(Box(Modifier::new().width(40.0).height(40.0)));
1999 } else {
2000 let day_num = (cell_idx - start_dow + 1) as i32;
2001 if day_num <= dim as i32 {
2002 let is_selected = day_num == day as i32;
2003 let is_today =
2004 today.0 == year && today.1 == month && today.2 == day_num as u32;
2005 let s = state.clone();
2006 let on_confirm = on_confirm.clone();
2007 let on_dismiss_fn = on_dismiss.clone();
2008 week.push(
2009 Box(Modifier::new()
2010 .width(40.0)
2011 .height(40.0)
2012 .background(if is_selected {
2013 th.primary
2014 } else {
2015 Color::TRANSPARENT
2016 })
2017 .clip_rounded(20.0)
2018 .align_items(AlignItems::Center)
2019 .justify_content(JustifyContent::Center)
2020 .clickable()
2021 .on_pointer_down(move |_| {
2022 s.day.set(day_num as u32);
2023 on_confirm(s.year.get(), s.month.get(), day_num as u32);
2024 on_dismiss_fn();
2025 }))
2026 .child({
2027 let mut t = Text(day_num.to_string())
2028 .size(th.typography.body_medium)
2029 .color(if is_selected {
2030 th.on_primary
2031 } else {
2032 th.on_surface
2033 });
2034 if is_today && !is_selected {
2035 t = t.modifier(
2036 Modifier::new().border(1.0, th.primary, 10.0),
2037 );
2038 }
2039 t
2040 }),
2041 );
2042 } else {
2043 week.push(Box(Modifier::new().width(40.0).height(40.0)));
2044 }
2045 }
2046 }
2047 rows.push(Row(Modifier::new()).with_children(week));
2048 }
2049 rows
2050 }),
2051 Box(Modifier::new().fill_max_width().height(12.0)),
2052 Row(Modifier::new()
2054 .fill_max_width()
2055 .justify_content(JustifyContent::End)
2056 .gap(8.0))
2057 .child((
2058 TextButton(
2059 Modifier::new(),
2060 {
2061 let on_dismiss = on_dismiss.clone();
2062 move || (on_dismiss)()
2063 },
2064 ButtonConfig::default(),
2065 || Text("Cancel").size(14.0),
2066 ),
2067 FilledButton(
2068 Modifier::new(),
2069 {
2070 let on_confirm = on_confirm.clone();
2071 let s = state.clone();
2072 move || {
2073 let (y, m, d) = s.selected_date();
2074 on_confirm(y, m, d);
2075 }
2076 },
2077 ButtonConfig::default(),
2078 || Text("OK").size(14.0),
2079 ),
2080 )),
2081 ))
2082}
2083
2084pub struct TimePickerState {
2086 pub hour: Signal<u32>,
2087 pub minute: Signal<u32>,
2088 pub is_am: Signal<bool>,
2089}
2090
2091impl TimePickerState {
2092 pub fn new(hour: u32, minute: u32) -> Self {
2093 let h = hour % 12;
2094 let am = hour < 12;
2095 Self {
2096 hour: signal(if h == 0 { 12 } else { h }),
2097 minute: signal(minute.min(59)),
2098 is_am: signal(am),
2099 }
2100 }
2101
2102 pub fn selected_time(&self) -> (u32, u32) {
2103 let mut h = self.hour.get();
2104 if !self.is_am.get() {
2105 h = (h % 12) + 12;
2106 } else if h == 12 {
2107 h = 0;
2108 }
2109 (h, self.minute.get())
2110 }
2111}
2112
2113pub fn TimePicker(
2115 state: Rc<TimePickerState>,
2116 on_confirm: Rc<dyn Fn(u32, u32)>,
2117 on_dismiss: Rc<dyn Fn()>,
2118) -> View {
2119 let th = theme();
2120 let hour = state.hour.get();
2121 let minute = state.minute.get();
2122 let is_am = state.is_am.get();
2123
2124 let hour_str = format!("{:02}", hour);
2125 let min_str = format!("{:02}", minute);
2126
2127 Column(
2128 Modifier::new()
2129 .width(256.0)
2130 .padding(24.0)
2131 .align_items(AlignItems::Center),
2132 )
2133 .child((
2134 Row(Modifier::new().align_items(AlignItems::Center)).child((
2136 Box(Modifier::new()
2137 .clickable()
2138 .on_pointer_down({
2139 let s = state.clone();
2140 move |_| s.hour.set((s.hour.get() % 12) + 1)
2141 })
2142 .padding(8.0))
2143 .child(Text(hour_str).size(48.0).color(th.on_surface).single_line()),
2144 Text(":")
2145 .size(48.0)
2146 .color(th.on_surface_variant)
2147 .single_line(),
2148 Box(Modifier::new()
2149 .clickable()
2150 .on_pointer_down({
2151 let s = state.clone();
2152 move |_| s.minute.set((s.minute.get() + 1) % 60)
2153 })
2154 .padding(8.0))
2155 .child(Text(min_str).size(48.0).color(th.on_surface).single_line()),
2156 )),
2157 Box(Modifier::new().fill_max_width().height(16.0)),
2158 Row(Modifier::new().align_items(AlignItems::Center)).child((
2160 Box(Modifier::new()
2161 .padding_values(PaddingValues {
2162 left: 12.0,
2163 right: 12.0,
2164 top: 4.0,
2165 bottom: 4.0,
2166 })
2167 .background(if is_am {
2168 th.primary
2169 } else {
2170 Color::TRANSPARENT
2171 })
2172 .clip_rounded(8.0)
2173 .clickable()
2174 .on_pointer_down({
2175 let s = state.clone();
2176 move |_| {
2177 if !s.is_am.get() {
2178 s.is_am.set(true);
2179 let h = s.hour.get();
2180 s.hour.set(if h == 12 { 12 } else { (h + 12) % 24 });
2181 if s.hour.get() == 0 {
2182 s.hour.set(12);
2183 }
2184 }
2185 }
2186 }))
2187 .child(Text("AM").size(th.typography.label_large).color(if is_am {
2188 th.on_primary
2189 } else {
2190 th.on_surface
2191 })),
2192 Box(Modifier::new().width(8.0).height(1.0)),
2193 Box(Modifier::new()
2194 .padding_values(PaddingValues {
2195 left: 12.0,
2196 right: 12.0,
2197 top: 4.0,
2198 bottom: 4.0,
2199 })
2200 .background(if !is_am {
2201 th.primary
2202 } else {
2203 Color::TRANSPARENT
2204 })
2205 .clip_rounded(8.0)
2206 .clickable()
2207 .on_pointer_down({
2208 let s = state.clone();
2209 move |_| {
2210 if s.is_am.get() {
2211 s.is_am.set(false);
2212 let h = s.hour.get();
2213 s.hour.set(if h == 12 { 12 } else { (h + 12) % 24 });
2214 if s.hour.get() == 0 {
2215 s.hour.set(12);
2216 }
2217 }
2218 }
2219 }))
2220 .child(Text("PM").size(th.typography.label_large).color(if !is_am {
2221 th.on_primary
2222 } else {
2223 th.on_surface
2224 })),
2225 )),
2226 Box(Modifier::new().fill_max_width().height(16.0)),
2227 Row(Modifier::new().fill_max_width()).child((
2228 Spacer(),
2229 Box(Modifier::new().padding(8.0).clickable().on_pointer_down({
2230 let on_dismiss = on_dismiss.clone();
2231 move |_| on_dismiss()
2232 }))
2233 .child(
2234 Text("Cancel")
2235 .color(th.primary)
2236 .size(th.typography.label_large)
2237 .single_line(),
2238 ),
2239 Box(Modifier::new().width(8.0).height(1.0)),
2240 Box(Modifier::new()
2241 .padding(8.0)
2242 .clickable()
2243 .on_pointer_down(move |_| {
2244 let (h, m) = state.selected_time();
2245 on_confirm(h, m);
2246 on_dismiss();
2247 }))
2248 .child(
2249 Text("OK")
2250 .color(th.primary)
2251 .size(th.typography.label_large)
2252 .single_line(),
2253 ),
2254 )),
2255 ))
2256}
2257
2258pub struct NavRailItem {
2260 pub icon: View,
2261 pub label: String,
2262 pub on_click: Rc<dyn Fn()>,
2263 pub badge: Option<View>,
2264}
2265
2266static NAVRAIL_COUNTER: AtomicU64 = AtomicU64::new(0);
2267static FILTERCHIP_COUNTER: AtomicU64 = AtomicU64::new(0);
2268
2269pub fn NavigationRail(
2274 selected_index: usize,
2275 items: Vec<NavRailItem>,
2276 header: Option<View>,
2277 fab: Option<View>,
2278 config: NavigationRailConfig,
2279) -> View {
2280 let th = theme();
2281 let id = remember(|| NAVRAIL_COUNTER.fetch_add(1, Ordering::Relaxed));
2282 let spec = th.motion.shape;
2283
2284 let mut top_children: Vec<View> = Vec::new();
2285 let mut item_views: Vec<View> = Vec::new();
2286
2287 let has_header = header.is_some();
2288 let has_fab = fab.is_some();
2289
2290 if let Some(h) = header {
2291 top_children.push(
2292 Box(Modifier::new()
2293 .padding_values(PaddingValues {
2294 left: 12.0,
2295 right: 12.0,
2296 top: 12.0,
2297 bottom: 12.0,
2298 })
2299 .align_self(AlignSelf::Center))
2300 .child(h),
2301 );
2302 }
2303
2304 if let Some(f) = fab {
2305 top_children.push(
2306 Box(Modifier::new()
2307 .padding_values(PaddingValues {
2308 left: 12.0,
2309 right: 12.0,
2310 top: 8.0,
2311 bottom: 8.0,
2312 })
2313 .align_self(AlignSelf::Center))
2314 .child(f),
2315 );
2316 }
2317
2318 if has_header || has_fab {
2319 top_children.push(Box(Modifier::new()
2320 .fill_max_width()
2321 .height(1.0)
2322 .background(th.outline_variant)));
2323 }
2324
2325 for (i, item) in items.into_iter().enumerate() {
2326 let selected = i == selected_index;
2327
2328 let fg = animate_color(
2329 format!("nr_fg_{}_{}", id, i),
2330 if selected {
2331 config.selected_icon_color
2332 } else {
2333 config.unselected_icon_color
2334 },
2335 spec,
2336 );
2337 let bg = animate_color(
2338 format!("nr_bg_{}_{}", id, i),
2339 if selected {
2340 config.selected_container_color
2341 } else {
2342 Color::TRANSPARENT
2343 },
2344 spec,
2345 );
2346
2347 let cb = item.on_click.clone();
2348
2349 item_views.push(
2350 Column(
2351 Modifier::new()
2352 .fill_max_width()
2353 .padding_values(PaddingValues {
2354 left: 4.0,
2355 right: 4.0,
2356 top: 4.0,
2357 bottom: 4.0,
2358 })
2359 .align_items(AlignItems::Center)
2360 .justify_content(JustifyContent::Center)
2361 .background(bg)
2362 .state_colors(StateColors {
2363 default: Color::TRANSPARENT,
2364 hovered: th.on_surface.with_alpha_f32(0.08),
2365 pressed: th.on_surface.with_alpha_f32(0.12),
2366 disabled: Color::TRANSPARENT,
2367 })
2368 .clip_rounded(config.item_radius)
2369 .clickable()
2370 .on_pointer_down(move |_| cb()),
2371 )
2372 .child((
2373 Stack(Modifier::new()).child((
2374 Box(Modifier::new().size(24.0, 24.0))
2375 .child(with_content_color(fg, move || item.icon)),
2376 item.badge
2377 .map(|b| {
2378 Box(Modifier::new()
2379 .absolute()
2380 .offset(None, None, None, Some(0.0)))
2381 .child(b)
2382 })
2383 .unwrap_or(Box(Modifier::new())),
2384 )),
2385 Box(Modifier::new().fill_max_width().height(4.0)),
2386 Text(item.label)
2387 .color(fg)
2388 .size(th.typography.label_medium)
2389 .single_line(),
2390 )),
2391 );
2392 }
2393
2394 Column(
2395 Modifier::new()
2396 .width(config.width)
2397 .fill_max_height()
2398 .background(config.container_color)
2399 .align_items(AlignItems::Center)
2400 .then(config.modifier),
2401 )
2402 .child((
2403 Column(Modifier::new()).with_children(top_children),
2404 Box(Modifier::new().flex_grow(1.0)).child(
2405 Column(
2406 Modifier::new()
2407 .fill_max_size()
2408 .justify_content(JustifyContent::SpaceBetween)
2409 .align_items(AlignItems::Center),
2410 )
2411 .with_children(item_views),
2412 ),
2413 ))
2414}
2415
2416pub struct SwipeToDismissState {
2418 anim: Rc<RefCell<AnimatedValue<f32>>>,
2419 dismiss_handled: Rc<RefCell<bool>>,
2422}
2423
2424impl Default for SwipeToDismissState {
2425 fn default() -> Self {
2426 Self::new()
2427 }
2428}
2429
2430impl SwipeToDismissState {
2431 pub fn new() -> Self {
2432 Self {
2433 anim: Rc::new(RefCell::new(AnimatedValue::new(
2434 0.0,
2435 AnimationSpec::spring_gentle(),
2436 ))),
2437 dismiss_handled: Rc::new(RefCell::new(true)),
2438 }
2439 }
2440
2441 pub fn offset(&self) -> f32 {
2444 let mut anim = self.anim.borrow_mut();
2445 if anim.update() {
2446 request_frame();
2447 }
2448 *anim.get()
2449 }
2450
2451 pub fn set_offset_instant(&self, off: f32) {
2453 self.anim.borrow_mut().snap_to(off);
2454 request_frame();
2455 }
2456
2457 pub fn is_dismissed(&self) -> bool {
2459 *self.anim.borrow().get() < -150.0
2460 }
2461
2462 pub fn dismiss(&self) {
2464 *self.dismiss_handled.borrow_mut() = false;
2465 self.anim.borrow_mut().set_target(-300.0);
2466 request_frame();
2467 }
2468
2469 pub fn dismiss_to(&self, offset: f32) {
2471 *self.dismiss_handled.borrow_mut() = false;
2472 self.anim.borrow_mut().set_target(-offset);
2473 request_frame();
2474 }
2475
2476 pub fn reset(&self) {
2478 *self.dismiss_handled.borrow_mut() = true;
2479 self.anim.borrow_mut().set_target(0.0);
2480 request_frame();
2481 }
2482
2483 fn try_handle_dismiss(&self, on_dismiss: &Option<Rc<dyn Fn()>>) {
2485 let anim = self.anim.borrow();
2486 if !anim.is_animating() && !*self.dismiss_handled.borrow() && *anim.get() < -150.0 {
2487 *self.dismiss_handled.borrow_mut() = true;
2488 if let Some(cb) = on_dismiss {
2489 cb();
2490 }
2491 }
2492 }
2493
2494 fn try_handle_dismiss_with_threshold(&self, on_dismiss: &Option<Rc<dyn Fn()>>, threshold: f32) {
2496 let anim = self.anim.borrow();
2497 if !anim.is_animating() && !*self.dismiss_handled.borrow() && *anim.get() < -threshold {
2498 *self.dismiss_handled.borrow_mut() = true;
2499 if let Some(cb) = on_dismiss {
2500 cb();
2501 }
2502 }
2503 }
2504}
2505
2506pub fn SwipeToDismiss(
2510 state: Rc<SwipeToDismissState>,
2511 on_dismiss: Option<Rc<dyn Fn()>>,
2512 background: View,
2513 content: View,
2514 modifier: Modifier,
2515 config: SwipeToDismissConfig,
2516) -> View {
2517 let offset = state.offset();
2518 state.try_handle_dismiss_with_threshold(&on_dismiss, config.dismiss_threshold);
2519
2520 let drag_start_x = remember_with_key("swipe_drag_start", || RefCell::new(None::<f32>));
2521 let drag_base = remember_with_key("swipe_drag_base", || RefCell::new(0.0f32));
2522
2523 let st = state.clone();
2524 let on_down = {
2525 let d = drag_start_x.clone();
2526 let base = drag_base.clone();
2527 move |e: PointerEvent| {
2528 *d.borrow_mut() = Some(e.position.x);
2529 *base.borrow_mut() = *st.anim.borrow().get();
2530 }
2531 };
2532
2533 let st = state.clone();
2534 let on_move = {
2535 let d = drag_start_x.clone();
2536 let base = drag_base.clone();
2537 move |e: PointerEvent| {
2538 if let Some(start) = *d.borrow() {
2539 let dx = e.position.x - start;
2540 st.set_offset_instant(*base.borrow() + dx);
2541 }
2542 }
2543 };
2544
2545 let st = state.clone();
2546 let on_up = {
2547 let d = drag_start_x.clone();
2548 let dt = config.dismiss_threshold;
2549 move |_e: PointerEvent| {
2550 *d.borrow_mut() = None;
2551 let off = *st.anim.borrow().get();
2552 if off > -dt * 0.333 {
2553 st.reset();
2554 } else {
2555 st.dismiss();
2556 }
2557 }
2558 };
2559
2560 let display_offset = offset.max(-config.dismissed_offset).min(0.0);
2561
2562 Stack(modifier.fill_max_width()).child((
2563 Box(Modifier::new().fill_max_size().absolute()).child(background),
2564 Box(Modifier::new()
2565 .fill_max_width()
2566 .translate(display_offset, 0.0)
2567 .on_pointer_down(on_down)
2568 .on_pointer_move(on_move)
2569 .on_pointer_up(on_up))
2570 .child(content),
2571 ))
2572}
2573
2574pub fn Carousel<T, F>(
2579 items: Vec<T>,
2580 item_width: f32,
2581 peek_amount: f32,
2582 modifier: Modifier,
2583 state: Rc<LazyRowState>,
2584 item_builder: F,
2585) -> View
2586where
2587 T: Clone + 'static,
2588 F: Fn(T, usize) -> View + 'static,
2589{
2590 let padded_modifier = modifier.clone().padding_values(PaddingValues {
2591 left: peek_amount,
2592 right: peek_amount,
2593 top: 0.0,
2594 bottom: 0.0,
2595 });
2596
2597 LazyRow(items, item_width, state, padded_modifier, item_builder)
2598}