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
1793pub fn SearchBar(
1807 state: Rc<SearchBarState>,
1808 input_field: View,
1809 modifier: Modifier,
1810 leading_icon: Option<View>,
1811 trailing_icon: Option<View>,
1812 config: SearchBarConfig,
1813) -> View {
1814 let th = theme();
1815 let colors = config.colors;
1816
1817 let mut bar_m = modifier
1818 .fill_max_width()
1819 .height(config.height)
1820 .state_elevation(StateElevation {
1821 default: config.tonal_elevation,
1822 hovered: th.elevation.level2,
1823 pressed: th.elevation.level3,
1824 disabled: 0.0,
1825 })
1826 .shadow(config.shadow_elevation, 0.0)
1827 .padding_values(config.content_padding)
1828 .on_key_event({
1829 let s = state.clone();
1830 move |ev| {
1831 if ev.key == Key::Escape && s.is_active() {
1832 s.deactivate();
1833 true
1834 } else {
1835 false
1836 }
1837 }
1838 })
1839 .on_focus_changed({
1840 let s = state.clone();
1841 move |focused| {
1842 if focused {
1843 s.activate();
1844 }
1845 }
1846 })
1847 .semantics(Semantics {
1848 role: Role::TextField,
1849 label: Some("Search".into()),
1850 focused: state.is_active(),
1851 enabled: true,
1852 selectable_group: false,
1853 })
1854 .background(colors.container_color)
1855 .clip_rounded(config.shape_radius)
1856 .then(track_collapsed_layout(&state));
1857
1858 bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, colors.container_color);
1859
1860 Box(bar_m).child(
1861 Row(Modifier::new()
1862 .fill_max_size()
1863 .align_items(AlignItems::CENTER))
1864 .child((
1865 leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
1866 Box(Modifier::new().width(8.0).fill_max_height()),
1867 input_field,
1868 trailing_icon.unwrap_or(Box(Modifier::new())),
1869 )),
1870 )
1871}
1872
1873pub fn SearchBarWithContent(
1882 input_field: View,
1883 expanded: bool,
1884 on_expanded_change: Rc<dyn Fn(bool)>,
1885 modifier: Modifier,
1886 leading_icon: Option<View>,
1887 trailing_icon: Option<View>,
1888 config: SearchBarConfig,
1889 content: View,
1890) -> View {
1891 let th = theme();
1892 let width = animate_f32(
1893 "sbwc_w",
1894 if expanded {
1895 config.expanded_width
1896 } else {
1897 config.collapsed_width
1898 },
1899 theme().motion.expand,
1900 );
1901
1902 let bar_bg = if expanded {
1903 config.colors.active_container_color
1904 } else {
1905 config.colors.container_color
1906 };
1907 let shape = if expanded {
1908 config.active_shape_radius
1909 } else {
1910 config.shape_radius
1911 };
1912
1913 let mut bar_m = modifier
1914 .clone()
1915 .width(width)
1916 .min_width(config.min_width)
1917 .max_width(config.max_width)
1918 .height(config.height)
1919 .shadow(config.shadow_elevation, 0.0)
1920 .padding_values(config.content_padding)
1921 .on_key_event({
1922 let cb = on_expanded_change.clone();
1923 move |ev| {
1924 if ev.key == Key::Escape {
1925 cb(false);
1926 true
1927 } else {
1928 false
1929 }
1930 }
1931 })
1932 .background(bar_bg)
1933 .clip_rounded(shape);
1934
1935 bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
1936
1937 let content_alpha = animate_f32("sbwc_a", if expanded { 1.0 } else { 0.0 }, th.motion.color);
1939
1940 let bar = Box(bar_m).child(
1941 Row(Modifier::new()
1942 .fill_max_size()
1943 .align_items(AlignItems::CENTER))
1944 .child((
1945 leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
1946 Box(Modifier::new().width(8.0).fill_max_height()),
1947 input_field,
1948 trailing_icon.unwrap_or(Box(Modifier::new())),
1949 )),
1950 );
1951
1952 let show_content = expanded || content_alpha > 0.01;
1953 if show_content || expanded {
1954 Stack(modifier).child((
1955 bar,
1956 Box(Modifier::new()
1957 .width(width)
1958 .max_height(SearchBarDefaults::DOCKED_HEIGHT)
1959 .alpha(content_alpha)
1960 .background(config.colors.container_color)
1961 .clip_rounded(th.shapes.extra_small))
1962 .child(content),
1963 ))
1964 } else {
1965 bar
1966 }
1967}
1968
1969pub fn DockedSearchBar(
1974 input_field: View,
1975 expanded: bool,
1976 on_expanded_change: Option<Rc<dyn Fn(bool)>>,
1977 modifier: Modifier,
1978 leading_icon: Option<View>,
1979 config: SearchBarConfig,
1980 content: View,
1981) -> View {
1982 let th = theme();
1983 let active = expanded;
1984 let colors = config.colors;
1985
1986 let content_target = if expanded {
1987 get_window_container_height() * 2.0 / 3.0
1988 } else {
1989 0.0
1990 };
1991 let content_height = animate_f32("docked_sh", content_target, theme().motion.expand);
1992 let content_alpha = animate_f32(
1993 "docked_sa",
1994 if expanded { 1.0 } else { 0.0 },
1995 theme().motion.color,
1996 );
1997 let bar_bg = if active {
1998 colors.active_container_color
1999 } else {
2000 colors.container_color
2001 };
2002
2003 let clear_btn = if active {
2004 Box(Modifier::new().size(24.0, 24.0).clickable().on_click({
2005 let cb = on_expanded_change.clone();
2006 move || {
2007 if let Some(ref cb) = cb {
2008 cb(false);
2009 }
2010 }
2011 }))
2012 .child(Text("✕").size(16.0).color(colors.placeholder_color))
2013 } else {
2014 Box(Modifier::new())
2015 };
2016
2017 let mut bar_m = modifier
2018 .z_index(1.0)
2019 .min_width(SearchBarDefaults::MIN_WIDTH)
2020 .height(config.height)
2021 .state_elevation(StateElevation {
2022 default: if active {
2023 th.elevation.level3
2024 } else {
2025 config.tonal_elevation
2026 },
2027 hovered: th.elevation.level2,
2028 pressed: th.elevation.level3,
2029 disabled: 0.0,
2030 })
2031 .shadow(config.shadow_elevation, 0.0)
2032 .padding_values(config.content_padding)
2033 .on_key_event({
2034 let cb = on_expanded_change.clone();
2035 move |ev| {
2036 if ev.key == Key::Escape {
2037 if let Some(ref cb) = cb {
2038 cb(false);
2039 }
2040 true
2041 } else {
2042 false
2043 }
2044 }
2045 })
2046 .background(bar_bg)
2047 .clip_rounded(config.shape_radius);
2048
2049 bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
2050
2051 let bar = Box(bar_m).child(
2052 Row(Modifier::new()
2053 .fill_max_size()
2054 .align_items(AlignItems::CENTER))
2055 .child((
2056 leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
2057 Box(Modifier::new().width(12.0).fill_max_height()),
2058 input_field,
2059 clear_btn,
2060 )),
2061 );
2062
2063 let show_content = expanded || content_height > 1.0;
2064 if show_content {
2065 Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
2066 bar,
2067 Box(Modifier::new()
2068 .min_width(SearchBarDefaults::MIN_WIDTH)
2069 .height(content_height)
2070 .alpha(content_alpha)
2071 .clip_rounded(th.shapes.small)
2072 .background(colors.container_color)
2073 .state_elevation(StateElevation {
2074 default: th.elevation.level3,
2075 hovered: th.elevation.level3,
2076 pressed: th.elevation.level3,
2077 disabled: 0.0,
2078 }))
2079 .child(
2080 Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
2081 Box(Modifier::new()
2082 .min_width(SearchBarDefaults::MIN_WIDTH)
2083 .height(1.0)
2084 .background(colors.divider_color)),
2085 content,
2086 )),
2087 ),
2088 ))
2089 } else {
2090 bar
2091 }
2092}
2093
2094use std::sync::Mutex;
2098static WINDOW_CONTAINER_HEIGHT: Mutex<f32> = Mutex::new(800.0);
2099
2100pub fn set_window_container_height(h: f32) {
2102 if let Ok(mut v) = WINDOW_CONTAINER_HEIGHT.lock() {
2103 *v = h;
2104 }
2105}
2106
2107fn get_window_container_height() -> f32 {
2108 WINDOW_CONTAINER_HEIGHT.lock().map(|v| *v).unwrap_or(800.0)
2109}
2110
2111pub fn ExpandedFullScreenSearchBar(
2115 state: Rc<SearchBarState>,
2116 overlay: OverlayHandle,
2117 input_field: View,
2118 modifier: Modifier,
2119 config: ExpandedFullScreenSearchBarConfig,
2120 content: View,
2121) -> View {
2122 state.expands_to_full_screen.set(true);
2124
2125 let overlay_id = remember_with_key("efs_oid", || signal(0u64));
2126 let current_content = remember_state_with_key("efs_cc", || Box(Modifier::new()));
2127 *current_content.borrow_mut() = content;
2128
2129 let progress = state.progress();
2130 let _content_alpha = state.content_progress();
2131
2132 let expanded = state.is_expanded();
2133 let visible = expanded || progress > 0.01;
2134
2135 if visible {
2136 if overlay_id.get() == 0 {
2137 let input_fr = FocusRequester::new();
2138 let builder: Rc<dyn Fn() -> View> = Rc::new({
2139 let state = state.clone();
2140 let modifier = modifier.clone();
2141 let input_field = input_field.clone();
2142 let current_content = current_content.clone();
2143 let config = config.clone();
2144 let input_fr = input_fr.clone();
2145 move || {
2146 let progress = state.progress();
2147 let content_alpha = state.content_progress();
2148 let alpha = progress.clamp(0.0, 1.0);
2149 let c_alpha = content_alpha.clamp(0.0, 1.0);
2150 let th = theme();
2151 let content = current_content.borrow().clone();
2152
2153 let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
2155 .child(input_field.clone());
2156 input_fr.request_focus();
2157
2158 let header = Box(modifier
2159 .clone()
2160 .fill_max_width()
2161 .height(SearchBarDefaults::HEIGHT)
2162 .padding_values(PaddingValues {
2163 left: 16.0,
2164 right: 16.0,
2165 top: 0.0,
2166 bottom: 0.0,
2167 })
2168 .background(config.colors.container_color)
2169 .alpha(alpha))
2170 .child(inp);
2171
2172 let body = Box(Modifier::new()
2173 .fill_max_width()
2174 .flex_grow(1.0)
2175 .alpha(c_alpha)
2176 .background(th.surface))
2177 .child(content);
2178
2179 let insets = config.window_insets;
2180 let full = Column(Modifier::new().fill_max_size().padding_values(
2181 PaddingValues {
2182 left: insets.left,
2183 right: insets.right,
2184 top: insets.top,
2185 bottom: insets.bottom,
2186 },
2187 ))
2188 .child((header, body));
2189
2190 let scrim = Box(Modifier::new()
2191 .fill_max_size()
2192 .background(config.scrim_color.with_alpha((85.0 * alpha) as u8))
2193 .on_click({
2194 let s = state.clone();
2195 move || s.collapse()
2196 }));
2197
2198 ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, full))
2199 }
2200 });
2201
2202 let id = overlay.show_entry(builder, 900.0, false);
2203 overlay_id.set(id);
2204 }
2205 } else {
2206 let prev = overlay_id.get();
2207 if prev != 0 {
2208 let _ = overlay.dismiss(prev);
2209 overlay_id.set(0);
2210 }
2211 }
2212
2213 Box(Modifier::new())
2214}
2215
2216pub fn ExpandedDockedSearchBar(
2220 state: Rc<SearchBarState>,
2221 overlay: OverlayHandle,
2222 input_field: View,
2223 modifier: Modifier,
2224 config: ExpandedDockedSearchBarConfig,
2225 content: View,
2226) -> View {
2227 state.expands_to_full_screen.set(false);
2229
2230 let overlay_id = remember_with_key("eds_oid", || signal(0u64));
2231 let current_content = remember_state_with_key("eds_cc", || Box(Modifier::new()));
2232 *current_content.borrow_mut() = content;
2233
2234 let progress = state.progress();
2235 let _content_alpha = state.content_progress();
2236 let expanded = state.is_expanded();
2237 let visible = expanded || progress > 0.01;
2238
2239 if visible {
2240 if overlay_id.get() == 0 {
2241 let input_fr = FocusRequester::new();
2242 let builder: Rc<dyn Fn() -> View> = Rc::new({
2243 let state = state.clone();
2244 let modifier = modifier.clone();
2245 let input_field = input_field.clone();
2246 let current_content = current_content.clone();
2247 let config = config.clone();
2248 let input_fr = input_fr.clone();
2249 move || {
2250 let progress = state.progress();
2251 let content_alpha = state.content_progress();
2252 let alpha = progress.clamp(0.0, 1.0);
2253 let c_alpha = content_alpha.clamp(0.0, 1.0);
2254 let th = theme();
2255 let content = current_content.borrow().clone();
2256 let (_cx, _cy, _cw, _ch) = state.collapsed_layout_rect.get();
2257
2258 let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
2259 .child(input_field.clone());
2260 input_fr.request_focus();
2261
2262 let header = Box(modifier
2263 .clone()
2264 .fill_max_width()
2265 .height(SearchBarDefaults::HEIGHT)
2266 .alpha(alpha)
2267 .background(config.colors.container_color)
2268 .clip_rounded(config.shape_radius)
2269 .state_elevation(StateElevation {
2270 default: th.elevation.level3,
2271 hovered: th.elevation.level2,
2272 pressed: th.elevation.level3,
2273 disabled: 0.0,
2274 }))
2275 .child(inp);
2276
2277 let dropdown = Box(Modifier::new()
2278 .fill_max_width()
2279 .max_height(get_window_container_height() * 2.0 / 3.0)
2280 .alpha(c_alpha)
2281 .clip_rounded(config.dropdown_shape_radius)
2282 .background(config.colors.container_color)
2283 .state_elevation(StateElevation {
2284 default: th.elevation.level3,
2285 hovered: th.elevation.level3,
2286 pressed: th.elevation.level3,
2287 disabled: 0.0,
2288 }))
2289 .child(
2290 Column(Modifier::new().fill_max_width()).child((
2291 Box(Modifier::new()
2292 .fill_max_width()
2293 .height(1.0)
2294 .background(config.colors.divider_color)),
2295 content,
2296 )),
2297 );
2298
2299 let col = Column(Modifier::new().fill_max_width().padding_values(
2300 PaddingValues {
2301 left: _cx.max(16.0),
2302 right: 16.0,
2303 top: _cy + _ch + config.dropdown_gap_size,
2304 bottom: 0.0,
2305 },
2306 ))
2307 .child((header, dropdown));
2308
2309 let scrim = Box(Modifier::new()
2310 .fill_max_size()
2311 .background(config.dropdown_scrim_color)
2312 .on_click({
2313 let s = state.clone();
2314 move || s.collapse()
2315 }));
2316
2317 ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, col))
2318 }
2319 });
2320
2321 let id = overlay.show_entry(builder, 900.0, false);
2322 overlay_id.set(id);
2323 }
2324 } else {
2325 let prev = overlay_id.get();
2326 if prev != 0 {
2327 let _ = overlay.dismiss(prev);
2328 overlay_id.set(0);
2329 }
2330 }
2331
2332 Box(Modifier::new())
2333}
2334
2335pub fn AppBarWithSearch(
2339 state: Rc<SearchBarState>,
2340 input_field: View,
2341 navigation_icon: Option<View>,
2342 actions: Option<Vec<View>>,
2343 config: AppBarWithSearchConfig,
2344) -> View {
2345 let bg = config.colors.search_bar_container(config.scroll_fraction);
2346 let app_bar_bg = config.colors.app_bar_container(config.scroll_fraction);
2347
2348 let insets = config.window_insets;
2349
2350 let is_container_transparent = app_bar_bg.3 == 0;
2352 let tonal_elevation = if is_container_transparent {
2353 0.0
2354 } else {
2355 config.tonal_elevation
2356 };
2357 let shadow_elevation = if is_container_transparent {
2358 0.0
2359 } else {
2360 config.shadow_elevation
2361 };
2362
2363 let hide_collapsed = state.expands_to_full_screen.get() && state.is_expanded();
2365 let collapsed_alpha = if hide_collapsed { 0.0 } else { 1.0 };
2366
2367 let bar_m = Modifier::new()
2368 .fill_max_width()
2369 .height(config.height + insets.top)
2370 .translate(0.0, config.scroll_offset)
2371 .background(app_bar_bg)
2372 .semantics(Semantics::new(Role::Container).with_selectable_group());
2373
2374 let row = Row(Modifier::new()
2375 .fill_max_size()
2376 .align_items(AlignItems::CENTER)
2377 .padding_values(PaddingValues {
2378 left: config.content_padding.left + insets.left,
2379 right: config.content_padding.right + insets.right,
2380 top: insets.top,
2381 bottom: 0.0,
2382 }))
2383 .child({
2384 let mut children: Vec<View> = Vec::new();
2385 if let Some(nav) = navigation_icon {
2386 children.push(nav);
2387 children.push(Box(Modifier::new().width(4.0)));
2388 }
2389 let sb_colors = &config.colors.search_bar_colors;
2391 let collapsed_bar = SearchBar(
2392 state.clone(),
2393 input_field,
2394 Modifier::new().flex_grow(1.0).alpha(collapsed_alpha),
2395 None,
2396 None,
2397 SearchBarConfig {
2398 height: config.height - 8.0,
2399 shape_radius: config.shape_radius,
2400 colors: SearchBarColors {
2401 container_color: bg,
2402 active_container_color: bg,
2403 divider_color: sb_colors.divider_color,
2404 content_color: sb_colors.content_color,
2405 placeholder_color: sb_colors.placeholder_color,
2406 scrim_color: sb_colors.scrim_color,
2407 },
2408 tonal_elevation,
2409 shadow_elevation,
2410 ..Default::default()
2411 },
2412 );
2413 children.push(Box(Modifier::new().flex_grow(1.0)).child(collapsed_bar));
2414 if let Some(acts) = actions {
2415 children.push(Spacer());
2416 for a in acts {
2417 children.push(a);
2418 }
2419 }
2420 children
2421 });
2422
2423 Box(bar_m.shadow(shadow_elevation, 0.0)).child(row)
2424}
2425
2426pub struct SheetState {
2428 visible: Signal<bool>,
2429 drag_offset: Signal<f32>,
2430 peek_height: Signal<f32>,
2431}
2432
2433impl SheetState {
2434 pub fn new(peek_height: f32) -> Self {
2435 Self {
2436 visible: signal(false),
2437 drag_offset: signal(0.0),
2438 peek_height: signal(peek_height),
2439 }
2440 }
2441
2442 pub fn is_visible(&self) -> bool {
2443 self.visible.get()
2444 }
2445
2446 pub fn show(&self) {
2447 self.visible.set(true);
2448 }
2449
2450 pub fn dismiss(&self) {
2451 self.visible.set(false);
2452 self.drag_offset.set(0.0);
2453 }
2454
2455 pub fn set_peek_height(&self, h: f32) {
2456 self.peek_height.set(h);
2457 }
2458}
2459
2460pub fn ModalBottomSheet(
2465 state: Rc<SheetState>,
2466 overlay: OverlayHandle,
2467 modifier: Modifier,
2468 content: View,
2469 config: BottomSheetConfig,
2470) -> View {
2471 let th = theme();
2472 let peek_h = state.peek_height.get().max(config.peek_height);
2473 let anim_distance = peek_h.max(48.0).max(400.0);
2474 let overlay_id = remember_with_key("mbs_oid", || signal(0u64));
2475
2476 let drag_anchor_y: Rc<RefCell<f32>> = remember_state_with_key("mbs_drag_y", || 0.0);
2478 let offset_at_drag_start: Rc<RefCell<f32>> = remember_state_with_key("mbs_drag_base", || 0.0);
2479 let is_dragging: Rc<RefCell<bool>> = remember_state_with_key("mbs_drag", || false);
2480
2481 let anim = remember_state_with_key("mbs_anim", || {
2483 AnimatedValue::new(anim_distance, theme().motion.spring)
2484 });
2485 let last_target = remember_state_with_key("mbs_anim_target", || f32::NAN);
2486 let anim_target = if state.is_visible() {
2487 0.0
2488 } else {
2489 anim_distance
2490 };
2491
2492 {
2493 let mut a = anim.borrow_mut();
2494 let mut lt = last_target.borrow_mut();
2495 if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
2496 if state.is_visible() {
2497 a.set_spec(th.motion.spring);
2498 } else {
2499 a.set_spec(AnimationSpec::fast());
2500 }
2501 a.set_target(anim_target);
2502 *lt = anim_target;
2503 }
2504 drop(lt);
2505 let still_animating = a.update();
2506 if still_animating {
2507 request_frame();
2508 }
2509 }
2510
2511 let offset = *anim.borrow().get();
2512 let sheet_visible = state.is_visible() || offset < anim_distance - 10.0;
2513
2514 if sheet_visible {
2515 if overlay_id.get() == 0 {
2516 let builder: Rc<dyn Fn() -> View> = Rc::new({
2517 let state = state.clone();
2518 let anim = anim.clone();
2519 let modifier = modifier.clone();
2520 let content = content.clone();
2521 let drag_anchor_y = drag_anchor_y.clone();
2522 let offset_at_drag_start = offset_at_drag_start.clone();
2523 let is_dragging = is_dragging.clone();
2524 let anim_distance = anim_distance;
2525 move || {
2526 let off = *anim.borrow().get();
2527
2528 let sheet_body = Box(modifier
2529 .clone()
2530 .fill_max_width()
2531 .max_width(dp_to_px(config.max_width))
2532 .translate(0.0, off)
2533 .background(config.container_color)
2534 .clip_rounded(config.shape_radius)
2535 .on_pointer_down({
2536 let anim = anim.clone();
2537 let drag_anchor_y = drag_anchor_y.clone();
2538 let offset_at_drag_start = offset_at_drag_start.clone();
2539 let is_dragging = is_dragging.clone();
2540 move |ev| {
2541 *drag_anchor_y.borrow_mut() = ev.position.y;
2542 *offset_at_drag_start.borrow_mut() = *anim.borrow().get();
2543 *is_dragging.borrow_mut() = true;
2544 }
2545 })
2546 .on_pointer_move({
2547 let anim = anim.clone();
2548 let drag_anchor_y = drag_anchor_y.clone();
2549 let offset_at_drag_start = offset_at_drag_start.clone();
2550 let is_dragging = is_dragging.clone();
2551 move |ev| {
2552 if !*is_dragging.borrow() {
2553 return;
2554 }
2555 let delta = ev.position.y - *drag_anchor_y.borrow();
2556 let start_off = *offset_at_drag_start.borrow();
2557 let total = (start_off + delta).max(0.0);
2558 anim.borrow_mut().snap_to(total);
2559 request_frame();
2560 }
2561 })
2562 .on_pointer_up({
2563 let anim = anim.clone();
2564 let is_dragging = is_dragging.clone();
2565 let state = state.clone();
2566 let anim_distance = anim_distance;
2567 move |_| {
2568 *is_dragging.borrow_mut() = false;
2569 let current_off = *anim.borrow().get();
2570 let threshold = anim_distance * 0.3;
2571 if current_off > threshold {
2572 anim.borrow_mut().set_target(anim_distance);
2573 state.dismiss();
2574 } else {
2575 anim.borrow_mut().set_target(0.0);
2576 }
2577 }
2578 }))
2579 .child(
2580 Column(Modifier::new().fill_max_width()).child((
2581 Row(Modifier::new()
2582 .fill_max_width()
2583 .justify_content(JustifyContent::CENTER))
2584 .child(Box(Modifier::new()
2585 .margin_vertical(22.0)
2586 .width(config.drag_handle_width)
2587 .height(config.drag_handle_height)
2588 .background(config.drag_handle_color)
2589 .clip_rounded(2.0))),
2590 content.clone(),
2591 )),
2592 );
2593
2594 let sheet = Box(Modifier::new()
2595 .fill_max_size()
2596 .justify_content(JustifyContent::CENTER)
2597 .align_items(AlignItems::FLEX_END))
2598 .child(sheet_body);
2599
2600 let scrim_alpha = if state.is_visible() {
2601 config.scrim_color.3
2602 } else {
2603 let t = (off / anim_distance).clamp(0.0, 1.0);
2604 (config.scrim_color.3 as f32 * (1.0 - t)) as u8
2605 };
2606 let scrim = Box(Modifier::new()
2607 .fill_max_size()
2608 .background(config.scrim_color.with_alpha(scrim_alpha))
2609 .on_pointer_down({
2610 let s = state.clone();
2611 move |_| s.dismiss()
2612 }));
2613
2614 ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, sheet))
2615 }
2616 });
2617
2618 let id = overlay.show_entry(builder, 900.0, false);
2619 overlay_id.set(id);
2620 }
2621 } else {
2622 let prev = overlay_id.get();
2623 if prev != 0 {
2624 let _ = overlay.dismiss(prev);
2625 overlay_id.set(0);
2626 }
2627 }
2628
2629 Box(Modifier::new())
2630}
2631
2632pub struct PullToRefreshState {
2638 refreshing: Signal<bool>,
2639 scroll_state: RefCell<Option<Rc<repose_ui::scroll::ScrollState>>>,
2640 threshold: f32,
2641 triggered: Cell<bool>,
2642}
2643
2644impl Default for PullToRefreshState {
2645 fn default() -> Self {
2646 Self::new()
2647 }
2648}
2649
2650impl PullToRefreshState {
2651 pub fn new() -> Self {
2652 Self {
2653 refreshing: signal(false),
2654 scroll_state: RefCell::new(None),
2655 threshold: 64.0,
2656 triggered: Cell::new(false),
2657 }
2658 }
2659
2660 pub fn set_scroll_state(&self, state: Rc<repose_ui::scroll::ScrollState>) {
2663 *self.scroll_state.borrow_mut() = Some(state);
2664 }
2665
2666 pub fn set_threshold(&mut self, px: f32) {
2668 self.threshold = px;
2669 }
2670
2671 pub fn is_refreshing(&self) -> bool {
2672 self.refreshing.get()
2673 }
2674
2675 pub fn set_refreshing(&self, v: bool) {
2676 self.refreshing.set(v);
2677 if !v && let Some(sc) = self.scroll_state.borrow().as_ref() {
2678 sc.set_overscroll(0.0);
2679 }
2680 }
2681
2682 pub fn pull_offset(&self) -> f32 {
2684 if let Some(sc) = self.scroll_state.borrow().as_ref() {
2685 let os = sc.overscroll_offset();
2686 if os < 0.0 { -os } else { 0.0 }
2687 } else {
2688 0.0
2689 }
2690 }
2691}
2692
2693pub fn PullToRefresh(
2702 state: Rc<PullToRefreshState>,
2703 modifier: Modifier,
2704 on_refresh: Rc<dyn Fn()>,
2705 content: View,
2706 config: PullToRefreshConfig,
2707) -> View {
2708 let pull = state.pull_offset();
2709 let refreshing = state.is_refreshing();
2710 let threshold = config.threshold;
2711
2712 if state.triggered.get() && !refreshing && pull < threshold {
2713 state.triggered.set(false);
2714 }
2715
2716 if !refreshing && !state.triggered.get() && pull >= threshold {
2717 state.triggered.set(true);
2718 state.refreshing.set(true);
2719 (on_refresh)();
2720 }
2721
2722 let frac_key = format!("ptr_frac_{}", Rc::as_ptr(&state) as u64);
2723 let raw_frac = if refreshing {
2724 1.0
2725 } else if pull > 0.0 {
2726 pull / threshold
2727 } else {
2728 0.0
2729 };
2730 let distance_fraction = animate_f32_from(frac_key, 0.0, raw_frac, theme().motion.color);
2731
2732 let adjusted_percent = (distance_fraction.min(1.0) - 0.4).max(0.0) * 5.0 / 3.0;
2733 let overshoot_percent = (distance_fraction - 1.0).max(0.0);
2734 let linear_tension = overshoot_percent.min(2.0);
2735 let tension_percent = linear_tension - linear_tension.powi(2) / 4.0;
2736 let rotation_turns = (-0.25 + 0.4 * adjusted_percent + tension_percent) * 0.5;
2737 let spinner_rotation_rad = rotation_turns * std::f32::consts::TAU;
2739
2740 let indicator_h = distance_fraction * threshold;
2742 let comp_scale = adjusted_percent.min(1.0);
2743 let icon_size = if refreshing {
2744 24.0
2745 } else {
2746 (16.0 + comp_scale * 8.0).min(24.0)
2747 };
2748 let rotation = if refreshing {
2749 animate_f32_from(
2750 "ptr_spin",
2751 0.0,
2752 std::f32::consts::TAU,
2753 AnimationSpec::tween(Duration::from_millis(1000), Easing::Linear)
2754 .repeated(RepeatableSpec::infinite()),
2755 )
2756 } else {
2757 spinner_rotation_rad
2758 };
2759 let alpha = if refreshing {
2760 1.0
2761 } else if distance_fraction >= 1.0 {
2762 1.0
2763 } else {
2764 0.3
2765 };
2766 Column(modifier.align_items(config.content_alignment)).child((
2767 if distance_fraction > 0.01 {
2768 Box(Modifier::new()
2769 .fill_max_width()
2770 .height(indicator_h)
2771 .align_items(AlignItems::CENTER)
2772 .justify_content(JustifyContent::CENTER))
2773 .child(
2774 Box(Modifier::new()
2775 .size(icon_size, icon_size)
2776 .translate(icon_size * 0.5, icon_size * 0.5)
2777 .rotate(rotation)
2778 .translate(-icon_size * 0.5, -icon_size * 0.5))
2779 .child(if refreshing {
2780 Icon(Symbol::new("refresh", '\u{E5D5}'))
2781 .size(24.0)
2782 .color(config.indicator_color)
2783 } else {
2784 Icon(Symbol::new("arrow_downward", '\u{E5DB}'))
2785 .size(icon_size)
2786 .color(config.indicator_color.with_alpha_f32(alpha))
2787 }),
2788 )
2789 } else {
2790 Box(Modifier::new())
2791 },
2792 content,
2793 ))
2794}
2795
2796pub struct DatePickerState {
2798 pub year: Signal<i32>,
2799 pub month: Signal<u32>, pub day: Signal<u32>,
2801}
2802
2803impl DatePickerState {
2804 pub fn new(year: i32, month: u32, day: u32) -> Self {
2805 Self {
2806 year: signal(year),
2807 month: signal(month.clamp(1, 12)),
2808 day: signal(day.clamp(1, 31)),
2809 }
2810 }
2811
2812 pub fn selected_date(&self) -> (i32, u32, u32) {
2813 (self.year.get(), self.month.get(), self.day.get())
2814 }
2815}
2816
2817fn days_in_month(year: i32, month: u32) -> u32 {
2818 match month {
2819 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
2820 4 | 6 | 9 | 11 => 30,
2821 2 => {
2822 if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
2823 29
2824 } else {
2825 28
2826 }
2827 }
2828 _ => 30,
2829 }
2830}
2831
2832fn first_day_of_month(year: i32, month: u32) -> u32 {
2835 let m = month as i32;
2836 let (y, adj_m) = if m <= 2 {
2837 (year - 1, m + 12)
2838 } else {
2839 (year, m)
2840 };
2841 let k = y % 100;
2842 let j = y / 100;
2843 let h = (1 + (13 * (adj_m + 1)) / 5 + k + k / 4 + j / 4 + 5 * j) % 7;
2844 ((h + 5) % 7) as u32
2846}
2847
2848struct ReposeDate {
2850 year: i32,
2851 month: u32,
2852 day: u32,
2853}
2854
2855impl ReposeDate {
2856 fn now() -> Self {
2858 let duration = web_time::SystemTime::now()
2859 .duration_since(web_time::UNIX_EPOCH)
2860 .unwrap_or_default();
2861 let days = (duration.as_secs() / 86_400) as i64;
2862 let z = days + 719468;
2864 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
2865 let doe = (z - era * 146_097) as u64;
2866 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
2867 let y = (yoe as i64) + era * 400;
2868 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2869 let mp = (5 * doy + 2) / 153;
2870 let d = doy - (153 * mp + 2) / 5 + 1;
2871 let m = if mp < 10 { mp + 3 } else { mp - 9 };
2872 let y = if m <= 2 { y + 1 } else { y };
2873 Self {
2874 year: y as i32,
2875 month: m as u32,
2876 day: d as u32,
2877 }
2878 }
2879}
2880
2881const MONTH_NAMES: [&str; 12] = [
2882 "January",
2883 "February",
2884 "March",
2885 "April",
2886 "May",
2887 "June",
2888 "July",
2889 "August",
2890 "September",
2891 "October",
2892 "November",
2893 "December",
2894];
2895
2896const DOW_HEADERS: [&str; 7] = ["M", "T", "W", "T", "F", "S", "S"];
2897
2898#[derive(Clone)]
2900pub struct DatePickerColors {
2901 pub container_color: Color,
2902 pub header_color: Color,
2903 pub weekday_color: Color,
2904 pub day_color: Color,
2905 pub selected_day_color: Color,
2906 pub selected_day_container_color: Color,
2907 pub today_content_color: Color,
2908 pub today_border_color: Color,
2909 pub navigation_color: Color,
2910 pub year_selected_container_color: Color,
2911 pub year_selected_content_color: Color,
2912 pub year_unselected_content_color: Color,
2913}
2914
2915impl Default for DatePickerColors {
2916 fn default() -> Self {
2917 Self {
2918 container_color: DatePickerDefaults::container_color(),
2919 header_color: DatePickerDefaults::header_color(),
2920 weekday_color: DatePickerDefaults::weekday_color(),
2921 day_color: DatePickerDefaults::day_color(),
2922 selected_day_color: DatePickerDefaults::selected_day_color(),
2923 selected_day_container_color: DatePickerDefaults::selected_day_container_color(),
2924 today_content_color: DatePickerDefaults::today_content_color(),
2925 today_border_color: DatePickerDefaults::today_border_color(),
2926 navigation_color: DatePickerDefaults::header_color(),
2927 year_selected_container_color: DatePickerDefaults::year_selected_container_color(),
2928 year_selected_content_color: DatePickerDefaults::year_selected_content_color(),
2929 year_unselected_content_color: DatePickerDefaults::year_unselected_content_color(),
2930 }
2931 }
2932}
2933
2934#[derive(Clone)]
2936pub struct DatePickerConfig {
2937 pub modifier: Modifier,
2938 pub colors: DatePickerColors,
2939 pub show_mode_toggle: bool,
2940}
2941
2942impl Default for DatePickerConfig {
2943 fn default() -> Self {
2944 Self {
2945 modifier: Modifier::new(),
2946 colors: DatePickerColors::default(),
2947 show_mode_toggle: true,
2948 }
2949 }
2950}
2951
2952pub fn DatePicker(
2955 state: Rc<DatePickerState>,
2956 on_confirm: Rc<dyn Fn(i32, u32, u32)>,
2957 on_dismiss: Rc<dyn Fn()>,
2958 config: DatePickerConfig,
2959) -> View {
2960 let th = theme();
2961 let (year, month, day) = state.selected_date();
2962 let dim = days_in_month(year, month);
2963 let start_dow = first_day_of_month(year, month);
2964
2965 let prev_year = {
2967 let s = state.clone();
2968 move || {
2969 s.year.set(s.year.get() - 1);
2970 let d = days_in_month(s.year.get(), s.month.get());
2971 if s.day.get() > d {
2972 s.day.set(d);
2973 }
2974 }
2975 };
2976 let next_year = {
2977 let s = state.clone();
2978 move || {
2979 s.year.set(s.year.get() + 1);
2980 let d = days_in_month(s.year.get(), s.month.get());
2981 if s.day.get() > d {
2982 s.day.set(d);
2983 }
2984 }
2985 };
2986
2987 let prev_month = {
2988 let s = state.clone();
2989 move || {
2990 if s.month.get() == 1 {
2991 s.year.set(s.year.get() - 1);
2992 s.month.set(12);
2993 } else {
2994 s.month.set(s.month.get() - 1);
2995 }
2996 let d = days_in_month(s.year.get(), s.month.get());
2997 if s.day.get() > d {
2998 s.day.set(d);
2999 }
3000 }
3001 };
3002
3003 let next_month = {
3004 let s = state.clone();
3005 move || {
3006 if s.month.get() == 12 {
3007 s.year.set(s.year.get() + 1);
3008 s.month.set(1);
3009 } else {
3010 s.month.set(s.month.get() + 1);
3011 }
3012 let d = days_in_month(s.year.get(), s.month.get());
3013 if s.day.get() > d {
3014 s.day.set(d);
3015 }
3016 }
3017 };
3018
3019 let now = ReposeDate::now();
3021 let today = (now.year, now.month, now.day);
3022
3023 Column(config.modifier.padding(16.0)).child((
3024 Row(Modifier::new()
3026 .fill_max_width()
3027 .align_items(AlignItems::CENTER))
3028 .child((
3029 IconButton(
3030 Box(Modifier::new())
3031 .child(Text("â—€").color(config.colors.navigation_color).size(16.0)),
3032 prev_month,
3033 IconButtonConfig::default(),
3034 ),
3035 Spacer(),
3036 Column(Modifier::new().align_items(AlignItems::CENTER)).child((
3037 Text(MONTH_NAMES[(month - 1) as usize].to_string())
3038 .size(th.typography.title_medium)
3039 .color(config.colors.header_color),
3040 Row(Modifier::new().gap(8.0).align_items(AlignItems::CENTER)).child((
3041 IconButton(
3042 Box(Modifier::new())
3043 .child(Text("‹").color(config.colors.navigation_color).size(14.0)),
3044 prev_year,
3045 IconButtonConfig::default(),
3046 ),
3047 Text(year.to_string())
3048 .size(th.typography.body_small)
3049 .color(th.on_surface_variant),
3050 IconButton(
3051 Box(Modifier::new())
3052 .child(Text("›").color(config.colors.navigation_color).size(14.0)),
3053 next_year,
3054 IconButtonConfig::default(),
3055 ),
3056 )),
3057 )),
3058 Spacer(),
3059 IconButton(
3060 Box(Modifier::new())
3061 .child(Text("â–¶").color(config.colors.navigation_color).size(16.0)),
3062 next_month,
3063 IconButtonConfig::default(),
3064 ),
3065 )),
3066 Box(Modifier::new().fill_max_width().height(12.0)),
3067 Column(Modifier::new()).child({
3069 let mut rows: Vec<View> = Vec::new();
3070 let dow_headers: Vec<View> = DOW_HEADERS
3072 .iter()
3073 .map(|d| {
3074 Box(Modifier::new()
3075 .width(40.0)
3076 .height(40.0)
3077 .align_items(AlignItems::CENTER)
3078 .justify_content(JustifyContent::CENTER))
3079 .child(
3080 Text(d.to_string())
3081 .size(th.typography.label_small)
3082 .color(config.colors.weekday_color),
3083 )
3084 })
3085 .collect();
3086 rows.push(Row(Modifier::new()).with_children(dow_headers));
3087
3088 let total_cells = start_dow + dim;
3090 let num_rows = total_cells.div_ceil(7).min(6);
3091 for w in 0..num_rows {
3092 let mut week: Vec<View> = Vec::new();
3093 for d in 0..7 {
3094 let cell_idx = w * 7 + d;
3095 if cell_idx < start_dow {
3096 week.push(Box(Modifier::new().width(40.0).height(40.0)));
3097 } else {
3098 let day_num = (cell_idx - start_dow + 1) as i32;
3099 if day_num <= dim as i32 {
3100 let is_selected = day_num == day as i32;
3101 let is_today =
3102 today.0 == year && today.1 == month && today.2 == day_num as u32;
3103 let s = state.clone();
3104 week.push(
3105 Box(Modifier::new()
3106 .width(40.0)
3107 .height(40.0)
3108 .background(if is_selected {
3109 config.colors.selected_day_container_color
3110 } else {
3111 Color::TRANSPARENT
3112 })
3113 .clip_rounded(20.0)
3114 .align_items(AlignItems::CENTER)
3115 .justify_content(JustifyContent::CENTER)
3116 .clickable()
3117 .on_click(move || {
3118 s.day.set(day_num as u32);
3119 }))
3120 .child({
3121 let mut t = Text(day_num.to_string())
3122 .size(th.typography.body_medium)
3123 .color(if is_selected {
3124 config.colors.selected_day_color
3125 } else {
3126 config.colors.day_color
3127 });
3128 if is_today && !is_selected {
3129 t = t.modifier(Modifier::new().border(
3130 1.0,
3131 config.colors.today_border_color,
3132 10.0,
3133 ));
3134 }
3135 t
3136 }),
3137 );
3138 } else {
3139 week.push(Box(Modifier::new().width(40.0).height(40.0)));
3140 }
3141 }
3142 }
3143 rows.push(Row(Modifier::new()).with_children(week));
3144 }
3145 rows
3146 }),
3147 Box(Modifier::new().fill_max_width().height(12.0)),
3148 Row(Modifier::new()
3150 .fill_max_width()
3151 .justify_content(JustifyContent::END)
3152 .gap(8.0))
3153 .child((
3154 TextButton(
3155 Modifier::new(),
3156 {
3157 let on_dismiss = on_dismiss.clone();
3158 move || (on_dismiss)()
3159 },
3160 ButtonConfig::default(),
3161 || Text("Cancel").size(14.0),
3162 ),
3163 Button(
3164 Modifier::new(),
3165 {
3166 let on_confirm = on_confirm.clone();
3167 let s = state.clone();
3168 move || {
3169 let (y, m, d) = s.selected_date();
3170 on_confirm(y, m, d);
3171 }
3172 },
3173 ButtonConfig::default(),
3174 || Text("OK").size(14.0),
3175 ),
3176 )),
3177 ))
3178}
3179
3180pub struct TimePickerState {
3182 pub hour: Signal<u32>,
3183 pub minute: Signal<u32>,
3184 pub is_am: Signal<bool>,
3185}
3186
3187impl TimePickerState {
3188 pub fn new(hour: u32, minute: u32) -> Self {
3189 let h = hour % 12;
3190 let am = hour < 12;
3191 Self {
3192 hour: signal(if h == 0 { 12 } else { h }),
3193 minute: signal(minute.min(59)),
3194 is_am: signal(am),
3195 }
3196 }
3197
3198 pub fn selected_time(&self) -> (u32, u32) {
3199 let mut h = self.hour.get();
3200 if !self.is_am.get() {
3201 h = (h % 12) + 12;
3202 } else if h == 12 {
3203 h = 0;
3204 }
3205 (h, self.minute.get())
3206 }
3207}
3208
3209#[derive(Clone, Copy, PartialEq, Debug)]
3211pub enum TimePickerLayoutType {
3212 Horizontal,
3213 Vertical,
3214}
3215
3216#[derive(Clone)]
3218pub struct TimePickerColors {
3219 pub clock_dial_color: Color,
3220 pub clock_dial_selected_content_color: Color,
3221 pub clock_dial_unselected_content_color: Color,
3222 pub selector_color: Color,
3223 pub container_color: Color,
3224 pub period_selector_border_color: Color,
3225 pub period_selector_selected_container_color: Color,
3226 pub period_selector_unselected_container_color: Color,
3227 pub period_selector_selected_content_color: Color,
3228 pub period_selector_unselected_content_color: Color,
3229 pub time_selector_selected_container_color: Color,
3230 pub time_selector_unselected_container_color: Color,
3231 pub time_selector_selected_content_color: Color,
3232 pub time_selector_unselected_content_color: Color,
3233}
3234
3235impl Default for TimePickerColors {
3236 fn default() -> Self {
3237 Self {
3238 clock_dial_color: TimePickerDefaults::clock_dial_color(),
3239 clock_dial_selected_content_color:
3240 TimePickerDefaults::clock_dial_selected_content_color(),
3241 clock_dial_unselected_content_color:
3242 TimePickerDefaults::clock_dial_unselected_content_color(),
3243 selector_color: TimePickerDefaults::selector_color(),
3244 container_color: TimePickerDefaults::container_color(),
3245 period_selector_border_color: TimePickerDefaults::period_selector_border_color(),
3246 period_selector_selected_container_color:
3247 TimePickerDefaults::period_selector_selected_container_color(),
3248 period_selector_unselected_container_color:
3249 TimePickerDefaults::period_selector_unselected_container_color(),
3250 period_selector_selected_content_color:
3251 TimePickerDefaults::period_selector_selected_content_color(),
3252 period_selector_unselected_content_color:
3253 TimePickerDefaults::period_selector_unselected_content_color(),
3254 time_selector_selected_container_color:
3255 TimePickerDefaults::time_selector_selected_container_color(),
3256 time_selector_unselected_container_color:
3257 TimePickerDefaults::time_selector_unselected_container_color(),
3258 time_selector_selected_content_color:
3259 TimePickerDefaults::time_selector_selected_content_color(),
3260 time_selector_unselected_content_color:
3261 TimePickerDefaults::time_selector_unselected_content_color(),
3262 }
3263 }
3264}
3265
3266#[derive(Clone)]
3268pub struct TimePickerConfig {
3269 pub modifier: Modifier,
3270 pub colors: TimePickerColors,
3271 pub layout_type: TimePickerLayoutType,
3272}
3273
3274impl Default for TimePickerConfig {
3275 fn default() -> Self {
3276 Self {
3277 modifier: Modifier::new(),
3278 colors: TimePickerColors::default(),
3279 layout_type: TimePickerLayoutType::Vertical,
3280 }
3281 }
3282}
3283
3284pub fn TimePicker(
3286 state: Rc<TimePickerState>,
3287 on_confirm: Rc<dyn Fn(u32, u32)>,
3288 on_dismiss: Rc<dyn Fn()>,
3289 config: TimePickerConfig,
3290) -> View {
3291 let th = theme();
3292 let hour = state.hour.get();
3293 let minute = state.minute.get();
3294 let is_am = state.is_am.get();
3295
3296 let hour_str = format!("{:02}", hour);
3297 let min_str = format!("{:02}", minute);
3298
3299 Column(
3300 config
3301 .modifier
3302 .width(256.0)
3303 .padding(24.0)
3304 .align_items(AlignItems::CENTER),
3305 )
3306 .child((
3307 Row(Modifier::new().align_items(AlignItems::CENTER)).child((
3309 Box(Modifier::new()
3310 .clickable()
3311 .on_click({
3312 let s = state.clone();
3313 move || s.hour.set((s.hour.get() % 12) + 1)
3314 })
3315 .padding(8.0))
3316 .child(
3317 Text(hour_str)
3318 .size(48.0)
3319 .color(config.colors.clock_dial_unselected_content_color)
3320 .single_line(),
3321 ),
3322 Text(":")
3323 .size(48.0)
3324 .color(config.colors.clock_dial_unselected_content_color)
3325 .single_line(),
3326 Box(Modifier::new()
3327 .clickable()
3328 .on_click({
3329 let s = state.clone();
3330 move || s.minute.set((s.minute.get() + 1) % 60)
3331 })
3332 .padding(8.0))
3333 .child(
3334 Text(min_str)
3335 .size(48.0)
3336 .color(config.colors.clock_dial_unselected_content_color)
3337 .single_line(),
3338 ),
3339 )),
3340 Box(Modifier::new().fill_max_width().height(16.0)),
3341 Row(Modifier::new().align_items(AlignItems::CENTER)).child((
3343 Box(Modifier::new()
3344 .padding_values(PaddingValues {
3345 left: 12.0,
3346 right: 12.0,
3347 top: 4.0,
3348 bottom: 4.0,
3349 })
3350 .background(if is_am {
3351 config.colors.period_selector_selected_container_color
3352 } else {
3353 Color::TRANSPARENT
3354 })
3355 .clip_rounded(8.0)
3356 .clickable()
3357 .on_click({
3358 let s = state.clone();
3359 move || {
3360 if !s.is_am.get() {
3361 s.is_am.set(true);
3362 let h = s.hour.get();
3363 s.hour.set(if h == 12 { 12 } else { (h + 12) % 24 });
3364 if s.hour.get() == 0 {
3365 s.hour.set(12);
3366 }
3367 }
3368 }
3369 }))
3370 .child(Text("AM").size(th.typography.label_large).color(if is_am {
3371 config.colors.period_selector_selected_content_color
3372 } else {
3373 config.colors.period_selector_unselected_content_color
3374 })),
3375 Box(Modifier::new().width(8.0).height(1.0)),
3376 Box(Modifier::new()
3377 .padding_values(PaddingValues {
3378 left: 12.0,
3379 right: 12.0,
3380 top: 4.0,
3381 bottom: 4.0,
3382 })
3383 .background(if !is_am {
3384 config.colors.period_selector_selected_container_color
3385 } else {
3386 Color::TRANSPARENT
3387 })
3388 .clip_rounded(8.0)
3389 .clickable()
3390 .on_click({
3391 let s = state.clone();
3392 move || {
3393 if s.is_am.get() {
3394 s.is_am.set(false);
3395 let h = s.hour.get();
3396 s.hour.set(if h == 12 { 12 } else { (h + 12) % 24 });
3397 if s.hour.get() == 0 {
3398 s.hour.set(12);
3399 }
3400 }
3401 }
3402 }))
3403 .child(Text("PM").size(th.typography.label_large).color(if !is_am {
3404 config.colors.period_selector_selected_content_color
3405 } else {
3406 config.colors.period_selector_unselected_content_color
3407 })),
3408 )),
3409 Box(Modifier::new().fill_max_width().height(16.0)),
3410 Row(Modifier::new().fill_max_width()).child((
3411 Spacer(),
3412 Box(Modifier::new().padding(8.0).clickable().on_click({
3413 let on_dismiss = on_dismiss.clone();
3414 move || on_dismiss()
3415 }))
3416 .child(
3417 Text("Cancel")
3418 .color(config.colors.selector_color)
3419 .size(th.typography.label_large)
3420 .single_line(),
3421 ),
3422 Box(Modifier::new().width(8.0).height(1.0)),
3423 Box(Modifier::new().padding(8.0).clickable().on_click({
3424 let on_confirm = on_confirm.clone();
3425 let state = state.clone();
3426 move || {
3427 let (h, m) = state.selected_time();
3428 on_confirm(h, m);
3429 }
3430 }))
3431 .child(
3432 Text("OK")
3433 .color(config.colors.selector_color)
3434 .size(th.typography.label_large)
3435 .single_line(),
3436 ),
3437 )),
3438 ))
3439}
3440
3441pub struct NavRailItem {
3443 pub icon: View,
3444 pub label: String,
3445 pub on_click: Rc<dyn Fn()>,
3446 pub badge: Option<View>,
3447 pub enabled: bool,
3448 pub interaction_source: Option<MutableInteractionSource>,
3449}
3450
3451static NAVRAIL_COUNTER: AtomicU64 = AtomicU64::new(0);
3452static FILTERCHIP_COUNTER: AtomicU64 = AtomicU64::new(0);
3453
3454pub fn NavigationRail(
3459 selected_index: usize,
3460 items: Vec<NavRailItem>,
3461 header: Option<View>,
3462 fab: Option<View>,
3463 config: NavigationRailConfig,
3464) -> View {
3465 let th = theme();
3466 let id = remember(|| NAVRAIL_COUNTER.fetch_add(1, Ordering::Relaxed));
3467 let default_effects = AnimationSpec::spring_crit(40.0);
3468
3469 let mut top_children: Vec<View> = Vec::new();
3470 let mut item_views: Vec<View> = Vec::new();
3471
3472 let has_header = header.is_some();
3473 let has_fab = fab.is_some();
3474
3475 if let Some(h) = header {
3476 top_children.push(
3477 Box(Modifier::new()
3478 .padding_values(PaddingValues {
3479 left: 12.0,
3480 right: 12.0,
3481 top: 12.0,
3482 bottom: 12.0,
3483 })
3484 .align_self(AlignSelf::CENTER))
3485 .child(h),
3486 );
3487 }
3488
3489 if let Some(f) = fab {
3490 top_children.push(
3491 Box(Modifier::new()
3492 .padding_values(PaddingValues {
3493 left: 12.0,
3494 right: 12.0,
3495 top: 8.0,
3496 bottom: 8.0,
3497 })
3498 .align_self(AlignSelf::CENTER))
3499 .child(f),
3500 );
3501 }
3502
3503 if has_header || has_fab {
3504 top_children.push(Box(Modifier::new()
3505 .fill_max_width()
3506 .height(1.0)
3507 .background(th.outline_variant)));
3508 }
3509
3510 for (i, item) in items.into_iter().enumerate() {
3511 let selected = i == selected_index;
3512 let is_enabled = item.enabled;
3513
3514 let fg = animate_color(
3515 format!("nr_fg_{}_{}", id, i),
3516 if selected {
3517 config.selected_icon_color
3518 } else {
3519 config.unselected_icon_color
3520 },
3521 default_effects,
3522 );
3523 let fg_label = animate_color(
3524 format!("nr_fl_{}_{}", id, i),
3525 if selected {
3526 config.selected_text_color
3527 } else {
3528 config.unselected_text_color
3529 },
3530 default_effects,
3531 );
3532 let bg = animate_color(
3533 format!("nr_bg_{}_{}", id, i),
3534 if selected {
3535 config.indicator_color
3536 } else {
3537 Color::TRANSPARENT
3538 },
3539 default_effects,
3540 );
3541
3542 let cb = item.on_click.clone();
3543 let nr_source: Rc<MutableInteractionSource> = item
3544 .interaction_source
3545 .clone()
3546 .map(Rc::new)
3547 .unwrap_or_else(|| remember(MutableInteractionSource::new));
3548
3549 let mut item_m = Modifier::new()
3550 .fill_max_width()
3551 .padding_values(PaddingValues {
3552 left: 4.0,
3553 right: 4.0,
3554 top: 4.0,
3555 bottom: 4.0,
3556 })
3557 .align_items(AlignItems::CENTER)
3558 .justify_content(JustifyContent::CENTER)
3559 .background(bg)
3560 .state_colors(StateColors {
3561 default: Color::TRANSPARENT,
3562 hovered: th.on_surface.with_alpha_f32(0.08),
3563 pressed: th.on_surface.with_alpha_f32(0.12),
3564 disabled: Color::TRANSPARENT,
3565 })
3566 .clip_rounded(config.item_radius)
3567 .interaction_source(&*nr_source)
3568 .semantics(Semantics::new(Role::Tab).with_label(&item.label));
3569
3570 if is_enabled {
3571 item_m = item_m.clickable().on_click({
3572 let cb = cb.clone();
3573 move || cb()
3574 });
3575 }
3576
3577 item_views.push(
3578 Column(item_m).child((
3579 Stack(Modifier::new()).child((
3580 Box(Modifier::new().size(24.0, 24.0))
3581 .child(with_content_color(fg, move || item.icon)),
3582 item.badge
3583 .map(|b| {
3584 Box(Modifier::new()
3585 .absolute()
3586 .offset(None, None, None, Some(0.0)))
3587 .child(b)
3588 })
3589 .unwrap_or(Box(Modifier::new())),
3590 )),
3591 Box(Modifier::new().fill_max_width().height(4.0)),
3592 Text(item.label)
3593 .color(fg_label)
3594 .size(th.typography.label_medium)
3595 .single_line(),
3596 )),
3597 );
3598 }
3599
3600 Column(
3601 Modifier::new()
3602 .width(config.width)
3603 .fill_max_height()
3604 .background(config.container_color)
3605 .align_items(AlignItems::CENTER)
3606 .semantics(Semantics::new(Role::Container).with_selectable_group())
3607 .then(config.modifier),
3608 )
3609 .child((
3610 Column(Modifier::new()).with_children(top_children),
3611 Box(Modifier::new().flex_grow(1.0)).child(
3612 Column(
3613 Modifier::new()
3614 .fill_max_size()
3615 .justify_content(JustifyContent::SPACE_BETWEEN)
3616 .align_items(AlignItems::CENTER),
3617 )
3618 .with_children(item_views),
3619 ),
3620 ))
3621}
3622
3623#[derive(Clone, Copy, Debug, PartialEq)]
3625pub enum DismissDirection {
3626 StartToEnd,
3627 EndToStart,
3628 Both,
3629}
3630
3631#[derive(Clone, Copy, Debug, PartialEq)]
3633pub enum DismissValue {
3634 Default,
3635 DismissedToStart,
3636 DismissedToEnd,
3637}
3638
3639pub struct SwipeToDismissState {
3641 swipeable: repose_core::SwipeableState<DismissValue>,
3642 dismissed_offset: f32,
3643}
3644
3645impl Default for SwipeToDismissState {
3646 fn default() -> Self {
3647 Self::new()
3648 }
3649}
3650
3651impl SwipeToDismissState {
3652 pub fn new() -> Self {
3653 Self::with_config(SwipeToDismissConfig::default())
3654 }
3655
3656 pub fn with_config(config: SwipeToDismissConfig) -> Self {
3657 let one_third = 1.0 / 3.0;
3658 let positional_threshold = (config.dismiss_threshold * one_third) / config.dismissed_offset;
3659 let mut anchors = vec![(0.0, DismissValue::Default)];
3660 if config.enable_dismiss_from_end_to_start {
3661 anchors.push((-config.dismissed_offset, DismissValue::DismissedToStart));
3662 }
3663 if config.enable_dismiss_from_start_to_end {
3664 anchors.push((config.dismissed_offset, DismissValue::DismissedToEnd));
3665 }
3666 anchors.sort_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap());
3668 let swipeable = repose_core::SwipeableState::new(
3669 anchors,
3670 repose_core::SwipeableConfig {
3671 animation_spec: config.animation_spec.clone(),
3672 positional_threshold,
3673 ..Default::default()
3674 },
3675 );
3676 swipeable.snap_to(0.0);
3678 Self {
3679 swipeable,
3680 dismissed_offset: config.dismissed_offset,
3681 }
3682 }
3683
3684 pub fn offset(&self) -> f32 {
3686 self.swipeable.offset()
3687 }
3688
3689 pub fn set_offset_instant(&self, off: f32) {
3691 self.swipeable.snap_to(off);
3692 }
3693
3694 pub fn is_dismissed(&self) -> bool {
3696 self.swipeable.current_value() != DismissValue::Default
3697 }
3698
3699 pub fn dismiss(&self) {
3701 self.swipeable.animate_to(&DismissValue::DismissedToStart);
3702 }
3703
3704 pub fn dismiss_to(&self, offset: f32) {
3706 let value = if offset < 0.0 {
3707 DismissValue::DismissedToStart
3708 } else {
3709 DismissValue::DismissedToEnd
3710 };
3711 self.swipeable.animate_to(&value);
3712 }
3713
3714 pub fn reset(&self) {
3716 self.swipeable.animate_to(&DismissValue::Default);
3717 }
3718
3719 fn try_handle_dismiss_with_threshold(&self, on_dismiss: &Option<Rc<dyn Fn()>>, threshold: f32) {
3721 if !self.swipeable.is_animating() {
3722 let val = self.swipeable.current_value();
3723 if val != DismissValue::Default {
3724 if let Some(cb) = on_dismiss {
3725 cb();
3726 }
3727 }
3728 }
3729 }
3730}
3731
3732pub fn SwipeToDismiss(
3739 state: Rc<SwipeToDismissState>,
3740 on_dismiss: Option<Rc<dyn Fn()>>,
3741 background: View,
3742 content: View,
3743 modifier: Modifier,
3744 config: SwipeToDismissConfig,
3745) -> View {
3746 let offset = state.offset();
3747 state.try_handle_dismiss_with_threshold(&on_dismiss, config.dismiss_threshold);
3748
3749 let s1 = state.swipeable.clone();
3750 let s2 = state.swipeable.clone();
3751 let s3 = state.swipeable.clone();
3752 let on_down = { move |e: PointerEvent| s1.on_pointer_down(e.position.x) };
3753 let on_move = { move |e: PointerEvent| s2.on_pointer_move(e.position.x) };
3754 let on_up = { move |_e: PointerEvent| s3.on_pointer_up() };
3755
3756 let display_offset = offset
3757 .max(-config.dismissed_offset)
3758 .min(config.dismissed_offset);
3759
3760 let content_modifier = {
3761 let mut m = Modifier::new()
3762 .fill_max_width()
3763 .translate(display_offset, 0.0);
3764 if config.gestures_enabled {
3765 m = m
3766 .on_pointer_down(on_down)
3767 .on_pointer_move(on_move)
3768 .on_pointer_up(on_up);
3769 }
3770 m
3771 };
3772
3773 Stack(modifier.fill_max_width()).child((
3774 Box(Modifier::new().fill_max_size().absolute()).child(background),
3775 Box(content_modifier).child(content),
3776 ))
3777}
3778
3779#[derive(Clone, Debug)]
3785pub struct CarouselConfig {
3786 pub modifier: Modifier,
3787}
3788
3789impl Default for CarouselConfig {
3790 fn default() -> Self {
3791 Self {
3792 modifier: Modifier::new(),
3793 }
3794 }
3795}
3796
3797pub fn Carousel<T, F>(
3802 items: Vec<T>,
3803 item_width: f32,
3804 peek_amount: f32,
3805 state: Rc<LazyRowState>,
3806 item_builder: F,
3807 config: CarouselConfig,
3808) -> View
3809where
3810 T: Clone + 'static,
3811 F: Fn(T, usize) -> View + 'static,
3812{
3813 let padded_modifier = config.modifier.padding_values(PaddingValues {
3814 left: peek_amount,
3815 right: peek_amount,
3816 top: 0.0,
3817 bottom: 0.0,
3818 });
3819
3820 LazyRow(
3821 items,
3822 item_width,
3823 item_builder,
3824 LazyRowConfig {
3825 state,
3826 modifier: padded_modifier,
3827 ..Default::default()
3828 },
3829 )
3830}