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