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