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