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::animation::{AnimationSpec, Easing, RepeatableSpec};
23use repose_core::text::ImeAction;
24use repose_core::*;
25use repose_ui::LazyRowState;
26use repose_ui::lazy::LazyRow;
27use repose_ui::lazy_states::LazyRowConfig;
28use repose_ui::scroll::NestedScrollConnection;
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
810 let content_padding = PaddingValues {
811 top: if config.top_bar.is_some() {
812 64.0
813 } else {
814 insets.top
815 },
816 bottom: if config.bottom_bar.is_some() {
817 80.0 + insets.bottom + insets.ime_bottom
818 } else {
819 insets.bottom + insets.ime_bottom
820 },
821 left: insets.left,
822 right: insets.right,
823 };
824
825 Stack(
826 config
827 .modifier
828 .fill_max_size()
829 .background(config.container_color),
830 )
831 .child((
832 Box(Modifier::new()
833 .fill_max_size()
834 .padding_values(PaddingValues {
835 top: if config.top_bar.is_some() {
836 64.0 + insets.top
837 } else {
838 0.0
839 },
840 bottom: if config.bottom_bar.is_some() {
841 80.0 + insets.bottom + insets.ime_bottom
842 } else {
843 insets.bottom + insets.ime_bottom
844 },
845 ..Default::default()
846 }))
847 .child(content(content_padding)),
848 if let Some(bar) = config.top_bar {
849 Box(Modifier::new()
850 .absolute()
851 .offset(Some(0.0), Some(insets.top), Some(0.0), None))
852 .child(bar)
853 } else {
854 Box(Modifier::new())
855 },
856 if let Some(bar) = config.bottom_bar {
857 Box(Modifier::new().absolute().offset(
858 Some(0.0),
859 None,
860 Some(insets.bottom + insets.ime_bottom),
861 Some(0.0),
862 ))
863 .child(bar)
864 } else {
865 Box(Modifier::new())
866 },
867 if let Some(fab) = config.floating_action_button {
868 let mut fab_m = Modifier::new().absolute();
869 match config.fab_position {
870 FabPosition::End => {
871 fab_m = fab_m.offset(
872 None,
873 None,
874 Some(16.0 + insets.bottom + insets.ime_bottom),
875 Some(16.0),
876 );
877 }
878 FabPosition::Center => {
879 fab_m = fab_m.fill_max_width().align_self(AlignSelf::Center).offset(
880 None,
881 None,
882 Some(16.0 + insets.bottom + insets.ime_bottom),
883 None,
884 );
885 }
886 }
887 Box(fab_m).child(fab)
888 } else {
889 Box(Modifier::new())
890 },
891 config.snackbar_host.unwrap_or_else(|| Box(Modifier::new())),
892 ))
893}
894
895pub struct TooltipState {
897 visible: Signal<bool>,
898}
899
900impl TooltipState {
901 pub fn new() -> Rc<Self> {
902 Rc::new(Self {
903 visible: signal(false),
904 })
905 }
906
907 pub fn is_visible(&self) -> bool {
908 self.visible.get()
909 }
910
911 pub fn show(&self) {
912 self.visible.set(true);
913 }
914
915 pub fn dismiss(&self) {
916 self.visible.set(false);
917 }
918}
919
920pub fn TooltipBox(
931 text: impl Into<String>,
932 state: Rc<TooltipState>,
933 content: View,
934 config: TooltipConfig,
935) -> View {
936 let text: Rc<str> = Rc::from(text.into());
937 let th = theme();
938 let spec = th.motion.overlay;
939
940 let alpha = animate_f32(
941 "tooltip_alpha",
942 if state.is_visible() { 1.0 } else { 0.0 },
943 spec,
944 );
945
946 let tooltip_visible = state.is_visible() || alpha > 0.01;
947 let scale = 0.92 + 0.08 * alpha;
948
949 Stack(config.modifier).child((
950 Box(Modifier::new().fill_max_size()).child(content),
951 if tooltip_visible {
952 Box(Modifier::new()
953 .background(config.container_color)
954 .clip_rounded(th.shapes.extra_small)
955 .padding_values(PaddingValues {
956 left: config.horizontal_padding,
957 right: config.horizontal_padding,
958 top: config.vertical_padding,
959 bottom: config.vertical_padding,
960 })
961 .absolute()
962 .offset(None, Some(config.offset_y), None, None)
963 .align_self(AlignSelf::Center)
964 .render_z_index(10000.0)
965 .alpha(alpha)
966 .scale(scale))
967 .child(
968 Text((*text).to_string())
969 .color(config.content_color)
970 .size(th.typography.label_medium)
971 .single_line(),
972 )
973 } else {
974 Box(Modifier::new())
975 },
976 ))
977}
978
979pub struct DrawerState {
981 visible: Signal<bool>,
982}
983
984impl DrawerState {
985 pub fn new() -> Rc<Self> {
986 Rc::new(Self {
987 visible: signal(false),
988 })
989 }
990
991 pub fn is_open(&self) -> bool {
992 self.visible.get()
993 }
994
995 pub fn open(&self) {
996 self.visible.set(true);
997 }
998
999 pub fn dismiss(&self) {
1000 self.visible.set(false);
1001 }
1002}
1003
1004pub fn ModalNavigationDrawer(
1006 drawer_state: Rc<DrawerState>,
1007 drawer_content: View,
1008 content: View,
1009 config: NavigationDrawerConfig,
1010) -> View {
1011 let th = theme();
1012
1013 let drawer_offset = animate_f32(
1014 "modal_drawer_offset",
1015 if drawer_state.is_open() { 0.0 } else { -360.0 },
1016 theme().motion.spring,
1017 );
1018
1019 let mut drawer_m = Modifier::new()
1020 .absolute()
1021 .offset(Some(drawer_offset), Some(0.0), None, Some(0.0))
1022 .fill_max_height()
1023 .width(config.width)
1024 .background(config.container_color)
1025 .clip_rounded(config.shape_radius);
1026
1027 if config.tonal_elevation > 0.0 {
1028 drawer_m = drawer_m.state_elevation(StateElevation {
1029 default: config.tonal_elevation,
1030 hovered: config.tonal_elevation,
1031 pressed: config.tonal_elevation,
1032 disabled: 0.0,
1033 });
1034 }
1035
1036 ZStack(Modifier::new().fill_max_size()).child((
1037 Box(Modifier::new()
1038 .fill_max_size()
1039 .background(config.content_color))
1040 .child(content),
1041 if drawer_state.is_open() {
1042 Box(Modifier::new()
1043 .fill_max_size()
1044 .background(config.scrim_color)
1045 .clickable()
1046 .on_pointer_down({
1047 let ds = drawer_state.clone();
1048 move |_| ds.dismiss()
1049 }))
1050 .child(Box(Modifier::new()))
1051 } else {
1052 Box(Modifier::new())
1053 },
1054 Box(drawer_m).child(drawer_content),
1055 ))
1056}
1057
1058pub fn DismissibleNavigationDrawer(
1061 drawer_state: Rc<DrawerState>,
1062 drawer_content: View,
1063 content: View,
1064 config: NavigationDrawerConfig,
1065) -> View {
1066 let th = theme();
1067 let drawer_offset = animate_f32(
1068 "dismissible_drawer_offset",
1069 if drawer_state.is_open() { 0.0 } else { -360.0 },
1070 theme().motion.spring,
1071 );
1072
1073 let mut drawer_m = Modifier::new()
1074 .absolute()
1075 .offset(Some(drawer_offset), Some(0.0), None, Some(0.0))
1076 .fill_max_height()
1077 .width(config.width)
1078 .background(config.container_color)
1079 .clip_rounded(config.shape_radius);
1080
1081 if config.tonal_elevation > 0.0 {
1082 drawer_m = drawer_m.state_elevation(StateElevation {
1083 default: config.tonal_elevation,
1084 hovered: config.tonal_elevation,
1085 pressed: config.tonal_elevation,
1086 disabled: 0.0,
1087 });
1088 }
1089
1090 ZStack(Modifier::new().fill_max_size()).child((
1091 Box(Modifier::new()
1092 .fill_max_size()
1093 .background(config.content_color))
1094 .child(content),
1095 Box(drawer_m).child(drawer_content),
1096 ))
1097}
1098
1099pub fn PermanentNavigationDrawer(
1101 drawer_content: View,
1102 content: View,
1103 config: NavigationDrawerConfig,
1104) -> View {
1105 Row(Modifier::new().fill_max_size()).child((
1106 Box(Modifier::new()
1107 .width(config.width)
1108 .fill_max_height()
1109 .background(config.container_color))
1110 .child(
1111 Box(Modifier::new())
1112 .color(config.content_color)
1113 .child(drawer_content),
1114 ),
1115 Box(Modifier::new().flex_grow(1.0)).child(content),
1116 ))
1117}
1118
1119#[derive(Clone)]
1121pub struct NavigationDrawerItemConfig {
1122 pub modifier: Modifier,
1123 pub icon: Option<View>,
1124 pub badge: Option<View>,
1125 pub enabled: bool,
1126 pub shape_radius: f32,
1127 pub interaction_source: Option<MutableInteractionSource>,
1128}
1129
1130impl Default for NavigationDrawerItemConfig {
1131 fn default() -> Self {
1132 Self {
1133 modifier: Modifier::new(),
1134 icon: None,
1135 badge: None,
1136 enabled: true,
1137 shape_radius: repose_core::locals::theme().shapes.large,
1138 interaction_source: None,
1139 }
1140 }
1141}
1142
1143pub fn NavigationDrawerItem(
1144 label: View,
1145 selected: bool,
1146 on_click: impl Fn() + 'static,
1147 config: NavigationDrawerItemConfig,
1148) -> View {
1149 let th = theme();
1150 let id = remember(|| FILTERCHIP_COUNTER.fetch_add(1, Ordering::Relaxed));
1151 let spec = th.motion.color;
1152 let bg = animate_color(
1153 format!("ndi_bg_{}", id),
1154 if selected {
1155 th.secondary_container
1156 } else {
1157 Color::TRANSPARENT
1158 },
1159 spec,
1160 );
1161 let fg = animate_color(
1162 format!("ndi_fg_{}", id),
1163 if selected {
1164 th.on_secondary_container
1165 } else {
1166 th.on_surface_variant
1167 },
1168 spec,
1169 );
1170
1171 let nd_source: Rc<MutableInteractionSource> = config
1172 .interaction_source
1173 .clone()
1174 .map(Rc::new)
1175 .unwrap_or_else(|| remember(MutableInteractionSource::new));
1176
1177 let mut m = Modifier::new()
1178 .fill_max_width()
1179 .padding_values(PaddingValues {
1180 left: 12.0,
1181 right: 12.0,
1182 top: 0.0,
1183 bottom: 0.0,
1184 })
1185 .min_height(56.0)
1186 .background(bg)
1187 .state_colors(StateColors {
1188 default: Color::TRANSPARENT,
1189 hovered: th.on_surface.with_alpha_f32(0.08),
1190 pressed: th.on_surface.with_alpha_f32(0.12),
1191 disabled: Color::TRANSPARENT,
1192 })
1193 .clip_rounded(config.shape_radius)
1194 .interaction_source(&*nd_source)
1195 .then(config.modifier);
1196
1197 if config.enabled {
1198 m = m.clickable().on_click(move || on_click());
1199 }
1200
1201 Box(m).child(with_content_color(fg, || {
1202 Row(Modifier::new()
1203 .align_items(AlignItems::Center)
1204 .padding_values(PaddingValues {
1205 left: 16.0,
1206 right: 24.0,
1207 top: 0.0,
1208 bottom: 0.0,
1209 }))
1210 .child((
1211 config
1212 .icon
1213 .unwrap_or(Box(Modifier::new().width(24.0).height(24.0))),
1214 Box(Modifier::new().width(12.0).height(1.0)),
1215 Box(Modifier::new().flex_grow(1.0)).child(label),
1216 config.badge.unwrap_or(Box(Modifier::new())),
1217 ))
1218 }))
1219}
1220
1221#[derive(Clone)]
1223pub struct DropdownMenuItem {
1224 pub text: String,
1225 pub leading_icon: Option<View>,
1226 pub trailing_icon: Option<View>,
1227 pub on_click: Rc<dyn Fn()>,
1228 pub enabled: bool,
1229}
1230
1231impl DropdownMenuItem {
1232 pub fn new(text: impl Into<String>, on_click: impl Fn() + 'static) -> Self {
1233 Self {
1234 text: text.into(),
1235 leading_icon: None,
1236 trailing_icon: None,
1237 on_click: Rc::new(on_click),
1238 enabled: true,
1239 }
1240 }
1241
1242 pub fn leading_icon(mut self, icon: View) -> Self {
1243 self.leading_icon = Some(icon);
1244 self
1245 }
1246
1247 pub fn trailing_icon(mut self, icon: View) -> Self {
1248 self.trailing_icon = Some(icon);
1249 self
1250 }
1251
1252 pub fn disabled(mut self) -> Self {
1253 self.enabled = false;
1254 self
1255 }
1256}
1257
1258pub struct MenuDivider;
1260
1261pub struct MenuState {
1263 visible: Signal<bool>,
1264 anchor: Signal<Option<Vec2>>,
1265}
1266
1267impl Default for MenuState {
1268 fn default() -> Self {
1269 Self::new()
1270 }
1271}
1272
1273impl MenuState {
1274 pub fn new() -> Self {
1275 Self {
1276 visible: signal(false),
1277 anchor: signal(None),
1278 }
1279 }
1280
1281 pub fn is_open(&self) -> bool {
1282 self.visible.get()
1283 }
1284
1285 pub fn open(&self) {
1286 self.visible.set(true);
1287 }
1288
1289 pub fn open_at(&self, screen_pos: Vec2) {
1290 self.anchor.set(Some(screen_pos));
1291 self.visible.set(true);
1292 }
1293
1294 pub fn dismiss(&self) {
1295 self.visible.set(false);
1296 }
1297}
1298
1299static DROPDOWN_COUNTER: AtomicU64 = AtomicU64::new(0);
1300
1301pub fn DropdownMenu(
1308 state: Rc<MenuState>,
1309 overlay: OverlayHandle,
1310 modifier: Modifier,
1311 trigger: View,
1312 items: Vec<DropdownMenuEntry>,
1313 config: DropdownMenuConfig,
1314) -> View {
1315 let th = theme();
1316 let ddm_id = remember(|| DROPDOWN_COUNTER.fetch_add(1, Ordering::Relaxed));
1317 let overlay_id = remember_with_key(format!("ddm_oid_{}", ddm_id), || signal(0u64));
1318
1319 let anim = remember_state_with_key(format!("ddm_anim_{}", ddm_id), || {
1321 AnimatedValue::new(0.0, theme().motion.overlay)
1322 });
1323 let last_target = remember_state_with_key(format!("ddm_lt_{}", ddm_id), || f32::NAN);
1324 let anim_target = if state.is_open() { 1.0 } else { 0.0 };
1325
1326 {
1327 let mut a = anim.borrow_mut();
1328 let mut lt = last_target.borrow_mut();
1329 if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
1330 a.set_target(anim_target);
1331 *lt = anim_target;
1332 }
1333 drop(lt);
1334 if a.update() {
1335 request_frame();
1336 }
1337 }
1338
1339 let progress = *anim.borrow().get();
1340 let menu_visible = state.is_open() || progress > 0.01;
1341
1342 if menu_visible {
1344 if overlay_id.get() == 0 {
1345 let scrim = Box(Modifier::new().fill_max_size().absolute().on_pointer_down({
1346 let s = state.clone();
1347 move |_| s.dismiss()
1348 }));
1349 let id = overlay.show_with(scrim, 899.0, true);
1350 overlay_id.set(id);
1351 }
1352 } else {
1353 let prev = overlay_id.get();
1354 if prev != 0 {
1355 let _ = overlay.dismiss(prev);
1356 overlay_id.set(0);
1357 }
1358 }
1359
1360 let scale = 0.92 + 0.08 * progress;
1361 let alpha = progress;
1362
1363 Stack(modifier).child((
1364 trigger,
1365 if menu_visible {
1366 Box(Modifier::new()
1367 .absolute()
1368 .offset(None, Some(40.0), None, None)
1369 .render_z_index(900.0)
1370 .scale(scale)
1371 .alpha(alpha))
1372 .child(render_dropdown_menu_content(
1373 &th,
1374 &items,
1375 state.clone(),
1376 &config,
1377 ))
1378 } else {
1379 Box(Modifier::new())
1380 },
1381 ))
1382}
1383
1384#[derive(Clone)]
1386pub enum DropdownMenuEntry {
1387 Item(DropdownMenuItem),
1388 Divider,
1389}
1390
1391fn render_dropdown_menu_content(
1392 th: &Theme,
1393 items: &[DropdownMenuEntry],
1394 state: Rc<MenuState>,
1395 config: &DropdownMenuConfig,
1396) -> View {
1397 let children: Vec<View> = items
1398 .iter()
1399 .map(|entry| match entry {
1400 DropdownMenuEntry::Item(item) => {
1401 let text_color = if item.enabled {
1402 config.item_text_color
1403 } else {
1404 config.disabled_item_text_color
1405 };
1406 let on_click = item.on_click.clone();
1407 let state = state.clone();
1408 let mut modifier = Modifier::new()
1409 .fill_max_width()
1410 .min_height(40.0)
1411 .padding_values(PaddingValues {
1412 left: 12.0,
1413 right: 12.0,
1414 top: 0.0,
1415 bottom: 0.0,
1416 })
1417 .align_items(AlignItems::Center);
1418
1419 if item.enabled {
1420 modifier = modifier
1421 .state_colors(StateColors {
1422 default: Color::TRANSPARENT,
1423 hovered: th.on_surface.with_alpha_f32(0.08),
1424 pressed: th.on_surface.with_alpha_f32(0.12),
1425 disabled: Color::TRANSPARENT,
1426 })
1427 .clickable()
1428 .on_click(move || {
1429 on_click();
1430 state.dismiss();
1431 });
1432 }
1433
1434 Row(modifier).child((
1435 item.leading_icon
1436 .clone()
1437 .unwrap_or(Box(Modifier::new().width(24.0).height(24.0))),
1438 Box(Modifier::new().width(12.0).fill_max_height()),
1439 Box(Modifier::new().flex_grow(1.0)).child(
1440 Text(item.text.clone())
1441 .color(text_color)
1442 .size(th.typography.body_large)
1443 .single_line(),
1444 ),
1445 item.trailing_icon.clone().unwrap_or(Box(Modifier::new())),
1446 ))
1447 }
1448 DropdownMenuEntry::Divider => Box(Modifier::new()
1449 .fill_max_width()
1450 .height(1.0)
1451 .margin(12.0)
1452 .background(config.divider_color)),
1453 })
1454 .collect();
1455
1456 Box(Modifier::new()
1457 .state_elevation(StateElevation {
1458 default: th.elevation.level2,
1459 hovered: th.elevation.level3,
1460 pressed: th.elevation.level3,
1461 disabled: 0.0,
1462 })
1463 .min_width(config.min_width)
1464 .padding(4.0)
1465 .background(config.container_color)
1466 .clip_rounded(config.shape_radius.unwrap_or(th.shapes.small)))
1467 .child(Column(Modifier::new()).with_children(children))
1468}
1469
1470#[derive(Clone, Copy, Debug, PartialEq)]
1472pub enum SearchBarValue {
1473 Collapsed,
1474 Expanded,
1475}
1476
1477pub struct SearchBarState {
1480 pub query: Signal<String>,
1481 pub expanded: Signal<bool>,
1482 pub active: Signal<bool>,
1483 pub expands_to_full_screen: Signal<bool>,
1486 anim: Rc<RefCell<AnimatedValue<f32>>>,
1488 content_anim: Rc<RefCell<AnimatedValue<f32>>>,
1490 pub collapsed_layout_rect: Signal<(f32, f32, f32, f32)>,
1493}
1494
1495impl Default for SearchBarState {
1496 fn default() -> Self {
1497 Self::new()
1498 }
1499}
1500
1501impl SearchBarState {
1502 pub fn new() -> Self {
1503 Self {
1504 query: signal(String::new()),
1505 expanded: signal(false),
1506 active: signal(false),
1507 expands_to_full_screen: signal(false),
1508 anim: Rc::new(RefCell::new(AnimatedValue::new(
1509 0.0,
1510 AnimationSpec::spring_gentle(),
1511 ))),
1512 content_anim: Rc::new(RefCell::new(AnimatedValue::new(
1513 0.0,
1514 AnimationSpec::spring_gentle(),
1515 ))),
1516 collapsed_layout_rect: signal((0.0, 0.0, 0.0, 0.0)),
1517 }
1518 }
1519
1520 pub fn query(&self) -> String {
1521 self.query.get()
1522 }
1523
1524 pub fn set_query(&self, q: impl Into<String>) {
1525 self.query.set(q.into());
1526 }
1527
1528 pub fn is_expanded(&self) -> bool {
1529 self.expanded.get()
1530 }
1531
1532 pub fn expand(&self) {
1533 self.expanded.set(true);
1534 self.anim.borrow_mut().set_target(1.0);
1535 self.content_anim.borrow_mut().set_target(1.0);
1536 request_frame();
1537 }
1538
1539 pub fn collapse(&self) {
1540 self.expanded.set(false);
1541 self.active.set(false);
1542 self.content_anim.borrow_mut().set_target(0.0);
1544 self.anim.borrow_mut().set_target(0.0);
1545 request_frame();
1546 }
1547
1548 pub fn is_active(&self) -> bool {
1549 self.active.get()
1550 }
1551
1552 pub fn activate(&self) {
1553 self.active.set(true);
1554 self.expanded.set(true);
1555 self.anim.borrow_mut().set_target(1.0);
1556 self.content_anim.borrow_mut().set_target(1.0);
1557 request_frame();
1558 }
1559
1560 pub fn deactivate(&self) {
1561 if self.expanded.get() {
1562 self.expanded.set(false);
1563 self.content_anim.borrow_mut().set_target(0.0);
1564 self.anim.borrow_mut().set_target(0.0);
1565 }
1566 self.active.set(false);
1567 FocusManager::new(vec![], None).clear_focus(false);
1568 request_frame();
1569 }
1570
1571 pub fn progress(&self) -> f32 {
1574 let mut a = self.anim.borrow_mut();
1575 let still = a.update();
1576 if still {
1577 request_frame();
1578 }
1579 a.get().clamp(0.0, 1.0)
1580 }
1581
1582 pub fn content_progress(&self) -> f32 {
1584 let mut a = self.content_anim.borrow_mut();
1585 let still = a.update();
1586 if still {
1587 request_frame();
1588 }
1589 a.get().clamp(0.0, 1.0)
1590 }
1591
1592 pub fn is_animating(&self) -> bool {
1594 self.anim.borrow().is_animating() || self.content_anim.borrow().is_animating()
1595 }
1596
1597 pub fn current_value(&self) -> SearchBarValue {
1599 if *self.anim.borrow().get() <= 0.02 {
1600 SearchBarValue::Collapsed
1601 } else {
1602 SearchBarValue::Expanded
1603 }
1604 }
1605
1606 pub fn snap_to(&self, fraction: f32) {
1608 self.anim.borrow_mut().snap_to(fraction.clamp(0.0, 1.0));
1609 request_frame();
1610 }
1611}
1612
1613#[derive(Clone)]
1614pub struct SearchBarInputFieldConfig {
1615 pub state: Option<Rc<SearchBarState>>,
1616 pub on_search: Option<Rc<dyn Fn(String)>>,
1617 pub enabled: bool,
1618 pub text_color: Color,
1619 pub placeholder_color: Color,
1620 pub leading_icon: Option<View>,
1621 pub trailing_icon: Option<View>,
1622 pub interaction_source: Option<MutableInteractionSource>,
1623}
1624
1625impl Default for SearchBarInputFieldConfig {
1626 fn default() -> Self {
1627 let th = theme();
1628 Self {
1629 state: None,
1630 on_search: None,
1631 enabled: true,
1632 text_color: th.on_surface,
1633 placeholder_color: th.on_surface_variant,
1634 leading_icon: None,
1635 trailing_icon: None,
1636 interaction_source: None,
1637 }
1638 }
1639}
1640
1641pub fn SearchBarInputField(
1646 placeholder: String,
1647 query: String,
1648 on_query_change: Rc<dyn Fn(String)>,
1649 expanded: bool,
1650 config: SearchBarInputFieldConfig,
1651) -> View {
1652 let source: Rc<MutableInteractionSource> = config
1653 .interaction_source
1654 .clone()
1655 .map(Rc::new)
1656 .unwrap_or_else(|| Rc::new(MutableInteractionSource::new()));
1657 let focused = source.source().collect_is_focused();
1658 let state = config.state;
1659 let enabled = config.enabled;
1660
1661 let mut input_m = Modifier::new()
1662 .flex_grow(1.0)
1663 .padding(4.0)
1664 .required_width_in(SearchBarDefaults::MIN_WIDTH, SearchBarDefaults::MAX_WIDTH)
1665 .required_height_in(SearchBarDefaults::HEIGHT, SearchBarDefaults::HEIGHT)
1666 .interaction_source(&*source)
1667 .semantics(Semantics {
1668 role: Role::TextField,
1669 label: Some("Search".into()),
1670 focused: expanded || focused,
1671 enabled,
1672 selectable_group: false,
1673 })
1674 .on_key_event({
1675 let s = state.clone();
1676 move |ev| {
1677 if ev.key == Key::Escape {
1678 if let Some(ref s) = s {
1679 if s.is_active() {
1680 s.deactivate();
1681 }
1682 }
1683 true
1684 } else if ev.key == Key::ArrowDown || ev.key == Key::ArrowUp {
1685 if let Some(ref s) = s {
1686 if !s.is_expanded() {
1687 s.activate();
1688 }
1689 }
1690 true
1691 } else {
1692 false
1693 }
1694 }
1695 });
1696 if let Some(ref s) = state {
1697 let s2 = s.clone();
1698 input_m = input_m.on_focus_changed(move |focused| {
1699 if focused {
1700 s2.activate();
1701 }
1702 });
1703 }
1704
1705 let on_qc = on_query_change.clone();
1706 let on_s = config.on_search.clone();
1707
1708 let read_only = !expanded;
1710
1711 let display_color = if query.is_empty() {
1712 config.placeholder_color
1713 } else {
1714 config.text_color
1715 };
1716
1717 let tf_state = remember_with_key("SearchBarInputField_tf_state", || {
1718 RefCell::new(TextFieldState::new())
1719 });
1720 if tf_state.borrow().text != query {
1721 tf_state.borrow_mut().text = query.clone();
1722 }
1723
1724 let mut row_children: Vec<View> = Vec::new();
1726 if let Some(icon) = config.leading_icon {
1727 row_children.push(icon);
1728 }
1729 let on_qc2 = on_qc.clone();
1730 row_children.push(
1731 BasicTextField(
1732 tf_state.clone(),
1733 input_m,
1734 placeholder,
1735 repose_ui::TextFieldConfig {
1736 on_change: Some(Rc::new(move |text| on_qc2(text))),
1737 on_submit: on_s.clone(),
1738 enabled,
1739 read_only,
1740 line_limits: TextFieldLineLimits::SingleLine,
1741 keyboard_options: KeyboardOptions {
1742 ime_action: ImeAction::Search,
1743 ..KeyboardOptions::DEFAULT
1744 },
1745 ..Default::default()
1746 },
1747 )
1748 .color(display_color)
1749 .size(repose_core::locals::theme().typography.body_large),
1750 );
1751 if let Some(icon) = config.trailing_icon {
1752 row_children.push(icon);
1753 }
1754
1755 if row_children.len() == 1 {
1756 row_children.into_iter().next().unwrap()
1757 } else {
1758 Row(Modifier::new()
1759 .fill_max_width()
1760 .align_items(AlignItems::Center))
1761 .child(row_children)
1762 }
1763}
1764
1765fn apply_tonal_elevation(m: Modifier, elevation: f32, container: Color) -> Modifier {
1768 if elevation > 0.0 {
1769 let th = theme();
1770 if container == th.colors.surface {
1771 let overlay_alpha = (elevation * 4.0 + 4.0).min(24.0) / 100.0;
1772 return m.background(th.colors.primary.with_alpha_f32(overlay_alpha));
1773 }
1774 }
1775 m
1776}
1777
1778fn track_collapsed_layout(state: &Rc<SearchBarState>) -> Modifier {
1781 let s = state.clone();
1782 Modifier::new().on_globally_positioned(move |rect| {
1783 s.collapsed_layout_rect
1784 .set((rect.x, rect.y, rect.w, rect.h));
1785 })
1786}
1787
1788pub fn SearchBar(
1802 state: Rc<SearchBarState>,
1803 input_field: View,
1804 modifier: Modifier,
1805 leading_icon: Option<View>,
1806 trailing_icon: Option<View>,
1807 config: SearchBarConfig,
1808) -> View {
1809 let th = theme();
1810 let colors = config.colors;
1811
1812 let mut bar_m = modifier
1813 .fill_max_width()
1814 .height(config.height)
1815 .state_elevation(StateElevation {
1816 default: config.tonal_elevation,
1817 hovered: th.elevation.level2,
1818 pressed: th.elevation.level3,
1819 disabled: 0.0,
1820 })
1821 .shadow(config.shadow_elevation, 0.0)
1822 .padding_values(config.content_padding)
1823 .on_key_event({
1824 let s = state.clone();
1825 move |ev| {
1826 if ev.key == Key::Escape && s.is_active() {
1827 s.deactivate();
1828 true
1829 } else {
1830 false
1831 }
1832 }
1833 })
1834 .on_focus_changed({
1835 let s = state.clone();
1836 move |focused| {
1837 if focused {
1838 s.activate();
1839 }
1840 }
1841 })
1842 .semantics(Semantics {
1843 role: Role::TextField,
1844 label: Some("Search".into()),
1845 focused: state.is_active(),
1846 enabled: true,
1847 selectable_group: false,
1848 })
1849 .background(colors.container_color)
1850 .clip_rounded(config.shape_radius)
1851 .then(track_collapsed_layout(&state));
1852
1853 bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, colors.container_color);
1854
1855 Box(bar_m).child(
1856 Row(Modifier::new()
1857 .fill_max_size()
1858 .align_items(AlignItems::Center))
1859 .child((
1860 leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
1861 Box(Modifier::new().width(8.0).fill_max_height()),
1862 input_field,
1863 trailing_icon.unwrap_or(Box(Modifier::new())),
1864 )),
1865 )
1866}
1867
1868pub fn SearchBarWithContent(
1877 input_field: View,
1878 expanded: bool,
1879 on_expanded_change: Rc<dyn Fn(bool)>,
1880 modifier: Modifier,
1881 leading_icon: Option<View>,
1882 trailing_icon: Option<View>,
1883 config: SearchBarConfig,
1884 content: View,
1885) -> View {
1886 let th = theme();
1887 let width = animate_f32(
1888 "sbwc_w",
1889 if expanded {
1890 config.expanded_width
1891 } else {
1892 config.collapsed_width
1893 },
1894 theme().motion.expand,
1895 );
1896
1897 let bar_bg = if expanded {
1898 config.colors.active_container_color
1899 } else {
1900 config.colors.container_color
1901 };
1902 let shape = if expanded {
1903 config.active_shape_radius
1904 } else {
1905 config.shape_radius
1906 };
1907
1908 let mut bar_m = modifier
1909 .clone()
1910 .width(width)
1911 .min_width(config.min_width)
1912 .max_width(config.max_width)
1913 .height(config.height)
1914 .shadow(config.shadow_elevation, 0.0)
1915 .padding_values(config.content_padding)
1916 .on_key_event({
1917 let cb = on_expanded_change.clone();
1918 move |ev| {
1919 if ev.key == Key::Escape {
1920 cb(false);
1921 true
1922 } else {
1923 false
1924 }
1925 }
1926 })
1927 .background(bar_bg)
1928 .clip_rounded(shape);
1929
1930 bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
1931
1932 let content_alpha = animate_f32("sbwc_a", if expanded { 1.0 } else { 0.0 }, th.motion.color);
1934
1935 let bar = Box(bar_m).child(
1936 Row(Modifier::new()
1937 .fill_max_size()
1938 .align_items(AlignItems::Center))
1939 .child((
1940 leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
1941 Box(Modifier::new().width(8.0).fill_max_height()),
1942 input_field,
1943 trailing_icon.unwrap_or(Box(Modifier::new())),
1944 )),
1945 );
1946
1947 let show_content = expanded || content_alpha > 0.01;
1948 if show_content || expanded {
1949 Stack(modifier).child((
1950 bar,
1951 Box(Modifier::new()
1952 .width(width)
1953 .max_height(SearchBarDefaults::DOCKED_HEIGHT)
1954 .alpha(content_alpha)
1955 .background(config.colors.container_color)
1956 .clip_rounded(th.shapes.extra_small))
1957 .child(content),
1958 ))
1959 } else {
1960 bar
1961 }
1962}
1963
1964pub fn DockedSearchBar(
1969 input_field: View,
1970 expanded: bool,
1971 on_expanded_change: Option<Rc<dyn Fn(bool)>>,
1972 modifier: Modifier,
1973 leading_icon: Option<View>,
1974 config: SearchBarConfig,
1975 content: View,
1976) -> View {
1977 let th = theme();
1978 let active = expanded;
1979 let colors = config.colors;
1980
1981 let content_target = if expanded {
1982 get_window_container_height() * 2.0 / 3.0
1983 } else {
1984 0.0
1985 };
1986 let content_height = animate_f32("docked_sh", content_target, theme().motion.expand);
1987 let content_alpha = animate_f32(
1988 "docked_sa",
1989 if expanded { 1.0 } else { 0.0 },
1990 theme().motion.color,
1991 );
1992 let bar_bg = if active {
1993 colors.active_container_color
1994 } else {
1995 colors.container_color
1996 };
1997
1998 let clear_btn = if active {
1999 Box(Modifier::new().size(24.0, 24.0).clickable().on_click({
2000 let cb = on_expanded_change.clone();
2001 move || {
2002 if let Some(ref cb) = cb {
2003 cb(false);
2004 }
2005 }
2006 }))
2007 .child(Text("✕").size(16.0).color(colors.placeholder_color))
2008 } else {
2009 Box(Modifier::new())
2010 };
2011
2012 let mut bar_m = modifier
2013 .z_index(1.0)
2014 .min_width(SearchBarDefaults::MIN_WIDTH)
2015 .height(config.height)
2016 .state_elevation(StateElevation {
2017 default: if active {
2018 th.elevation.level3
2019 } else {
2020 config.tonal_elevation
2021 },
2022 hovered: th.elevation.level2,
2023 pressed: th.elevation.level3,
2024 disabled: 0.0,
2025 })
2026 .shadow(config.shadow_elevation, 0.0)
2027 .padding_values(config.content_padding)
2028 .on_key_event({
2029 let cb = on_expanded_change.clone();
2030 move |ev| {
2031 if ev.key == Key::Escape {
2032 if let Some(ref cb) = cb {
2033 cb(false);
2034 }
2035 true
2036 } else {
2037 false
2038 }
2039 }
2040 })
2041 .background(bar_bg)
2042 .clip_rounded(config.shape_radius);
2043
2044 bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
2045
2046 let bar = Box(bar_m).child(
2047 Row(Modifier::new()
2048 .fill_max_size()
2049 .align_items(AlignItems::Center))
2050 .child((
2051 leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
2052 Box(Modifier::new().width(12.0).fill_max_height()),
2053 input_field,
2054 clear_btn,
2055 )),
2056 );
2057
2058 let show_content = expanded || content_height > 1.0;
2059 if show_content {
2060 Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
2061 bar,
2062 Box(Modifier::new()
2063 .min_width(SearchBarDefaults::MIN_WIDTH)
2064 .height(content_height)
2065 .alpha(content_alpha)
2066 .clip_rounded(th.shapes.small)
2067 .background(colors.container_color)
2068 .state_elevation(StateElevation {
2069 default: th.elevation.level3,
2070 hovered: th.elevation.level3,
2071 pressed: th.elevation.level3,
2072 disabled: 0.0,
2073 }))
2074 .child(
2075 Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
2076 Box(Modifier::new()
2077 .min_width(SearchBarDefaults::MIN_WIDTH)
2078 .height(1.0)
2079 .background(colors.divider_color)),
2080 content,
2081 )),
2082 ),
2083 ))
2084 } else {
2085 bar
2086 }
2087}
2088
2089use std::sync::Mutex;
2093static WINDOW_CONTAINER_HEIGHT: Mutex<f32> = Mutex::new(800.0);
2094
2095pub fn set_window_container_height(h: f32) {
2097 if let Ok(mut v) = WINDOW_CONTAINER_HEIGHT.lock() {
2098 *v = h;
2099 }
2100}
2101
2102fn get_window_container_height() -> f32 {
2103 WINDOW_CONTAINER_HEIGHT.lock().map(|v| *v).unwrap_or(800.0)
2104}
2105
2106pub fn ExpandedFullScreenSearchBar(
2110 state: Rc<SearchBarState>,
2111 overlay: OverlayHandle,
2112 input_field: View,
2113 modifier: Modifier,
2114 config: ExpandedFullScreenSearchBarConfig,
2115 content: View,
2116) -> View {
2117 state.expands_to_full_screen.set(true);
2119
2120 let overlay_id = remember_with_key("efs_oid", || signal(0u64));
2121 let current_content = remember_state_with_key("efs_cc", || Box(Modifier::new()));
2122 *current_content.borrow_mut() = content;
2123
2124 let progress = state.progress();
2125 let _content_alpha = state.content_progress();
2126
2127 let expanded = state.is_expanded();
2128 let visible = expanded || progress > 0.01;
2129
2130 if visible {
2131 if overlay_id.get() == 0 {
2132 let input_fr = FocusRequester::new();
2133 let builder: Rc<dyn Fn() -> View> = Rc::new({
2134 let state = state.clone();
2135 let modifier = modifier.clone();
2136 let input_field = input_field.clone();
2137 let current_content = current_content.clone();
2138 let config = config.clone();
2139 let input_fr = input_fr.clone();
2140 move || {
2141 let progress = state.progress();
2142 let content_alpha = state.content_progress();
2143 let alpha = progress.clamp(0.0, 1.0);
2144 let c_alpha = content_alpha.clamp(0.0, 1.0);
2145 let th = theme();
2146 let content = current_content.borrow().clone();
2147
2148 let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
2150 .child(input_field.clone());
2151 input_fr.request_focus();
2152
2153 let header = Box(modifier
2154 .clone()
2155 .fill_max_width()
2156 .height(SearchBarDefaults::HEIGHT)
2157 .padding_values(PaddingValues {
2158 left: 16.0,
2159 right: 16.0,
2160 top: 0.0,
2161 bottom: 0.0,
2162 })
2163 .background(config.colors.container_color)
2164 .alpha(alpha))
2165 .child(inp);
2166
2167 let body = Box(Modifier::new()
2168 .fill_max_width()
2169 .flex_grow(1.0)
2170 .alpha(c_alpha)
2171 .background(th.surface))
2172 .child(content);
2173
2174 let insets = config.window_insets;
2175 let full = Column(Modifier::new().fill_max_size().padding_values(
2176 PaddingValues {
2177 left: insets.left,
2178 right: insets.right,
2179 top: insets.top,
2180 bottom: insets.bottom,
2181 },
2182 ))
2183 .child((header, body));
2184
2185 let scrim = Box(Modifier::new()
2186 .fill_max_size()
2187 .background(config.scrim_color.with_alpha((85.0 * alpha) as u8))
2188 .on_click({
2189 let s = state.clone();
2190 move || s.collapse()
2191 }));
2192
2193 ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, full))
2194 }
2195 });
2196
2197 let id = overlay.show_entry(builder, 900.0, false);
2198 overlay_id.set(id);
2199 }
2200 } else {
2201 let prev = overlay_id.get();
2202 if prev != 0 {
2203 let _ = overlay.dismiss(prev);
2204 overlay_id.set(0);
2205 }
2206 }
2207
2208 Box(Modifier::new())
2209}
2210
2211pub fn ExpandedDockedSearchBar(
2215 state: Rc<SearchBarState>,
2216 overlay: OverlayHandle,
2217 input_field: View,
2218 modifier: Modifier,
2219 config: ExpandedDockedSearchBarConfig,
2220 content: View,
2221) -> View {
2222 state.expands_to_full_screen.set(false);
2224
2225 let overlay_id = remember_with_key("eds_oid", || signal(0u64));
2226 let current_content = remember_state_with_key("eds_cc", || Box(Modifier::new()));
2227 *current_content.borrow_mut() = content;
2228
2229 let progress = state.progress();
2230 let _content_alpha = state.content_progress();
2231 let expanded = state.is_expanded();
2232 let visible = expanded || progress > 0.01;
2233
2234 if visible {
2235 if overlay_id.get() == 0 {
2236 let input_fr = FocusRequester::new();
2237 let builder: Rc<dyn Fn() -> View> = Rc::new({
2238 let state = state.clone();
2239 let modifier = modifier.clone();
2240 let input_field = input_field.clone();
2241 let current_content = current_content.clone();
2242 let config = config.clone();
2243 let input_fr = input_fr.clone();
2244 move || {
2245 let progress = state.progress();
2246 let content_alpha = state.content_progress();
2247 let alpha = progress.clamp(0.0, 1.0);
2248 let c_alpha = content_alpha.clamp(0.0, 1.0);
2249 let th = theme();
2250 let content = current_content.borrow().clone();
2251 let (_cx, _cy, _cw, _ch) = state.collapsed_layout_rect.get();
2252
2253 let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
2254 .child(input_field.clone());
2255 input_fr.request_focus();
2256
2257 let header = Box(modifier
2258 .clone()
2259 .fill_max_width()
2260 .height(SearchBarDefaults::HEIGHT)
2261 .alpha(alpha)
2262 .background(config.colors.container_color)
2263 .clip_rounded(config.shape_radius)
2264 .state_elevation(StateElevation {
2265 default: th.elevation.level3,
2266 hovered: th.elevation.level2,
2267 pressed: th.elevation.level3,
2268 disabled: 0.0,
2269 }))
2270 .child(inp);
2271
2272 let dropdown = Box(Modifier::new()
2273 .fill_max_width()
2274 .max_height(get_window_container_height() * 2.0 / 3.0)
2275 .alpha(c_alpha)
2276 .clip_rounded(config.dropdown_shape_radius)
2277 .background(config.colors.container_color)
2278 .state_elevation(StateElevation {
2279 default: th.elevation.level3,
2280 hovered: th.elevation.level3,
2281 pressed: th.elevation.level3,
2282 disabled: 0.0,
2283 }))
2284 .child(
2285 Column(Modifier::new().fill_max_width()).child((
2286 Box(Modifier::new()
2287 .fill_max_width()
2288 .height(1.0)
2289 .background(config.colors.divider_color)),
2290 content,
2291 )),
2292 );
2293
2294 let col = Column(Modifier::new().fill_max_width().padding_values(
2295 PaddingValues {
2296 left: _cx.max(16.0),
2297 right: 16.0,
2298 top: _cy + _ch + config.dropdown_gap_size,
2299 bottom: 0.0,
2300 },
2301 ))
2302 .child((header, dropdown));
2303
2304 let scrim = Box(Modifier::new()
2305 .fill_max_size()
2306 .background(config.dropdown_scrim_color)
2307 .on_click({
2308 let s = state.clone();
2309 move || s.collapse()
2310 }));
2311
2312 ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, col))
2313 }
2314 });
2315
2316 let id = overlay.show_entry(builder, 900.0, false);
2317 overlay_id.set(id);
2318 }
2319 } else {
2320 let prev = overlay_id.get();
2321 if prev != 0 {
2322 let _ = overlay.dismiss(prev);
2323 overlay_id.set(0);
2324 }
2325 }
2326
2327 Box(Modifier::new())
2328}
2329
2330pub fn AppBarWithSearch(
2334 state: Rc<SearchBarState>,
2335 input_field: View,
2336 navigation_icon: Option<View>,
2337 actions: Option<Vec<View>>,
2338 config: AppBarWithSearchConfig,
2339) -> View {
2340 let bg = config.colors.search_bar_container(config.scroll_fraction);
2341 let app_bar_bg = config.colors.app_bar_container(config.scroll_fraction);
2342
2343 let insets = config.window_insets;
2344
2345 let is_container_transparent = app_bar_bg.3 == 0;
2347 let tonal_elevation = if is_container_transparent {
2348 0.0
2349 } else {
2350 config.tonal_elevation
2351 };
2352 let shadow_elevation = if is_container_transparent {
2353 0.0
2354 } else {
2355 config.shadow_elevation
2356 };
2357
2358 let hide_collapsed = state.expands_to_full_screen.get() && state.is_expanded();
2360 let collapsed_alpha = if hide_collapsed { 0.0 } else { 1.0 };
2361
2362 let bar_m = Modifier::new()
2363 .fill_max_width()
2364 .height(config.height + insets.top)
2365 .translate(0.0, config.scroll_offset)
2366 .background(app_bar_bg)
2367 .semantics(Semantics::new(Role::Container).with_selectable_group());
2368
2369 let row = Row(Modifier::new()
2370 .fill_max_size()
2371 .align_items(AlignItems::Center)
2372 .padding_values(PaddingValues {
2373 left: config.content_padding.left + insets.left,
2374 right: config.content_padding.right + insets.right,
2375 top: insets.top,
2376 bottom: 0.0,
2377 }))
2378 .child({
2379 let mut children: Vec<View> = Vec::new();
2380 if let Some(nav) = navigation_icon {
2381 children.push(nav);
2382 children.push(Box(Modifier::new().width(4.0)));
2383 }
2384 let sb_colors = &config.colors.search_bar_colors;
2386 let collapsed_bar = SearchBar(
2387 state.clone(),
2388 input_field,
2389 Modifier::new().flex_grow(1.0).alpha(collapsed_alpha),
2390 None,
2391 None,
2392 SearchBarConfig {
2393 height: config.height - 8.0,
2394 shape_radius: config.shape_radius,
2395 colors: SearchBarColors {
2396 container_color: bg,
2397 active_container_color: bg,
2398 divider_color: sb_colors.divider_color,
2399 content_color: sb_colors.content_color,
2400 placeholder_color: sb_colors.placeholder_color,
2401 scrim_color: sb_colors.scrim_color,
2402 },
2403 tonal_elevation,
2404 shadow_elevation,
2405 ..Default::default()
2406 },
2407 );
2408 children.push(Box(Modifier::new().flex_grow(1.0)).child(collapsed_bar));
2409 if let Some(acts) = actions {
2410 children.push(Spacer());
2411 for a in acts {
2412 children.push(a);
2413 }
2414 }
2415 children
2416 });
2417
2418 Box(bar_m.shadow(shadow_elevation, 0.0)).child(row)
2419}
2420
2421pub struct SheetState {
2423 visible: Signal<bool>,
2424 drag_offset: Signal<f32>,
2425 peek_height: Signal<f32>,
2426}
2427
2428impl SheetState {
2429 pub fn new(peek_height: f32) -> Self {
2430 Self {
2431 visible: signal(false),
2432 drag_offset: signal(0.0),
2433 peek_height: signal(peek_height),
2434 }
2435 }
2436
2437 pub fn is_visible(&self) -> bool {
2438 self.visible.get()
2439 }
2440
2441 pub fn show(&self) {
2442 self.visible.set(true);
2443 }
2444
2445 pub fn dismiss(&self) {
2446 self.visible.set(false);
2447 self.drag_offset.set(0.0);
2448 }
2449
2450 pub fn set_peek_height(&self, h: f32) {
2451 self.peek_height.set(h);
2452 }
2453}
2454
2455pub fn ModalBottomSheet(
2460 state: Rc<SheetState>,
2461 overlay: OverlayHandle,
2462 modifier: Modifier,
2463 content: View,
2464 config: BottomSheetConfig,
2465) -> View {
2466 let th = theme();
2467 let peek_h = state.peek_height.get().max(config.peek_height);
2468 let anim_distance = peek_h.max(48.0).max(400.0);
2469 let overlay_id = remember_with_key("mbs_oid", || signal(0u64));
2470
2471 let drag_anchor_y: Rc<RefCell<f32>> = remember_state_with_key("mbs_drag_y", || 0.0);
2473 let offset_at_drag_start: Rc<RefCell<f32>> = remember_state_with_key("mbs_drag_base", || 0.0);
2474 let is_dragging: Rc<RefCell<bool>> = remember_state_with_key("mbs_drag", || false);
2475
2476 let anim = remember_state_with_key("mbs_anim", || {
2478 AnimatedValue::new(anim_distance, theme().motion.spring)
2479 });
2480 let last_target = remember_state_with_key("mbs_anim_target", || f32::NAN);
2481 let anim_target = if state.is_visible() {
2482 0.0
2483 } else {
2484 anim_distance
2485 };
2486
2487 {
2488 let mut a = anim.borrow_mut();
2489 let mut lt = last_target.borrow_mut();
2490 if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
2491 if state.is_visible() {
2492 a.set_spec(th.motion.spring);
2493 } else {
2494 a.set_spec(AnimationSpec::fast());
2495 }
2496 a.set_target(anim_target);
2497 *lt = anim_target;
2498 }
2499 drop(lt);
2500 let still_animating = a.update();
2501 if still_animating {
2502 request_frame();
2503 }
2504 }
2505
2506 let offset = *anim.borrow().get();
2507 let sheet_visible = state.is_visible() || offset < anim_distance - 10.0;
2508
2509 if sheet_visible {
2510 if overlay_id.get() == 0 {
2511 let builder: Rc<dyn Fn() -> View> = Rc::new({
2512 let state = state.clone();
2513 let anim = anim.clone();
2514 let modifier = modifier.clone();
2515 let content = content.clone();
2516 let drag_anchor_y = drag_anchor_y.clone();
2517 let offset_at_drag_start = offset_at_drag_start.clone();
2518 let is_dragging = is_dragging.clone();
2519 let anim_distance = anim_distance;
2520 move || {
2521 let off = *anim.borrow().get();
2522
2523 let sheet_body = Box(modifier
2524 .clone()
2525 .fill_max_width()
2526 .max_width(dp_to_px(config.max_width))
2527 .translate(0.0, off)
2528 .background(config.container_color)
2529 .clip_rounded(config.shape_radius)
2530 .on_pointer_down({
2531 let anim = anim.clone();
2532 let drag_anchor_y = drag_anchor_y.clone();
2533 let offset_at_drag_start = offset_at_drag_start.clone();
2534 let is_dragging = is_dragging.clone();
2535 move |ev| {
2536 *drag_anchor_y.borrow_mut() = ev.position.y;
2537 *offset_at_drag_start.borrow_mut() = *anim.borrow().get();
2538 *is_dragging.borrow_mut() = true;
2539 }
2540 })
2541 .on_pointer_move({
2542 let anim = anim.clone();
2543 let drag_anchor_y = drag_anchor_y.clone();
2544 let offset_at_drag_start = offset_at_drag_start.clone();
2545 let is_dragging = is_dragging.clone();
2546 move |ev| {
2547 if !*is_dragging.borrow() {
2548 return;
2549 }
2550 let delta = ev.position.y - *drag_anchor_y.borrow();
2551 let start_off = *offset_at_drag_start.borrow();
2552 let total = (start_off + delta).max(0.0);
2553 anim.borrow_mut().snap_to(total);
2554 request_frame();
2555 }
2556 })
2557 .on_pointer_up({
2558 let anim = anim.clone();
2559 let is_dragging = is_dragging.clone();
2560 let state = state.clone();
2561 let anim_distance = anim_distance;
2562 move |_| {
2563 *is_dragging.borrow_mut() = false;
2564 let current_off = *anim.borrow().get();
2565 let threshold = anim_distance * 0.3;
2566 if current_off > threshold {
2567 anim.borrow_mut().set_target(anim_distance);
2568 state.dismiss();
2569 } else {
2570 anim.borrow_mut().set_target(0.0);
2571 }
2572 }
2573 }))
2574 .child(
2575 Column(Modifier::new().fill_max_width()).child((
2576 Row(Modifier::new()
2577 .fill_max_width()
2578 .justify_content(JustifyContent::Center))
2579 .child(Box(Modifier::new()
2580 .margin_vertical(22.0)
2581 .width(config.drag_handle_width)
2582 .height(config.drag_handle_height)
2583 .background(config.drag_handle_color)
2584 .clip_rounded(2.0))),
2585 content.clone(),
2586 )),
2587 );
2588
2589 let sheet = Box(Modifier::new()
2590 .fill_max_size()
2591 .justify_content(JustifyContent::Center)
2592 .align_items(AlignItems::FlexEnd))
2593 .child(sheet_body);
2594
2595 let scrim_alpha = if state.is_visible() {
2596 config.scrim_color.3
2597 } else {
2598 let t = (off / anim_distance).clamp(0.0, 1.0);
2599 (config.scrim_color.3 as f32 * (1.0 - t)) as u8
2600 };
2601 let scrim = Box(Modifier::new()
2602 .fill_max_size()
2603 .background(config.scrim_color.with_alpha(scrim_alpha))
2604 .on_pointer_down({
2605 let s = state.clone();
2606 move |_| s.dismiss()
2607 }));
2608
2609 ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, sheet))
2610 }
2611 });
2612
2613 let id = overlay.show_entry(builder, 900.0, false);
2614 overlay_id.set(id);
2615 }
2616 } else {
2617 let prev = overlay_id.get();
2618 if prev != 0 {
2619 let _ = overlay.dismiss(prev);
2620 overlay_id.set(0);
2621 }
2622 }
2623
2624 Box(Modifier::new())
2625}
2626
2627pub struct PullToRefreshState {
2633 refreshing: Signal<bool>,
2634 scroll_state: RefCell<Option<Rc<repose_ui::scroll::ScrollState>>>,
2635 threshold: f32,
2636 triggered: Cell<bool>,
2637}
2638
2639impl Default for PullToRefreshState {
2640 fn default() -> Self {
2641 Self::new()
2642 }
2643}
2644
2645impl PullToRefreshState {
2646 pub fn new() -> Self {
2647 Self {
2648 refreshing: signal(false),
2649 scroll_state: RefCell::new(None),
2650 threshold: 64.0,
2651 triggered: Cell::new(false),
2652 }
2653 }
2654
2655 pub fn set_scroll_state(&self, state: Rc<repose_ui::scroll::ScrollState>) {
2658 *self.scroll_state.borrow_mut() = Some(state);
2659 }
2660
2661 pub fn set_threshold(&mut self, px: f32) {
2663 self.threshold = px;
2664 }
2665
2666 pub fn is_refreshing(&self) -> bool {
2667 self.refreshing.get()
2668 }
2669
2670 pub fn set_refreshing(&self, v: bool) {
2671 self.refreshing.set(v);
2672 if !v && let Some(sc) = self.scroll_state.borrow().as_ref() {
2673 sc.set_overscroll(0.0);
2674 }
2675 }
2676
2677 pub fn pull_offset(&self) -> f32 {
2679 if let Some(sc) = self.scroll_state.borrow().as_ref() {
2680 let os = sc.overscroll_offset();
2681 if os < 0.0 { -os } else { 0.0 }
2682 } else {
2683 0.0
2684 }
2685 }
2686}
2687
2688pub fn PullToRefresh(
2697 state: Rc<PullToRefreshState>,
2698 modifier: Modifier,
2699 on_refresh: Rc<dyn Fn()>,
2700 content: View,
2701 config: PullToRefreshConfig,
2702) -> View {
2703 let pull = state.pull_offset();
2704 let refreshing = state.is_refreshing();
2705 let threshold = config.threshold;
2706
2707 if state.triggered.get() && !refreshing && pull < threshold {
2708 state.triggered.set(false);
2709 }
2710
2711 if !refreshing && !state.triggered.get() && pull >= threshold {
2712 state.triggered.set(true);
2713 state.refreshing.set(true);
2714 (on_refresh)();
2715 }
2716
2717 let frac_key = format!("ptr_frac_{}", Rc::as_ptr(&state) as u64);
2718 let raw_frac = if refreshing {
2719 1.0
2720 } else if pull > 0.0 {
2721 pull / threshold
2722 } else {
2723 0.0
2724 };
2725 let distance_fraction = animate_f32_from(frac_key, 0.0, raw_frac, theme().motion.color);
2726
2727 let adjusted_percent = (distance_fraction.min(1.0) - 0.4).max(0.0) * 5.0 / 3.0;
2728 let overshoot_percent = (distance_fraction - 1.0).max(0.0);
2729 let linear_tension = overshoot_percent.min(2.0);
2730 let tension_percent = linear_tension - linear_tension.powi(2) / 4.0;
2731 let rotation_turns = (-0.25 + 0.4 * adjusted_percent + tension_percent) * 0.5;
2732 let spinner_rotation_rad = rotation_turns * std::f32::consts::TAU;
2734
2735 let indicator_h = distance_fraction * threshold;
2737 let comp_scale = adjusted_percent.min(1.0);
2738 let icon_size = if refreshing {
2739 24.0
2740 } else {
2741 (16.0 + comp_scale * 8.0).min(24.0)
2742 };
2743 let rotation = if refreshing {
2744 animate_f32_from(
2745 "ptr_spin",
2746 0.0,
2747 std::f32::consts::TAU,
2748 AnimationSpec::tween(Duration::from_millis(1000), Easing::Linear)
2749 .repeated(RepeatableSpec::infinite()),
2750 )
2751 } else {
2752 spinner_rotation_rad
2753 };
2754 let alpha = if refreshing {
2755 1.0
2756 } else if distance_fraction >= 1.0 {
2757 1.0
2758 } else {
2759 0.3
2760 };
2761 Column(modifier.align_items(config.content_alignment)).child((
2762 if distance_fraction > 0.01 {
2763 Box(Modifier::new()
2764 .fill_max_width()
2765 .height(indicator_h)
2766 .align_items(AlignItems::Center)
2767 .justify_content(JustifyContent::Center))
2768 .child(
2769 Box(Modifier::new()
2770 .size(icon_size, icon_size)
2771 .translate(icon_size * 0.5, icon_size * 0.5)
2772 .rotate(rotation)
2773 .translate(-icon_size * 0.5, -icon_size * 0.5))
2774 .child(if refreshing {
2775 Icon(Symbol::new("refresh", '\u{E5D5}'))
2776 .size(24.0)
2777 .color(config.indicator_color)
2778 } else {
2779 Icon(Symbol::new("arrow_downward", '\u{E5DB}'))
2780 .size(icon_size)
2781 .color(config.indicator_color.with_alpha_f32(alpha))
2782 }),
2783 )
2784 } else {
2785 Box(Modifier::new())
2786 },
2787 content,
2788 ))
2789}
2790
2791pub struct DatePickerState {
2793 pub year: Signal<i32>,
2794 pub month: Signal<u32>, pub day: Signal<u32>,
2796}
2797
2798impl DatePickerState {
2799 pub fn new(year: i32, month: u32, day: u32) -> Self {
2800 Self {
2801 year: signal(year),
2802 month: signal(month.clamp(1, 12)),
2803 day: signal(day.clamp(1, 31)),
2804 }
2805 }
2806
2807 pub fn selected_date(&self) -> (i32, u32, u32) {
2808 (self.year.get(), self.month.get(), self.day.get())
2809 }
2810}
2811
2812fn days_in_month(year: i32, month: u32) -> u32 {
2813 match month {
2814 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
2815 4 | 6 | 9 | 11 => 30,
2816 2 => {
2817 if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
2818 29
2819 } else {
2820 28
2821 }
2822 }
2823 _ => 30,
2824 }
2825}
2826
2827fn first_day_of_month(year: i32, month: u32) -> u32 {
2830 let m = month as i32;
2831 let (y, adj_m) = if m <= 2 {
2832 (year - 1, m + 12)
2833 } else {
2834 (year, m)
2835 };
2836 let k = y % 100;
2837 let j = y / 100;
2838 let h = (1 + (13 * (adj_m + 1)) / 5 + k + k / 4 + j / 4 + 5 * j) % 7;
2839 ((h + 5) % 7) as u32
2841}
2842
2843struct ReposeDate {
2845 year: i32,
2846 month: u32,
2847 day: u32,
2848}
2849
2850impl ReposeDate {
2851 fn now() -> Self {
2853 let duration = web_time::SystemTime::now()
2854 .duration_since(web_time::UNIX_EPOCH)
2855 .unwrap_or_default();
2856 let days = (duration.as_secs() / 86_400) as i64;
2857 let z = days + 719468;
2859 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
2860 let doe = (z - era * 146_097) as u64;
2861 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
2862 let y = (yoe as i64) + era * 400;
2863 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2864 let mp = (5 * doy + 2) / 153;
2865 let d = doy - (153 * mp + 2) / 5 + 1;
2866 let m = if mp < 10 { mp + 3 } else { mp - 9 };
2867 let y = if m <= 2 { y + 1 } else { y };
2868 Self {
2869 year: y as i32,
2870 month: m as u32,
2871 day: d as u32,
2872 }
2873 }
2874}
2875
2876const MONTH_NAMES: [&str; 12] = [
2877 "January",
2878 "February",
2879 "March",
2880 "April",
2881 "May",
2882 "June",
2883 "July",
2884 "August",
2885 "September",
2886 "October",
2887 "November",
2888 "December",
2889];
2890
2891const DOW_HEADERS: [&str; 7] = ["M", "T", "W", "T", "F", "S", "S"];
2892
2893#[derive(Clone)]
2895pub struct DatePickerColors {
2896 pub container_color: Color,
2897 pub header_color: Color,
2898 pub weekday_color: Color,
2899 pub day_color: Color,
2900 pub selected_day_color: Color,
2901 pub selected_day_container_color: Color,
2902 pub today_content_color: Color,
2903 pub today_border_color: Color,
2904 pub navigation_color: Color,
2905 pub year_selected_container_color: Color,
2906 pub year_selected_content_color: Color,
2907 pub year_unselected_content_color: Color,
2908}
2909
2910impl Default for DatePickerColors {
2911 fn default() -> Self {
2912 Self {
2913 container_color: DatePickerDefaults::container_color(),
2914 header_color: DatePickerDefaults::header_color(),
2915 weekday_color: DatePickerDefaults::weekday_color(),
2916 day_color: DatePickerDefaults::day_color(),
2917 selected_day_color: DatePickerDefaults::selected_day_color(),
2918 selected_day_container_color: DatePickerDefaults::selected_day_container_color(),
2919 today_content_color: DatePickerDefaults::today_content_color(),
2920 today_border_color: DatePickerDefaults::today_border_color(),
2921 navigation_color: DatePickerDefaults::header_color(),
2922 year_selected_container_color: DatePickerDefaults::year_selected_container_color(),
2923 year_selected_content_color: DatePickerDefaults::year_selected_content_color(),
2924 year_unselected_content_color: DatePickerDefaults::year_unselected_content_color(),
2925 }
2926 }
2927}
2928
2929#[derive(Clone)]
2931pub struct DatePickerConfig {
2932 pub modifier: Modifier,
2933 pub colors: DatePickerColors,
2934 pub show_mode_toggle: bool,
2935}
2936
2937impl Default for DatePickerConfig {
2938 fn default() -> Self {
2939 Self {
2940 modifier: Modifier::new(),
2941 colors: DatePickerColors::default(),
2942 show_mode_toggle: true,
2943 }
2944 }
2945}
2946
2947pub fn DatePicker(
2950 state: Rc<DatePickerState>,
2951 on_confirm: Rc<dyn Fn(i32, u32, u32)>,
2952 on_dismiss: Rc<dyn Fn()>,
2953 config: DatePickerConfig,
2954) -> View {
2955 let th = theme();
2956 let (year, month, day) = state.selected_date();
2957 let dim = days_in_month(year, month);
2958 let start_dow = first_day_of_month(year, month);
2959
2960 let prev_year = {
2962 let s = state.clone();
2963 move || {
2964 s.year.set(s.year.get() - 1);
2965 let d = days_in_month(s.year.get(), s.month.get());
2966 if s.day.get() > d {
2967 s.day.set(d);
2968 }
2969 }
2970 };
2971 let next_year = {
2972 let s = state.clone();
2973 move || {
2974 s.year.set(s.year.get() + 1);
2975 let d = days_in_month(s.year.get(), s.month.get());
2976 if s.day.get() > d {
2977 s.day.set(d);
2978 }
2979 }
2980 };
2981
2982 let prev_month = {
2983 let s = state.clone();
2984 move || {
2985 if s.month.get() == 1 {
2986 s.year.set(s.year.get() - 1);
2987 s.month.set(12);
2988 } else {
2989 s.month.set(s.month.get() - 1);
2990 }
2991 let d = days_in_month(s.year.get(), s.month.get());
2992 if s.day.get() > d {
2993 s.day.set(d);
2994 }
2995 }
2996 };
2997
2998 let next_month = {
2999 let s = state.clone();
3000 move || {
3001 if s.month.get() == 12 {
3002 s.year.set(s.year.get() + 1);
3003 s.month.set(1);
3004 } else {
3005 s.month.set(s.month.get() + 1);
3006 }
3007 let d = days_in_month(s.year.get(), s.month.get());
3008 if s.day.get() > d {
3009 s.day.set(d);
3010 }
3011 }
3012 };
3013
3014 let now = ReposeDate::now();
3016 let today = (now.year, now.month, now.day);
3017
3018 Column(config.modifier.padding(16.0)).child((
3019 Row(Modifier::new()
3021 .fill_max_width()
3022 .align_items(AlignItems::Center))
3023 .child((
3024 IconButton(
3025 Box(Modifier::new())
3026 .child(Text("â—€").color(config.colors.navigation_color).size(16.0)),
3027 prev_month,
3028 IconButtonConfig::default(),
3029 ),
3030 Spacer(),
3031 Column(Modifier::new().align_items(AlignItems::Center)).child((
3032 Text(MONTH_NAMES[(month - 1) as usize].to_string())
3033 .size(th.typography.title_medium)
3034 .color(config.colors.header_color),
3035 Row(Modifier::new().gap(8.0).align_items(AlignItems::Center)).child((
3036 IconButton(
3037 Box(Modifier::new())
3038 .child(Text("‹").color(config.colors.navigation_color).size(14.0)),
3039 prev_year,
3040 IconButtonConfig::default(),
3041 ),
3042 Text(year.to_string())
3043 .size(th.typography.body_small)
3044 .color(th.on_surface_variant),
3045 IconButton(
3046 Box(Modifier::new())
3047 .child(Text("›").color(config.colors.navigation_color).size(14.0)),
3048 next_year,
3049 IconButtonConfig::default(),
3050 ),
3051 )),
3052 )),
3053 Spacer(),
3054 IconButton(
3055 Box(Modifier::new())
3056 .child(Text("â–¶").color(config.colors.navigation_color).size(16.0)),
3057 next_month,
3058 IconButtonConfig::default(),
3059 ),
3060 )),
3061 Box(Modifier::new().fill_max_width().height(12.0)),
3062 Column(Modifier::new()).child({
3064 let mut rows: Vec<View> = Vec::new();
3065 let dow_headers: Vec<View> = DOW_HEADERS
3067 .iter()
3068 .map(|d| {
3069 Box(Modifier::new()
3070 .width(40.0)
3071 .height(40.0)
3072 .align_items(AlignItems::Center)
3073 .justify_content(JustifyContent::Center))
3074 .child(
3075 Text(d.to_string())
3076 .size(th.typography.label_small)
3077 .color(config.colors.weekday_color),
3078 )
3079 })
3080 .collect();
3081 rows.push(Row(Modifier::new()).with_children(dow_headers));
3082
3083 let total_cells = start_dow + dim;
3085 let num_rows = total_cells.div_ceil(7).min(6);
3086 for w in 0..num_rows {
3087 let mut week: Vec<View> = Vec::new();
3088 for d in 0..7 {
3089 let cell_idx = w * 7 + d;
3090 if cell_idx < start_dow {
3091 week.push(Box(Modifier::new().width(40.0).height(40.0)));
3092 } else {
3093 let day_num = (cell_idx - start_dow + 1) as i32;
3094 if day_num <= dim as i32 {
3095 let is_selected = day_num == day as i32;
3096 let is_today =
3097 today.0 == year && today.1 == month && today.2 == day_num as u32;
3098 let s = state.clone();
3099 week.push(
3100 Box(Modifier::new()
3101 .width(40.0)
3102 .height(40.0)
3103 .background(if is_selected {
3104 config.colors.selected_day_container_color
3105 } else {
3106 Color::TRANSPARENT
3107 })
3108 .clip_rounded(20.0)
3109 .align_items(AlignItems::Center)
3110 .justify_content(JustifyContent::Center)
3111 .clickable()
3112 .on_click(move || {
3113 s.day.set(day_num as u32);
3114 }))
3115 .child({
3116 let mut t = Text(day_num.to_string())
3117 .size(th.typography.body_medium)
3118 .color(if is_selected {
3119 config.colors.selected_day_color
3120 } else {
3121 config.colors.day_color
3122 });
3123 if is_today && !is_selected {
3124 t = t.modifier(Modifier::new().border(
3125 1.0,
3126 config.colors.today_border_color,
3127 10.0,
3128 ));
3129 }
3130 t
3131 }),
3132 );
3133 } else {
3134 week.push(Box(Modifier::new().width(40.0).height(40.0)));
3135 }
3136 }
3137 }
3138 rows.push(Row(Modifier::new()).with_children(week));
3139 }
3140 rows
3141 }),
3142 Box(Modifier::new().fill_max_width().height(12.0)),
3143 Row(Modifier::new()
3145 .fill_max_width()
3146 .justify_content(JustifyContent::End)
3147 .gap(8.0))
3148 .child((
3149 TextButton(
3150 Modifier::new(),
3151 {
3152 let on_dismiss = on_dismiss.clone();
3153 move || (on_dismiss)()
3154 },
3155 ButtonConfig::default(),
3156 || Text("Cancel").size(14.0),
3157 ),
3158 Button(
3159 Modifier::new(),
3160 {
3161 let on_confirm = on_confirm.clone();
3162 let s = state.clone();
3163 move || {
3164 let (y, m, d) = s.selected_date();
3165 on_confirm(y, m, d);
3166 }
3167 },
3168 ButtonConfig::default(),
3169 || Text("OK").size(14.0),
3170 ),
3171 )),
3172 ))
3173}
3174
3175pub struct TimePickerState {
3177 pub hour: Signal<u32>,
3178 pub minute: Signal<u32>,
3179 pub is_am: Signal<bool>,
3180}
3181
3182impl TimePickerState {
3183 pub fn new(hour: u32, minute: u32) -> Self {
3184 let h = hour % 12;
3185 let am = hour < 12;
3186 Self {
3187 hour: signal(if h == 0 { 12 } else { h }),
3188 minute: signal(minute.min(59)),
3189 is_am: signal(am),
3190 }
3191 }
3192
3193 pub fn selected_time(&self) -> (u32, u32) {
3194 let mut h = self.hour.get();
3195 if !self.is_am.get() {
3196 h = (h % 12) + 12;
3197 } else if h == 12 {
3198 h = 0;
3199 }
3200 (h, self.minute.get())
3201 }
3202}
3203
3204#[derive(Clone, Copy, PartialEq, Debug)]
3206pub enum TimePickerLayoutType {
3207 Horizontal,
3208 Vertical,
3209}
3210
3211#[derive(Clone)]
3213pub struct TimePickerColors {
3214 pub clock_dial_color: Color,
3215 pub clock_dial_selected_content_color: Color,
3216 pub clock_dial_unselected_content_color: Color,
3217 pub selector_color: Color,
3218 pub container_color: Color,
3219 pub period_selector_border_color: Color,
3220 pub period_selector_selected_container_color: Color,
3221 pub period_selector_unselected_container_color: Color,
3222 pub period_selector_selected_content_color: Color,
3223 pub period_selector_unselected_content_color: Color,
3224 pub time_selector_selected_container_color: Color,
3225 pub time_selector_unselected_container_color: Color,
3226 pub time_selector_selected_content_color: Color,
3227 pub time_selector_unselected_content_color: Color,
3228}
3229
3230impl Default for TimePickerColors {
3231 fn default() -> Self {
3232 Self {
3233 clock_dial_color: TimePickerDefaults::clock_dial_color(),
3234 clock_dial_selected_content_color:
3235 TimePickerDefaults::clock_dial_selected_content_color(),
3236 clock_dial_unselected_content_color:
3237 TimePickerDefaults::clock_dial_unselected_content_color(),
3238 selector_color: TimePickerDefaults::selector_color(),
3239 container_color: TimePickerDefaults::container_color(),
3240 period_selector_border_color: TimePickerDefaults::period_selector_border_color(),
3241 period_selector_selected_container_color:
3242 TimePickerDefaults::period_selector_selected_container_color(),
3243 period_selector_unselected_container_color:
3244 TimePickerDefaults::period_selector_unselected_container_color(),
3245 period_selector_selected_content_color:
3246 TimePickerDefaults::period_selector_selected_content_color(),
3247 period_selector_unselected_content_color:
3248 TimePickerDefaults::period_selector_unselected_content_color(),
3249 time_selector_selected_container_color:
3250 TimePickerDefaults::time_selector_selected_container_color(),
3251 time_selector_unselected_container_color:
3252 TimePickerDefaults::time_selector_unselected_container_color(),
3253 time_selector_selected_content_color:
3254 TimePickerDefaults::time_selector_selected_content_color(),
3255 time_selector_unselected_content_color:
3256 TimePickerDefaults::time_selector_unselected_content_color(),
3257 }
3258 }
3259}
3260
3261#[derive(Clone)]
3263pub struct TimePickerConfig {
3264 pub modifier: Modifier,
3265 pub colors: TimePickerColors,
3266 pub layout_type: TimePickerLayoutType,
3267}
3268
3269impl Default for TimePickerConfig {
3270 fn default() -> Self {
3271 Self {
3272 modifier: Modifier::new(),
3273 colors: TimePickerColors::default(),
3274 layout_type: TimePickerLayoutType::Vertical,
3275 }
3276 }
3277}
3278
3279pub fn TimePicker(
3281 state: Rc<TimePickerState>,
3282 on_confirm: Rc<dyn Fn(u32, u32)>,
3283 on_dismiss: Rc<dyn Fn()>,
3284 config: TimePickerConfig,
3285) -> View {
3286 let th = theme();
3287 let hour = state.hour.get();
3288 let minute = state.minute.get();
3289 let is_am = state.is_am.get();
3290
3291 let hour_str = format!("{:02}", hour);
3292 let min_str = format!("{:02}", minute);
3293
3294 Column(
3295 config
3296 .modifier
3297 .width(256.0)
3298 .padding(24.0)
3299 .align_items(AlignItems::Center),
3300 )
3301 .child((
3302 Row(Modifier::new().align_items(AlignItems::Center)).child((
3304 Box(Modifier::new()
3305 .clickable()
3306 .on_click({
3307 let s = state.clone();
3308 move || s.hour.set((s.hour.get() % 12) + 1)
3309 })
3310 .padding(8.0))
3311 .child(
3312 Text(hour_str)
3313 .size(48.0)
3314 .color(config.colors.clock_dial_unselected_content_color)
3315 .single_line(),
3316 ),
3317 Text(":")
3318 .size(48.0)
3319 .color(config.colors.clock_dial_unselected_content_color)
3320 .single_line(),
3321 Box(Modifier::new()
3322 .clickable()
3323 .on_click({
3324 let s = state.clone();
3325 move || s.minute.set((s.minute.get() + 1) % 60)
3326 })
3327 .padding(8.0))
3328 .child(
3329 Text(min_str)
3330 .size(48.0)
3331 .color(config.colors.clock_dial_unselected_content_color)
3332 .single_line(),
3333 ),
3334 )),
3335 Box(Modifier::new().fill_max_width().height(16.0)),
3336 Row(Modifier::new().align_items(AlignItems::Center)).child((
3338 Box(Modifier::new()
3339 .padding_values(PaddingValues {
3340 left: 12.0,
3341 right: 12.0,
3342 top: 4.0,
3343 bottom: 4.0,
3344 })
3345 .background(if is_am {
3346 config.colors.period_selector_selected_container_color
3347 } else {
3348 Color::TRANSPARENT
3349 })
3350 .clip_rounded(8.0)
3351 .clickable()
3352 .on_click({
3353 let s = state.clone();
3354 move || {
3355 if !s.is_am.get() {
3356 s.is_am.set(true);
3357 let h = s.hour.get();
3358 s.hour.set(if h == 12 { 12 } else { (h + 12) % 24 });
3359 if s.hour.get() == 0 {
3360 s.hour.set(12);
3361 }
3362 }
3363 }
3364 }))
3365 .child(Text("AM").size(th.typography.label_large).color(if is_am {
3366 config.colors.period_selector_selected_content_color
3367 } else {
3368 config.colors.period_selector_unselected_content_color
3369 })),
3370 Box(Modifier::new().width(8.0).height(1.0)),
3371 Box(Modifier::new()
3372 .padding_values(PaddingValues {
3373 left: 12.0,
3374 right: 12.0,
3375 top: 4.0,
3376 bottom: 4.0,
3377 })
3378 .background(if !is_am {
3379 config.colors.period_selector_selected_container_color
3380 } else {
3381 Color::TRANSPARENT
3382 })
3383 .clip_rounded(8.0)
3384 .clickable()
3385 .on_click({
3386 let s = state.clone();
3387 move || {
3388 if s.is_am.get() {
3389 s.is_am.set(false);
3390 let h = s.hour.get();
3391 s.hour.set(if h == 12 { 12 } else { (h + 12) % 24 });
3392 if s.hour.get() == 0 {
3393 s.hour.set(12);
3394 }
3395 }
3396 }
3397 }))
3398 .child(Text("PM").size(th.typography.label_large).color(if !is_am {
3399 config.colors.period_selector_selected_content_color
3400 } else {
3401 config.colors.period_selector_unselected_content_color
3402 })),
3403 )),
3404 Box(Modifier::new().fill_max_width().height(16.0)),
3405 Row(Modifier::new().fill_max_width()).child((
3406 Spacer(),
3407 Box(Modifier::new().padding(8.0).clickable().on_click({
3408 let on_dismiss = on_dismiss.clone();
3409 move || on_dismiss()
3410 }))
3411 .child(
3412 Text("Cancel")
3413 .color(config.colors.selector_color)
3414 .size(th.typography.label_large)
3415 .single_line(),
3416 ),
3417 Box(Modifier::new().width(8.0).height(1.0)),
3418 Box(Modifier::new().padding(8.0).clickable().on_click({
3419 let on_confirm = on_confirm.clone();
3420 let state = state.clone();
3421 move || {
3422 let (h, m) = state.selected_time();
3423 on_confirm(h, m);
3424 }
3425 }))
3426 .child(
3427 Text("OK")
3428 .color(config.colors.selector_color)
3429 .size(th.typography.label_large)
3430 .single_line(),
3431 ),
3432 )),
3433 ))
3434}
3435
3436pub struct NavRailItem {
3438 pub icon: View,
3439 pub label: String,
3440 pub on_click: Rc<dyn Fn()>,
3441 pub badge: Option<View>,
3442 pub enabled: bool,
3443 pub interaction_source: Option<MutableInteractionSource>,
3444}
3445
3446static NAVRAIL_COUNTER: AtomicU64 = AtomicU64::new(0);
3447static FILTERCHIP_COUNTER: AtomicU64 = AtomicU64::new(0);
3448
3449pub fn NavigationRail(
3454 selected_index: usize,
3455 items: Vec<NavRailItem>,
3456 header: Option<View>,
3457 fab: Option<View>,
3458 config: NavigationRailConfig,
3459) -> View {
3460 let th = theme();
3461 let id = remember(|| NAVRAIL_COUNTER.fetch_add(1, Ordering::Relaxed));
3462 let default_effects = AnimationSpec::spring_crit(40.0);
3463
3464 let mut top_children: Vec<View> = Vec::new();
3465 let mut item_views: Vec<View> = Vec::new();
3466
3467 let has_header = header.is_some();
3468 let has_fab = fab.is_some();
3469
3470 if let Some(h) = header {
3471 top_children.push(
3472 Box(Modifier::new()
3473 .padding_values(PaddingValues {
3474 left: 12.0,
3475 right: 12.0,
3476 top: 12.0,
3477 bottom: 12.0,
3478 })
3479 .align_self(AlignSelf::Center))
3480 .child(h),
3481 );
3482 }
3483
3484 if let Some(f) = fab {
3485 top_children.push(
3486 Box(Modifier::new()
3487 .padding_values(PaddingValues {
3488 left: 12.0,
3489 right: 12.0,
3490 top: 8.0,
3491 bottom: 8.0,
3492 })
3493 .align_self(AlignSelf::Center))
3494 .child(f),
3495 );
3496 }
3497
3498 if has_header || has_fab {
3499 top_children.push(Box(Modifier::new()
3500 .fill_max_width()
3501 .height(1.0)
3502 .background(th.outline_variant)));
3503 }
3504
3505 for (i, item) in items.into_iter().enumerate() {
3506 let selected = i == selected_index;
3507 let is_enabled = item.enabled;
3508
3509 let fg = animate_color(
3510 format!("nr_fg_{}_{}", id, i),
3511 if selected {
3512 config.selected_icon_color
3513 } else {
3514 config.unselected_icon_color
3515 },
3516 default_effects,
3517 );
3518 let fg_label = animate_color(
3519 format!("nr_fl_{}_{}", id, i),
3520 if selected {
3521 config.selected_text_color
3522 } else {
3523 config.unselected_text_color
3524 },
3525 default_effects,
3526 );
3527 let bg = animate_color(
3528 format!("nr_bg_{}_{}", id, i),
3529 if selected {
3530 config.indicator_color
3531 } else {
3532 Color::TRANSPARENT
3533 },
3534 default_effects,
3535 );
3536
3537 let cb = item.on_click.clone();
3538 let nr_source: Rc<MutableInteractionSource> = item
3539 .interaction_source
3540 .clone()
3541 .map(Rc::new)
3542 .unwrap_or_else(|| remember(MutableInteractionSource::new));
3543
3544 let mut item_m = Modifier::new()
3545 .fill_max_width()
3546 .padding_values(PaddingValues {
3547 left: 4.0,
3548 right: 4.0,
3549 top: 4.0,
3550 bottom: 4.0,
3551 })
3552 .align_items(AlignItems::Center)
3553 .justify_content(JustifyContent::Center)
3554 .background(bg)
3555 .state_colors(StateColors {
3556 default: Color::TRANSPARENT,
3557 hovered: th.on_surface.with_alpha_f32(0.08),
3558 pressed: th.on_surface.with_alpha_f32(0.12),
3559 disabled: Color::TRANSPARENT,
3560 })
3561 .clip_rounded(config.item_radius)
3562 .interaction_source(&*nr_source)
3563 .semantics(Semantics::new(Role::Tab).with_label(&item.label));
3564
3565 if is_enabled {
3566 item_m = item_m.clickable().on_click({
3567 let cb = cb.clone();
3568 move || cb()
3569 });
3570 }
3571
3572 item_views.push(
3573 Column(item_m).child((
3574 Stack(Modifier::new()).child((
3575 Box(Modifier::new().size(24.0, 24.0))
3576 .child(with_content_color(fg, move || item.icon)),
3577 item.badge
3578 .map(|b| {
3579 Box(Modifier::new()
3580 .absolute()
3581 .offset(None, None, None, Some(0.0)))
3582 .child(b)
3583 })
3584 .unwrap_or(Box(Modifier::new())),
3585 )),
3586 Box(Modifier::new().fill_max_width().height(4.0)),
3587 Text(item.label)
3588 .color(fg_label)
3589 .size(th.typography.label_medium)
3590 .single_line(),
3591 )),
3592 );
3593 }
3594
3595 Column(
3596 Modifier::new()
3597 .width(config.width)
3598 .fill_max_height()
3599 .background(config.container_color)
3600 .align_items(AlignItems::Center)
3601 .semantics(Semantics::new(Role::Container).with_selectable_group())
3602 .then(config.modifier),
3603 )
3604 .child((
3605 Column(Modifier::new()).with_children(top_children),
3606 Box(Modifier::new().flex_grow(1.0)).child(
3607 Column(
3608 Modifier::new()
3609 .fill_max_size()
3610 .justify_content(JustifyContent::SpaceBetween)
3611 .align_items(AlignItems::Center),
3612 )
3613 .with_children(item_views),
3614 ),
3615 ))
3616}
3617
3618pub struct SwipeToDismissState {
3620 anim: Rc<RefCell<AnimatedValue<f32>>>,
3621 dismiss_handled: Rc<RefCell<bool>>,
3624}
3625
3626impl Default for SwipeToDismissState {
3627 fn default() -> Self {
3628 Self::new()
3629 }
3630}
3631
3632impl SwipeToDismissState {
3633 pub fn new() -> Self {
3634 Self {
3635 anim: Rc::new(RefCell::new(AnimatedValue::new(
3636 0.0,
3637 AnimationSpec::spring_gentle(),
3638 ))),
3639 dismiss_handled: Rc::new(RefCell::new(true)),
3640 }
3641 }
3642
3643 pub fn offset(&self) -> f32 {
3646 let mut anim = self.anim.borrow_mut();
3647 if anim.update() {
3648 request_frame();
3649 }
3650 *anim.get()
3651 }
3652
3653 pub fn set_offset_instant(&self, off: f32) {
3655 self.anim.borrow_mut().snap_to(off);
3656 request_frame();
3657 }
3658
3659 pub fn is_dismissed(&self) -> bool {
3661 *self.anim.borrow().get() < -150.0
3662 }
3663
3664 pub fn dismiss(&self) {
3666 *self.dismiss_handled.borrow_mut() = false;
3667 self.anim.borrow_mut().set_target(-300.0);
3668 request_frame();
3669 }
3670
3671 pub fn dismiss_to(&self, offset: f32) {
3673 *self.dismiss_handled.borrow_mut() = false;
3674 self.anim.borrow_mut().set_target(-offset);
3675 request_frame();
3676 }
3677
3678 pub fn reset(&self) {
3680 *self.dismiss_handled.borrow_mut() = true;
3681 self.anim.borrow_mut().set_target(0.0);
3682 request_frame();
3683 }
3684
3685 fn try_handle_dismiss(&self, on_dismiss: &Option<Rc<dyn Fn()>>) {
3687 let anim = self.anim.borrow();
3688 if !anim.is_animating() && !*self.dismiss_handled.borrow() && *anim.get() < -150.0 {
3689 *self.dismiss_handled.borrow_mut() = true;
3690 if let Some(cb) = on_dismiss {
3691 cb();
3692 }
3693 }
3694 }
3695
3696 fn try_handle_dismiss_with_threshold(&self, on_dismiss: &Option<Rc<dyn Fn()>>, threshold: f32) {
3698 let anim = self.anim.borrow();
3699 if !anim.is_animating() && !*self.dismiss_handled.borrow() && *anim.get() < -threshold {
3700 *self.dismiss_handled.borrow_mut() = true;
3701 if let Some(cb) = on_dismiss {
3702 cb();
3703 }
3704 }
3705 }
3706}
3707
3708pub fn SwipeToDismiss(
3712 state: Rc<SwipeToDismissState>,
3713 on_dismiss: Option<Rc<dyn Fn()>>,
3714 background: View,
3715 content: View,
3716 modifier: Modifier,
3717 config: SwipeToDismissConfig,
3718) -> View {
3719 let offset = state.offset();
3720 state.try_handle_dismiss_with_threshold(&on_dismiss, config.dismiss_threshold);
3721
3722 let drag_start_x = remember_with_key("swipe_drag_start", || RefCell::new(None::<f32>));
3723 let drag_base = remember_with_key("swipe_drag_base", || RefCell::new(0.0f32));
3724
3725 let st = state.clone();
3726 let on_down = {
3727 let d = drag_start_x.clone();
3728 let base = drag_base.clone();
3729 move |e: PointerEvent| {
3730 *d.borrow_mut() = Some(e.position.x);
3731 *base.borrow_mut() = *st.anim.borrow().get();
3732 }
3733 };
3734
3735 let st = state.clone();
3736 let on_move = {
3737 let d = drag_start_x.clone();
3738 let base = drag_base.clone();
3739 move |e: PointerEvent| {
3740 if let Some(start) = *d.borrow() {
3741 let dx = e.position.x - start;
3742 st.set_offset_instant(*base.borrow() + dx);
3743 }
3744 }
3745 };
3746
3747 let st = state.clone();
3748 let on_up = {
3749 let d = drag_start_x.clone();
3750 let dt = config.dismiss_threshold;
3751 move |_e: PointerEvent| {
3752 *d.borrow_mut() = None;
3753 let off = *st.anim.borrow().get();
3754 if off > -dt * 0.333 {
3755 st.reset();
3756 } else {
3757 st.dismiss();
3758 }
3759 }
3760 };
3761
3762 let display_offset = offset.max(-config.dismissed_offset).min(0.0);
3763
3764 let content_modifier = {
3765 let mut m = Modifier::new()
3766 .fill_max_width()
3767 .translate(display_offset, 0.0);
3768 if config.gestures_enabled {
3769 m = m
3770 .on_pointer_down(on_down)
3771 .on_pointer_move(on_move)
3772 .on_pointer_up(on_up);
3773 }
3774 m
3775 };
3776
3777 Stack(modifier.fill_max_width()).child((
3778 Box(Modifier::new().fill_max_size().absolute()).child(background),
3779 Box(content_modifier).child(content),
3780 ))
3781}
3782
3783#[derive(Clone, Debug)]
3789pub struct CarouselConfig {
3790 pub modifier: Modifier,
3791}
3792
3793impl Default for CarouselConfig {
3794 fn default() -> Self {
3795 Self {
3796 modifier: Modifier::new(),
3797 }
3798 }
3799}
3800
3801pub fn Carousel<T, F>(
3806 items: Vec<T>,
3807 item_width: f32,
3808 peek_amount: f32,
3809 state: Rc<LazyRowState>,
3810 item_builder: F,
3811 config: CarouselConfig,
3812) -> View
3813where
3814 T: Clone + 'static,
3815 F: Fn(T, usize) -> View + 'static,
3816{
3817 let padded_modifier = config.modifier.padding_values(PaddingValues {
3818 left: peek_amount,
3819 right: peek_amount,
3820 top: 0.0,
3821 bottom: 0.0,
3822 });
3823
3824 LazyRow(
3825 items,
3826 item_width,
3827 item_builder,
3828 LazyRowConfig {
3829 state,
3830 modifier: padded_modifier,
3831 ..Default::default()
3832 },
3833 )
3834}