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