1use std::cell::Cell;
5use std::collections::hash_map::DefaultHasher;
6use std::fmt;
7use std::hash::{Hash, Hasher};
8use std::rc::Rc;
9
10use ravenrs_animation::Transition;
11use ravenrs_core::{Border, Color, CursorIcon, Effect, Insets, Key, Point, Role};
12use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
13use ravenrs_reactive::{Binding, State};
14use ravenrs_style::{Semantic, TextStyle, Theme, Weight};
15
16use crate::context::{BuildCx, NodeId, Paint, Placement, QuadPaint, Semantics};
17use crate::overlay::{Entry, MenuList, PanelOf, panel};
18use crate::{
19 Button, ButtonSize, ButtonStyle, IconButton, IconName, MenuItem, Motion, TextField, Tone, View, motion,
20 util,
21};
22
23pub struct ContextMenu<V> {
25 content: V,
26 entries: Vec<Entry>,
27}
28
29impl<V> fmt::Debug for ContextMenu<V> {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 f.debug_struct("ContextMenu").field("entries", &self.entries.len()).finish_non_exhaustive()
32 }
33}
34
35#[cfg_attr(feature = "live", ravenrs_live::expose)]
36impl<V: View> ContextMenu<V> {
37 pub fn new(content: V) -> Self {
39 Self { content, entries: Vec::new() }
40 }
41
42 pub fn item(mut self, item: MenuItem) -> Self {
44 self.entries.push(Entry::Item(item));
45 self
46 }
47
48 pub fn separator(mut self) -> Self {
50 self.entries.push(Entry::Separator);
51 self
52 }
53}
54
55impl<V: View> View for ContextMenu<V> {
56 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
57 let at: State<Option<Point>> = cx.use_state(|| None);
58 let child = cx.child(&self.content);
59 let node = cx.container(
60 LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
61 &[child],
62 Paint::None,
63 );
64 let open = at.clone();
65 cx.on_context_menu(node, Rc::new(move |p| open.set(Some(p))));
66 if let Some(p) = at.get() {
67 let close: Rc<dyn Fn()> = {
68 let at = at.clone();
69 Rc::new(move || at.set(None))
70 };
71 cx.overlay(
72 node,
73 Placement::At(p),
74 &MenuList { entries: &self.entries, close: close.clone(), on_side: None },
75 Some(close),
76 );
77 }
78 node
79 }
80}
81
82pub struct HoverCard<A, C> {
85 trigger: A,
86 content: C,
87}
88
89impl<A, C> fmt::Debug for HoverCard<A, C> {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 f.write_str("HoverCard")
92 }
93}
94
95#[cfg_attr(feature = "live", ravenrs_live::expose)]
96impl<A: View, C: View> HoverCard<A, C> {
97 pub fn new(trigger: A, content: C) -> Self {
99 Self { trigger, content }
100 }
101}
102
103impl<A: View, C: View> View for HoverCard<A, C> {
104 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
105 let child = cx.child(&self.trigger);
106 let node = cx.container(
107 LayoutStyle { align_self: Some(Align::Fit), ..LayoutStyle::stack(Axis::Horizontal) },
108 &[child],
109 Paint::None,
110 );
111 cx.cursor(node, CursorIcon::Default);
112 if cx.is_hover_within() {
113 cx.overlay(node, Placement::Below, &PanelOf(&self.content), None);
114 }
115 node
116 }
117}
118
119#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
121#[non_exhaustive]
122#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
123pub enum Side {
124 Left,
126 #[default]
128 Right,
129}
130
131#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
133pub struct Sheet {
134 open: Binding<bool>,
135 title: String,
136 description: Option<String>,
137 content: Option<crate::AnyView>,
138 side: Side,
139 width: f32,
140}
141
142impl fmt::Debug for Sheet {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 f.debug_struct("Sheet").field("title", &self.title).finish_non_exhaustive()
145 }
146}
147
148#[cfg_attr(feature = "live", ravenrs_live::expose)]
149impl Sheet {
150 pub fn new(open: Binding<bool>, title: impl Into<String>) -> Self {
152 Self { open, title: title.into(), description: None, content: None, side: Side::Right, width: 400.0 }
153 }
154
155 pub fn description(mut self, text: impl Into<String>) -> Self {
157 self.description = Some(text.into());
158 self
159 }
160
161 pub fn content(mut self, content: impl View + 'static) -> Self {
163 self.content = Some(Box::new(content));
164 self
165 }
166
167 pub fn side(mut self, side: Side) -> Self {
169 self.side = side;
170 self
171 }
172
173 pub fn width(mut self, width: f32) -> Self {
175 self.width = width;
176 self
177 }
178}
179
180pub(crate) struct SheetLayer<'a> {
183 pub open: &'a Binding<bool>,
184 pub title: &'a str,
185 pub description: Option<&'a str>,
186 pub content: Option<&'a dyn View>,
187 pub side: Side,
188 pub width: f32,
189 pub bare: bool,
190}
191
192impl View for SheetLayer<'_> {
193 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
194 let theme = *cx.theme();
195 let s = self;
196 let viewport = cx.viewport();
197 let mut body = Vec::new();
198 if !s.bare {
199 let close = s.open.clone();
200 let title = util::label(cx, s.title, TextStyle::Title, None, theme.color(Semantic::Ink));
201 let title = cx.container(
202 LayoutStyle { grow: 1.0, ..LayoutStyle::stack(Axis::Horizontal) },
203 &[title],
204 Paint::None,
205 );
206 let x = cx.child(
207 &IconButton::new(IconName::X)
208 .label(cx.labels().close.clone())
209 .size(ButtonSize::Small)
210 .on_click(move || close.set(false)),
211 );
212 let header = cx.container(
213 LayoutStyle { align: Align::Center, spacing: 12.0, ..LayoutStyle::stack(Axis::Horizontal) },
214 &[title, x],
215 Paint::None,
216 );
217 body.push(header);
218 }
219 if let Some(d) = s.description {
220 body.push(util::label(cx, d, TextStyle::Body, None, theme.color(Semantic::InkMuted)));
221 }
222 if let Some(c) = s.content {
223 body.push(cx.child(c));
224 }
225 let panel = cx.container(
226 LayoutStyle {
227 width: Some(s.width.min(viewport.width)),
228 height: Some(viewport.height),
229 padding: if s.bare { Insets::ZERO } else { Insets::uniform(24.0) },
230 spacing: if s.bare { 0.0 } else { 16.0 },
231 align: Align::Stretch,
232 ..LayoutStyle::stack(Axis::Vertical)
233 },
234 &body,
235 Paint::Quad(QuadPaint {
236 border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
237 shadow: Some(theme.elevation.raised),
238 ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
239 }),
240 );
241 let mut semantics = Semantics::new(Role::Dialog).name(s.title).modal();
242 if let Some(d) = s.description {
243 semantics = semantics.description(d);
244 }
245 cx.semantics(panel, semantics);
246 cx.cursor(panel, CursorIcon::Default);
247 let away = s.width.min(viewport.width) * if s.side == Side::Right { 1.0 } else { -1.0 };
248 cx.motion(
249 panel,
250 Motion::new()
251 .enter(Effect::moved(away, 0.0), theme.motion.gentle)
252 .exit(Effect::moved(away, 0.0), theme.motion.base),
253 );
254 let scrim = motion::scrim(cx, viewport);
255 let backdrop = cx.container(
256 LayoutStyle {
257 width: Some(viewport.width),
258 height: Some(viewport.height),
259 justify: if s.side == Side::Right { Justify::End } else { Justify::Start },
260 ..LayoutStyle::stack(Axis::Horizontal)
261 },
262 &[scrim, panel],
263 Paint::None,
264 );
265 let close = s.open.clone();
266 cx.on_click(backdrop, Rc::new(move || close.set(false)));
267 backdrop
268 }
269}
270
271impl View for Sheet {
272 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
273 let anchor = cx.empty();
274 if self.open.get() {
275 let close = self.open.clone();
276 let layer = SheetLayer {
277 open: &self.open,
278 title: &self.title,
279 description: self.description.as_deref(),
280 content: self.content.as_deref(),
281 side: self.side,
282 width: self.width,
283 bare: false,
284 };
285 cx.overlay(anchor, Placement::Fill, &layer, Some(Rc::new(move || close.set(false))));
286 }
287 anchor
288 }
289}
290
291const SPLASH_CONTENT_WIDTH: f32 = 320.0;
293const SPLASH_ICON_SIZE: f32 = 64.0;
295const SPLASH_REST_LAG: f64 = 0.2;
297const SPLASH_OVERLAY_KEY: u64 = 0x7370_6c61_7368;
299
300#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
325pub struct SplashScreen {
326 visible: Binding<bool>,
327 name: String,
328 logo: Option<crate::AnyView>,
329 icon: Option<IconName>,
330 status: Option<String>,
331 progress: Option<f32>,
332 reveal_after: f64,
333}
334
335impl fmt::Debug for SplashScreen {
336 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337 f.debug_struct("SplashScreen").field("name", &self.name).finish_non_exhaustive()
338 }
339}
340
341#[cfg_attr(feature = "live", ravenrs_live::expose)]
342impl SplashScreen {
343 pub fn new(visible: Binding<bool>, name: impl Into<String>) -> Self {
345 Self {
346 visible,
347 name: name.into(),
348 logo: None,
349 icon: None,
350 status: None,
351 progress: None,
352 reveal_after: 0.0,
353 }
354 }
355
356 pub fn logo(mut self, logo: impl View + 'static) -> Self {
359 self.logo = Some(Box::new(logo));
360 self
361 }
362
363 pub fn icon(mut self, icon: IconName) -> Self {
365 self.icon = Some(icon);
366 self
367 }
368
369 pub fn status(mut self, status: impl Into<String>) -> Self {
371 self.status = Some(status.into());
372 self
373 }
374
375 pub fn progress(mut self, value: f32) -> Self {
377 self.progress = Some(value);
378 self
379 }
380
381 pub fn reveal_after(mut self, seconds: f64) -> Self {
386 self.reveal_after = seconds;
387 self
388 }
389}
390
391fn held_back(cx: &mut BuildCx<'_>, node: NodeId, held: bool, hidden: Effect, transition: Transition) {
393 let effect = if held { hidden } else { Effect::IDENTITY };
394 cx.motion(node, Motion::new().effect(effect, transition));
395}
396
397struct SplashStatus<'a>(&'a str, f32);
399
400impl View for SplashStatus<'_> {
401 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
402 let theme = *cx.theme();
403 let attrs = util::attrs(&theme, TextStyle::Callout, None);
404 let style = LayoutStyle { max_width: Some(self.1), ..LayoutStyle::leaf() };
405 let text = cx.text_truncated(self.0, &attrs, theme.color(Semantic::InkMuted), style);
406 let style = LayoutStyle { max_width: Some(self.1), ..LayoutStyle::stack(Axis::Horizontal) };
407 let node = cx.container(style, &[text], Paint::None);
408 cx.semantics(node, Semantics::new(Role::Status));
409 cx.motion(node, Motion::new().enter(Effect::hidden(), theme.motion.fast));
410 node
411 }
412}
413
414struct SplashBar(f32);
416
417impl View for SplashBar {
418 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
419 let bar = cx.child(&crate::ProgressBar::new(self.0));
420 let name = cx.labels().loading.clone();
421 cx.label_control(bar, &name, None);
422 bar
423 }
424}
425
426struct SplashLayer<'a>(&'a SplashScreen);
427
428impl View for SplashLayer<'_> {
429 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
430 let theme = *cx.theme();
431 let s = self.0;
432 let viewport = cx.viewport();
433 let width = (viewport.width - theme.spacing.s6 * 2.0).clamp(0.0, SPLASH_CONTENT_WIDTH);
434
435 let now = cx.now();
436 let began = cx.use_state(|| now).get();
437 let reveal = if cx.reduced_motion() { 0.0 } else { s.reveal_after };
438 let name_at = began + reveal;
439 let rest_at = if reveal > 0.0 { name_at + SPLASH_REST_LAG } else { name_at };
440 for at in [name_at, rest_at] {
441 if now < at {
442 cx.wake_at(at);
443 }
444 }
445
446 let mut content = Vec::new();
447 if let Some(logo) = &s.logo {
448 content.push(cx.child(logo.as_ref()));
449 } else if let Some(icon) = s.icon {
450 content.push(util::icon(cx, icon, SPLASH_ICON_SIZE, theme.color(Semantic::Accent)));
451 }
452 let attrs = util::attrs(&theme, TextStyle::Title, None);
453 let style = LayoutStyle { max_width: Some(width), ..LayoutStyle::leaf() };
454 let name = cx.text_truncated(&s.name, &attrs, theme.color(Semantic::Ink), style);
455 held_back(cx, name, now < name_at, Effect::hidden().with_scale(1.04), theme.motion.slow);
456 content.push(name);
457
458 if let Some(status) = &s.status {
460 let mut hasher = DefaultHasher::new();
461 status.hash(&mut hasher);
462 let status = cx.keyed_child(hasher.finish(), &SplashStatus(status, width));
463 let holder = cx.container(LayoutStyle::stack(Axis::Vertical), &[status], Paint::None);
464 held_back(cx, holder, now < rest_at, Effect::hidden(), theme.motion.base);
465 content.push(holder);
466 }
467
468 let spinner =
469 crate::VStack::new((crate::Spinner::new().size(theme.spacing.s6),)).align(Align::Center);
470 let spinner = motion::collapse(cx, s.progress.is_none(), &spinner);
471 let bar = motion::collapse(cx, s.progress.is_some(), &SplashBar(s.progress.unwrap_or(1.0)));
472 let indicator = cx.container(
473 LayoutStyle { width: Some(width), align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
474 &[spinner, bar],
475 Paint::None,
476 );
477 held_back(cx, indicator, now < rest_at, Effect::hidden(), theme.motion.base);
478 content.push(indicator);
479
480 let node = cx.container(
481 LayoutStyle {
482 width: Some(viewport.width),
483 height: Some(viewport.height),
484 padding: Insets::uniform(theme.spacing.s6),
485 spacing: theme.spacing.s4,
486 align: Align::Center,
487 justify: Justify::Center,
488 ..LayoutStyle::stack(Axis::Vertical)
489 },
490 &content,
491 Paint::Quad(QuadPaint::new(theme.color(Semantic::Canvas))),
492 );
493 cx.semantics(node, Semantics::new(Role::Dialog).name(s.name.clone()).modal());
494 cx.cursor(node, CursorIcon::Default);
496 cx.motion(
497 node,
498 Motion::new()
499 .enter(Effect::hidden(), theme.motion.base)
500 .exit(Effect::hidden().with_scale(1.02), theme.motion.slow),
501 );
502 node
503 }
504}
505
506impl View for SplashScreen {
507 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
508 let anchor = cx.empty();
509 if self.visible.get() {
510 cx.keyed_overlay(SPLASH_OVERLAY_KEY, anchor, Placement::Fill, &SplashLayer(self), None);
512 }
513 anchor
514 }
515}
516
517#[derive(Clone, Debug, PartialEq)]
519#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
520pub struct Toast {
521 id: u64,
522 title: String,
523 message: String,
524 tone: Tone,
525 duration: f64,
526 shown_at: Rc<Cell<Option<f64>>>,
528 extras: ToastExtras,
529}
530
531#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
533#[non_exhaustive]
534#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
535pub enum ToastPosition {
536 TopLeft,
538 TopCenter,
540 TopRight,
542 BottomLeft,
544 BottomCenter,
546 #[default]
548 BottomRight,
549}
550
551#[cfg_attr(feature = "live", ravenrs_live::expose)]
552impl ToastPosition {
553 fn placement(self) -> Placement {
554 match self {
555 Self::TopLeft => Placement::TopLeft,
556 Self::TopCenter => Placement::TopCenter,
557 Self::TopRight => Placement::TopRight,
558 Self::BottomLeft => Placement::BottomLeft,
559 Self::BottomCenter => Placement::BottomCenter,
560 Self::BottomRight => Placement::BottomRight,
561 }
562 }
563
564 fn travel(self) -> Effect {
566 match self {
567 Self::TopLeft | Self::BottomLeft => Effect::moved(-32.0, 0.0),
568 Self::TopRight | Self::BottomRight => Effect::moved(32.0, 0.0),
569 Self::TopCenter => Effect::moved(0.0, -32.0),
570 Self::BottomCenter => Effect::moved(0.0, 32.0),
571 }
572 }
573
574 fn at_bottom(self) -> bool {
575 matches!(self, Self::BottomLeft | Self::BottomCenter | Self::BottomRight)
576 }
577}
578
579#[derive(Clone, Default)]
582#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
583pub struct ToastExtras {
584 action: Option<(String, Rc<dyn Fn()>)>,
585 on_close: Option<Rc<dyn Fn()>>,
586 position: Option<ToastPosition>,
587}
588
589impl fmt::Debug for ToastExtras {
590 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
591 f.debug_struct("ToastExtras")
592 .field("action", &self.action.as_ref().map(|(label, _)| label))
593 .finish_non_exhaustive()
594 }
595}
596
597impl PartialEq for ToastExtras {
598 fn eq(&self, other: &Self) -> bool {
599 let same = |a: &Option<Rc<dyn Fn()>>, b: &Option<Rc<dyn Fn()>>| match (a, b) {
600 (Some(a), Some(b)) => Rc::ptr_eq(a, b),
601 (None, None) => true,
602 _ => false,
603 };
604 let action = |extras: &Self| extras.action.as_ref().map(|(_, run)| run.clone());
605 same(&action(self), &action(other))
606 && same(&self.on_close, &other.on_close)
607 && self.position == other.position
608 }
609}
610
611#[cfg_attr(feature = "live", ravenrs_live::expose)]
612impl ToastExtras {
613 pub fn new() -> Self {
615 Self::default()
616 }
617
618 pub fn action(mut self, label: impl Into<String>, run: impl Fn() + 'static) -> Self {
620 self.action = Some((label.into(), Rc::new(run)));
621 self
622 }
623
624 pub fn on_close(mut self, run: impl Fn() + 'static) -> Self {
626 self.on_close = Some(Rc::new(run));
627 self
628 }
629
630 pub fn position(mut self, position: ToastPosition) -> Self {
632 self.position = Some(position);
633 self
634 }
635}
636
637#[derive(Clone, Debug, Default)]
651#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
652pub struct Toasts {
653 list: State<Vec<Toast>>,
654 next: Rc<Cell<u64>>,
655}
656
657#[cfg_attr(feature = "live", ravenrs_live::expose)]
658impl Toasts {
659 pub fn new() -> Self {
661 Self::default()
662 }
663
664 pub fn show(&self, title: impl Into<String>, message: impl Into<String>, tone: Tone) {
666 self.show_for(title, message, tone, 4.0);
667 }
668
669 pub fn show_for(&self, title: impl Into<String>, message: impl Into<String>, tone: Tone, seconds: f64) {
671 self.show_with(title, message, tone, seconds, ToastExtras::new());
672 }
673
674 pub fn show_until_closed(&self, title: impl Into<String>, message: impl Into<String>, tone: Tone) {
676 self.show_for(title, message, tone, f64::INFINITY);
677 }
678
679 pub fn show_with(
682 &self,
683 title: impl Into<String>,
684 message: impl Into<String>,
685 tone: Tone,
686 seconds: f64,
687 extras: ToastExtras,
688 ) {
689 let id = self.next.get();
690 self.next.set(id + 1);
691 let toast = Toast {
692 id,
693 title: title.into(),
694 message: message.into(),
695 tone,
696 duration: seconds,
697 shown_at: Rc::new(Cell::new(None)),
698 extras,
699 };
700 let mut gone = Vec::new();
701 self.list.update(|l| {
702 l.push(toast);
703 let excess = l.len().saturating_sub(4);
705 gone.extend(l.drain(..excess));
706 });
707 tell_gone(&gone);
708 }
709
710 pub fn dismiss(&self, id: u64) {
712 let gone: Vec<Toast> = self.list.peek().iter().filter(|t| t.id == id).cloned().collect();
713 self.list.update(|l| l.retain(|t| t.id != id));
714 tell_gone(&gone);
715 }
716
717 pub fn len(&self) -> usize {
719 self.list.peek().len()
720 }
721
722 pub fn is_empty(&self) -> bool {
724 self.len() == 0
725 }
726}
727
728#[derive(Debug)]
744#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
745pub struct Toaster {
746 toasts: Toasts,
747 bottom_inset: f32,
748 position: ToastPosition,
749}
750
751#[cfg_attr(feature = "live", ravenrs_live::expose)]
752impl Toaster {
753 pub fn new(toasts: Toasts) -> Self {
755 Self { toasts, bottom_inset: 0.0, position: ToastPosition::default() }
756 }
757
758 pub fn position(mut self, position: ToastPosition) -> Self {
761 self.position = position;
762 self
763 }
764
765 pub fn bottom_inset(mut self, inset: f32) -> Self {
768 self.bottom_inset = inset;
769 self
770 }
771}
772
773const TOAST_OVERLAY_KEY: u64 = 0x746f_6173_7400_0000;
775
776fn tell_gone(gone: &[Toast]) {
777 for toast in gone {
778 if let Some(run) = &toast.extras.on_close {
779 run();
780 }
781 }
782}
783
784fn tone_color(theme: &Theme, tone: Tone) -> (Color, IconName) {
785 match tone {
786 Tone::Neutral => (theme.color(Semantic::InkMuted), IconName::Bell),
787 Tone::Accent => {
788 (theme.color(Semantic::Accent).lerp(theme.color(Semantic::Ink), 0.25), IconName::Info)
789 }
790 Tone::Success => (theme.color(Semantic::Success), IconName::CircleCheck),
791 Tone::Warning => (theme.color(Semantic::Warning), IconName::TriangleAlert),
792 Tone::Danger => (theme.color(Semantic::Danger), IconName::CircleAlert),
793 }
794}
795
796struct ToastStack<'a>(&'a [Toast], Toasts, f32, ToastPosition);
797
798impl View for ToastStack<'_> {
799 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
800 let theme = *cx.theme();
801 let mut cards = Vec::new();
802 for t in self.0 {
803 let (color, icon) = tone_color(&theme, t.tone);
804 let icon = util::icon(cx, icon, 18.0, color);
805 let title = util::label(
806 cx,
807 &t.title,
808 TextStyle::Body,
809 Some(Weight::SEMIBOLD),
810 theme.color(Semantic::Ink),
811 );
812 let msg = util::label(cx, &t.message, TextStyle::Callout, None, theme.color(Semantic::InkMuted));
813 let mut lines = vec![title, msg];
814 if let Some((label, run)) = &t.extras.action {
815 let (toasts, id, run) = (self.1.clone(), t.id, run.clone());
816 lines.push(cx.keyed_child(
817 t.id << 1 | 1,
818 &Button::new(label.clone()).style(ButtonStyle::Ghost).size(ButtonSize::Small).on_click(
819 move || {
820 run();
821 toasts.dismiss(id);
822 },
823 ),
824 ));
825 }
826 let text = cx.container(
827 LayoutStyle { grow: 1.0, spacing: 2.0, ..LayoutStyle::stack(Axis::Vertical) },
828 &lines,
829 Paint::None,
830 );
831 let (toasts, id) = (self.1.clone(), t.id);
832 let x = cx.keyed_child(
833 t.id << 1,
834 &IconButton::new(IconName::X)
835 .label(cx.labels().dismiss.clone())
836 .size(ButtonSize::Small)
837 .on_click(move || toasts.dismiss(id)),
838 );
839 let card = cx.container(
840 LayoutStyle {
841 width: Some(360.0),
842 padding: Insets { top: 14.0, bottom: 14.0, left: 16.0, right: 8.0 },
843 spacing: 12.0,
844 ..LayoutStyle::stack(Axis::Horizontal)
845 },
846 &[icon, text, x],
847 Paint::Quad(QuadPaint {
848 corner_radius: theme.radii.lg,
849 border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
850 shadow: Some(theme.elevation.raised),
851 ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
852 }),
853 );
854 let role = if t.tone == Tone::Danger { Role::Alert } else { Role::Status };
856 cx.semantics(card, Semantics::new(role));
857 cx.cursor(card, CursorIcon::Default);
858 cx.motion(
860 card,
861 Motion::new()
862 .key(t.id)
863 .layout(theme.motion.snappy)
864 .enter(self.3.travel(), theme.motion.gentle)
865 .exit(self.3.travel(), theme.motion.fast),
866 );
867 cards.push(card);
868 }
869 cx.container(
870 LayoutStyle {
871 spacing: 10.0,
872 padding: Insets {
873 bottom: if self.3.at_bottom() { self.2 } else { 0.0 },
874 ..Insets::uniform(0.0)
875 },
876 ..LayoutStyle::stack(Axis::Vertical)
877 },
878 &cards,
879 Paint::None,
880 )
881 }
882}
883
884impl View for Toaster {
885 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
886 let anchor = cx.empty();
887 let now = cx.now();
888 let all = self.toasts.list.get();
889 let mut visible = Vec::new();
890 let mut expired = false;
891 for t in &all {
892 let shown = t.shown_at.get().unwrap_or_else(|| {
893 t.shown_at.set(Some(now));
894 now
895 });
896 let until = shown + t.duration;
897 if now >= until {
898 expired = true;
899 } else {
900 if until.is_finite() {
902 cx.wake_at(until);
903 }
904 visible.push(t.clone());
905 }
906 }
907 if expired {
908 let ids: Vec<u64> = visible.iter().map(|t| t.id).collect();
909 let gone: Vec<Toast> = all.iter().filter(|t| !ids.contains(&t.id)).cloned().collect();
910 self.toasts.list.update(|l| l.retain(|t| ids.contains(&t.id)));
911 tell_gone(&gone);
912 }
913 let place = |t: &Toast| t.extras.position.unwrap_or(self.position);
914 let mut places: Vec<ToastPosition> = visible.iter().map(place).collect();
915 places.sort_unstable();
916 places.dedup();
917 for position in places {
918 let here: Vec<Toast> = visible.iter().filter(|t| place(t) == position).cloned().collect();
919 cx.keyed_overlay(
921 TOAST_OVERLAY_KEY + position as u64,
922 anchor,
923 position.placement(),
924 &ToastStack(&here, self.toasts.clone(), self.bottom_inset, position),
925 None,
926 );
927 }
928 anchor
929 }
930}
931
932#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
934pub struct Command {
935 label: String,
936 group: String,
937 icon: Option<IconName>,
938 shortcut: Option<String>,
939 action: Rc<dyn Fn()>,
940}
941
942impl fmt::Debug for Command {
943 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
944 f.debug_struct("Command").field("label", &self.label).finish_non_exhaustive()
945 }
946}
947
948#[cfg_attr(feature = "live", ravenrs_live::expose)]
949impl Command {
950 pub fn new(label: impl Into<String>, action: impl Fn() + 'static) -> Self {
952 Self {
953 label: label.into(),
954 group: String::new(),
955 icon: None,
956 shortcut: None,
957 action: Rc::new(action),
958 }
959 }
960
961 pub fn group(mut self, group: impl Into<String>) -> Self {
963 self.group = group.into();
964 self
965 }
966
967 pub fn icon(mut self, icon: IconName) -> Self {
969 self.icon = Some(icon);
970 self
971 }
972
973 pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
975 self.shortcut = Some(shortcut.into());
976 self
977 }
978}
979
980pub(crate) fn fuzzy_match(text: &str, query: &str) -> bool {
982 let text = text.to_lowercase();
983 let mut chars = text.chars();
984 query.to_lowercase().chars().filter(|c| !c.is_whitespace()).all(|q| chars.any(|c| c == q))
985}
986
987#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1003pub struct CommandPalette {
1004 open: Binding<bool>,
1005 commands: Vec<Command>,
1006 placeholder: Option<String>,
1007}
1008
1009impl fmt::Debug for CommandPalette {
1010 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1011 f.debug_struct("CommandPalette").field("commands", &self.commands.len()).finish_non_exhaustive()
1012 }
1013}
1014
1015#[cfg_attr(feature = "live", ravenrs_live::expose)]
1016impl CommandPalette {
1017 pub fn new(open: Binding<bool>) -> Self {
1019 Self { open, commands: Vec::new(), placeholder: None }
1020 }
1021
1022 pub fn command(mut self, command: Command) -> Self {
1024 self.commands.push(command);
1025 self
1026 }
1027
1028 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
1030 self.placeholder = Some(text.into());
1031 self
1032 }
1033}
1034
1035struct PaletteLayer<'a> {
1036 palette: &'a CommandPalette,
1037 query: State<String>,
1038 highlight: State<usize>,
1039}
1040
1041impl View for PaletteLayer<'_> {
1042 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1043 let theme = *cx.theme();
1044 let viewport = cx.viewport();
1045 let q = self.query.get();
1046 let matches: Vec<usize> = (0..self.palette.commands.len())
1047 .filter(|&i| fuzzy_match(&self.palette.commands[i].label, &q))
1048 .collect();
1049 let highlight = self.highlight.get().min(matches.len().saturating_sub(1));
1050 let actions: Rc<Vec<Rc<dyn Fn()>>> =
1051 Rc::new(matches.iter().map(|&i| self.palette.commands[i].action.clone()).collect());
1052
1053 let (hl, open, acts, count) =
1054 (self.highlight.clone(), self.palette.open.clone(), actions.clone(), matches.len());
1055 let field = cx.child(
1056 &TextField::new(self.query.binding())
1057 .placeholder(
1058 self.palette.placeholder.clone().unwrap_or_else(|| cx.labels().search_commands.clone()),
1059 )
1060 .icon(IconName::Search)
1061 .autofocus()
1062 .intercept_keys(Rc::new(move |key, _| {
1063 match key {
1064 Key::ArrowDown if count > 0 => hl.update(|h| *h = (*h + 1).min(count - 1)),
1065 Key::ArrowUp => hl.update(|h| *h = h.saturating_sub(1)),
1066 Key::Enter => {
1067 if let Some(a) = acts.get(hl.get().min(count.saturating_sub(1))) {
1068 open.set(false);
1069 a();
1070 }
1071 }
1072 Key::Escape => open.set(false),
1073 _ => return false,
1074 }
1075 true
1076 })),
1077 );
1078 let mut rows = vec![field];
1079 let mut last_group: Option<&str> = None;
1080 for (n, &i) in matches.iter().enumerate().take(12) {
1081 let c = &self.palette.commands[i];
1082 if last_group != Some(c.group.as_str()) && !c.group.is_empty() {
1083 rows.push(util::label(
1084 cx,
1085 &c.group.to_uppercase(),
1086 TextStyle::Caption,
1087 None,
1088 theme.color(Semantic::InkMuted),
1089 ));
1090 }
1091 last_group = Some(c.group.as_str());
1092 let on = n == highlight;
1093 let ink = theme.color(Semantic::Ink);
1094 let mut items = Vec::new();
1095 if let Some(icon) = c.icon {
1096 items.push(util::icon(cx, icon, 16.0, theme.color(Semantic::InkMuted)));
1097 }
1098 let label = util::label(cx, &c.label, TextStyle::Body, None, ink);
1099 items.push(cx.container(
1100 LayoutStyle { grow: 1.0, ..LayoutStyle::stack(Axis::Horizontal) },
1101 &[label],
1102 Paint::None,
1103 ));
1104 if let Some(s) = &c.shortcut {
1105 items.push(cx.child(&crate::Kbd::new(s.clone())));
1106 }
1107 let fill = if on { theme.color(Semantic::Accent).with_alpha(0.16) } else { Color::TRANSPARENT };
1108 let row = cx.container(
1109 LayoutStyle {
1110 height: Some(38.0),
1111 padding: Insets::symmetric(0.0, 10.0),
1112 spacing: 10.0,
1113 align: Align::Center,
1114 ..LayoutStyle::stack(Axis::Horizontal)
1115 },
1116 &items,
1117 Paint::Quad(QuadPaint { corner_radius: 8.0, ..QuadPaint::new(fill) }),
1118 );
1119 let mut semantics = Semantics::new(Role::ListBoxOption).selected(on).active(on);
1120 if let Some(shortcut) = &c.shortcut {
1121 semantics = semantics.name(c.label.clone()).description(shortcut.clone());
1122 }
1123 cx.semantics(row, semantics);
1124 let (open, action, hl) = (self.palette.open.clone(), c.action.clone(), self.highlight.clone());
1125 cx.on_click(
1126 row,
1127 Rc::new(move || {
1128 open.set(false);
1129 action();
1130 }),
1131 );
1132 let _ = hl;
1133 cx.cursor(row, CursorIcon::Pointer);
1134 rows.push(row);
1135 }
1136 if matches.is_empty() {
1137 rows.push(util::label(
1138 cx,
1139 &cx.labels().no_commands.clone(),
1140 TextStyle::Callout,
1141 None,
1142 theme.color(Semantic::InkMuted),
1143 ));
1144 }
1145 let palette_name = cx.labels().command_palette.clone();
1146 cx.label_control(field, &palette_name, None);
1147 let card = panel(cx, &rows, 10.0, (viewport.width - 32.0).clamp(280.0, 560.0));
1148 cx.semantics(card, Semantics::new(Role::Dialog).name(palette_name).modal());
1149 let scrim = motion::scrim(cx, viewport);
1150 let backdrop = cx.container(
1151 LayoutStyle {
1152 width: Some(viewport.width),
1153 height: Some(viewport.height),
1154 padding: Insets { top: (viewport.height * 0.15).min(140.0), ..Insets::ZERO },
1155 align: Align::Center,
1156 ..LayoutStyle::stack(Axis::Vertical)
1157 },
1158 &[scrim, card],
1159 Paint::None,
1160 );
1161 let close = self.palette.open.clone();
1162 cx.on_click(backdrop, Rc::new(move || close.set(false)));
1163 backdrop
1164 }
1165}
1166
1167impl View for CommandPalette {
1168 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1169 let query: State<String> = cx.use_state(String::new);
1170 let highlight: State<usize> = cx.use_state(|| 0);
1171 let toggle = self.open.clone();
1172 let (q, h) = (query.clone(), highlight.clone());
1173 cx.shortcut(
1174 Key::Character("k".into()),
1175 true,
1176 Rc::new(move || {
1177 q.set(String::new());
1178 h.set(0);
1179 toggle.update(|o| *o = !*o);
1180 }),
1181 );
1182 let anchor = cx.empty();
1183 if self.open.get() {
1184 let close = self.open.clone();
1185 let layer = PaletteLayer { palette: self, query, highlight };
1186 cx.overlay(anchor, Placement::Fill, &layer, Some(Rc::new(move || close.set(false))));
1187 }
1188 anchor
1189 }
1190}
1191
1192#[derive(Debug)]
1194#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1195pub struct Combobox {
1196 selected: Binding<Option<usize>>,
1197 options: Vec<String>,
1198 placeholder: Option<String>,
1199 width: f32,
1200 fill: bool,
1201 label: Option<String>,
1202 state: util::EditorState,
1203}
1204
1205#[cfg_attr(feature = "live", ravenrs_live::expose)]
1206impl Combobox {
1207 pub fn new(
1209 selected: Binding<Option<usize>>,
1210 options: impl IntoIterator<Item = impl Into<String>>,
1211 ) -> Self {
1212 Self {
1213 selected,
1214 options: options.into_iter().map(Into::into).collect(),
1215 placeholder: None,
1216 width: 240.0,
1217 fill: false,
1218 label: None,
1219 state: util::EditorState::default(),
1220 }
1221 }
1222
1223 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
1225 self.placeholder = Some(text.into());
1226 self
1227 }
1228
1229 pub fn fill(mut self) -> Self {
1231 self.fill = true;
1232 self
1233 }
1234
1235 pub fn width(mut self, width: f32) -> Self {
1237 self.width = width;
1238 self
1239 }
1240
1241 pub fn label(mut self, label: impl Into<String>) -> Self {
1244 self.label = Some(label.into());
1245 self
1246 }
1247
1248 pub fn disabled(mut self, disabled: bool) -> Self {
1250 self.state.disabled = disabled;
1251 self
1252 }
1253
1254 pub fn read_only(mut self, read_only: bool) -> Self {
1256 self.state.read_only = read_only;
1257 self
1258 }
1259}
1260
1261pub(crate) fn search_row(
1263 cx: &mut BuildCx<'_>,
1264 text: &str,
1265 selected: bool,
1266 highlighted: bool,
1267 on_pick: Rc<dyn Fn()>,
1268) -> NodeId {
1269 let theme = *cx.theme();
1270 let ink = theme.color(Semantic::Ink);
1271 let label = util::label(cx, text, TextStyle::Body, selected.then_some(Weight::SEMIBOLD), ink);
1272 let mut items = vec![cx.container(
1273 LayoutStyle { grow: 1.0, ..LayoutStyle::stack(Axis::Horizontal) },
1274 &[label],
1275 Paint::None,
1276 )];
1277 if selected {
1278 items.push(util::icon(cx, IconName::Check, 16.0, theme.color(Semantic::Accent).lerp(ink, 0.3)));
1279 }
1280 let fill = if highlighted { ink.with_alpha(0.07) } else { Color::TRANSPARENT };
1281 let row = cx.container(
1282 LayoutStyle {
1283 height: Some(34.0),
1284 padding: Insets::symmetric(0.0, 10.0),
1285 align: Align::Center,
1286 ..LayoutStyle::stack(Axis::Horizontal)
1287 },
1288 &items,
1289 Paint::Quad(QuadPaint { corner_radius: 7.0, ..QuadPaint::new(fill) }),
1290 );
1291 cx.semantics(row, Semantics::new(Role::ListBoxOption).selected(selected).active(highlighted));
1292 cx.on_click(row, on_pick);
1293 cx.cursor(row, CursorIcon::Pointer);
1294 row
1295}
1296
1297pub(crate) struct SearchPanel<'a> {
1300 pub options: &'a [String],
1301 pub current: Option<usize>,
1302 pub pick: Rc<dyn Fn(usize)>,
1303 pub query: State<String>,
1304 pub highlight: State<usize>,
1305 pub open: State<bool>,
1306 pub width: f32,
1307}
1308
1309impl View for SearchPanel<'_> {
1310 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1311 let theme = *cx.theme();
1312 let q = self.query.get();
1313 let matches: Vec<usize> =
1314 (0..self.options.len()).filter(|&i| fuzzy_match(&self.options[i], &q)).collect();
1315 let hl = self.highlight.get().min(matches.len().saturating_sub(1));
1316 let (current, pick) = (self.current, self.pick.clone());
1317 let (h, m, p, open) =
1318 (self.highlight.clone(), Rc::new(matches.clone()), pick.clone(), self.open.clone());
1319 let field = cx.child(
1320 &TextField::new(self.query.binding())
1321 .placeholder(cx.labels().search.clone())
1322 .icon(IconName::Search)
1323 .compact()
1324 .autofocus()
1325 .intercept_keys(Rc::new(move |key, _| {
1326 match key {
1327 Key::ArrowDown if !m.is_empty() => h.update(|x| *x = (*x + 1).min(m.len() - 1)),
1328 Key::ArrowUp => h.update(|x| *x = x.saturating_sub(1)),
1329 Key::Enter => {
1330 if let Some(&i) = m.get(h.get().min(m.len().saturating_sub(1))) {
1331 p(i);
1332 }
1333 }
1334 Key::Escape => open.set(false),
1335 _ => return false,
1336 }
1337 true
1338 })),
1339 );
1340 let mut rows = vec![field];
1341 for (n, &i) in matches.iter().enumerate().take(50) {
1342 let p = pick.clone();
1343 rows.push(search_row(cx, &self.options[i], current == Some(i), n == hl, Rc::new(move || p(i))));
1344 }
1345 if matches.is_empty() {
1346 rows.push(util::label(
1347 cx,
1348 &cx.labels().no_results.clone(),
1349 TextStyle::Callout,
1350 None,
1351 theme.color(Semantic::InkMuted),
1352 ));
1353 }
1354 let node = panel(cx, &rows, 6.0, self.width);
1355 cx.semantics(node, Semantics::new(Role::ListBox));
1356 node
1357 }
1358}
1359
1360impl View for Combobox {
1361 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1362 let theme = *cx.theme();
1363 let open: State<bool> = cx.use_state(|| false);
1364 let query: State<String> = cx.use_state(String::new);
1365 let highlight: State<usize> = cx.use_state(|| 0);
1366 let state = self.state;
1367 let is_open = open.get() && state.accepts_input();
1368 let hovered = cx.is_hovered() && state.accepts_input();
1369 let (text, color) = match self.selected.get().and_then(|i| self.options.get(i)) {
1370 Some(t) => (t.clone(), theme.color(Semantic::Ink)),
1371 None => (
1372 self.placeholder.clone().unwrap_or_else(|| cx.labels().select.clone()),
1373 theme.color(Semantic::InkMuted),
1374 ),
1375 };
1376 let label = util::label(cx, &text, TextStyle::Body, None, state.ink(color));
1377 let label = cx.container(
1378 LayoutStyle { grow: 1.0, ..LayoutStyle::stack(Axis::Horizontal) },
1379 &[label],
1380 Paint::None,
1381 );
1382 let chevrons =
1383 util::icon(cx, IconName::ChevronDown, 16.0, state.ink(theme.color(Semantic::InkMuted)));
1384 let border = if is_open {
1385 Border { width: 1.5, color: theme.color(Semantic::Accent) }
1386 } else {
1387 let base = theme.color(Semantic::Border);
1388 Border {
1389 width: 1.0,
1390 color: state.ink(if hovered { base.lerp(theme.color(Semantic::Ink), 0.25) } else { base }),
1391 }
1392 };
1393 let node = cx.container(
1394 LayoutStyle {
1395 width: (!self.fill).then_some(self.width),
1396 height: Some(40.0),
1397 padding: Insets::symmetric(0.0, 12.0),
1398 spacing: 8.0,
1399 align: Align::Center,
1400 align_self: Some(if self.fill { Align::Stretch } else { Align::Fit }),
1401 ..LayoutStyle::stack(Axis::Horizontal)
1402 },
1403 &[label, chevrons],
1404 Paint::Quad(QuadPaint {
1405 corner_radius: theme.radii.md,
1406 border: Some(border),
1407 ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
1408 }),
1409 );
1410 cx.semantics(label, Semantics::hidden());
1411 let value = if self.selected.get().is_some() { text.clone() } else { String::new() };
1412 let mut semantics = Semantics::new(Role::ComboBox).value(value).expanded(is_open);
1413 if let Some(label) = &self.label {
1414 semantics = semantics.name(label.clone());
1415 }
1416 cx.semantics(node, state.semantics(semantics));
1417 if state.disabled {
1418 return node;
1419 }
1420 if !state.accepts_input() {
1421 cx.focusable(node, theme.radii.md);
1422 return node;
1423 }
1424 let (o, q, h) = (open.clone(), query.clone(), highlight.clone());
1425 cx.on_click(
1426 node,
1427 Rc::new(move || {
1428 q.set(String::new());
1429 h.set(0);
1430 o.update(|x| *x = !*x);
1431 }),
1432 );
1433 cx.focusable(node, theme.radii.md);
1434 cx.cursor(node, CursorIcon::Pointer);
1435 if is_open {
1436 let close = open.clone();
1437 let pick: Rc<dyn Fn(usize)> = {
1438 let (selected, open) = (self.selected.clone(), open.clone());
1439 Rc::new(move |i| {
1440 selected.set(Some(i));
1441 open.set(false);
1442 })
1443 };
1444 let panel = SearchPanel {
1445 options: &self.options,
1446 current: self.selected.get(),
1447 pick,
1448 query,
1449 highlight,
1450 open: open.clone(),
1451 width: self.width,
1452 };
1453 cx.overlay(node, Placement::Below, &panel, Some(Rc::new(move || close.set(false))));
1454 }
1455 node
1456 }
1457}
1458
1459#[cfg(test)]
1460mod tests {
1461 use super::fuzzy_match;
1462
1463 #[test]
1464 fn fuzzy_matching() {
1465 assert!(fuzzy_match("Toggle theme", "tgth"));
1466 assert!(fuzzy_match("Open settings", "open set"));
1467 assert!(!fuzzy_match("Export CSV", "xcz"));
1468 assert!(fuzzy_match("anything", ""));
1469 }
1470}