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