1use std::cell::Cell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, Color, CursorIcon, Effect, Insets, Point, Role};
8use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle, Theme, Weight};
11
12use crate::context::{BuildCx, NodeId, Paint, Placement, QuadPaint, Semantics};
13use crate::{AnyView, Button, ButtonStyle, IconButton, IconName, Motion, View, motion, util};
14
15fn panel_paint(theme: &Theme) -> Paint {
17 Paint::Quad(QuadPaint {
18 corner_radius: theme.radii.lg,
19 border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
20 shadow: Some(theme.elevation.raised),
21 ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
22 })
23}
24
25pub(crate) fn panel(cx: &mut BuildCx<'_>, children: &[NodeId], padding: f32, min_width: f32) -> NodeId {
28 let theme = *cx.theme();
29 let style = LayoutStyle {
30 padding: Insets::uniform(padding),
31 min_width: Some(min_width),
32 align: Align::Stretch,
33 ..LayoutStyle::stack(Axis::Vertical)
34 };
35 let node = cx.container(style, children, panel_paint(&theme));
36 cx.cursor(node, CursorIcon::Default);
37 motion::popup(cx, node);
38 node
39}
40
41pub struct Tooltip<V> {
53 content: V,
54 text: String,
55 shortcut: Option<String>,
56}
57
58impl<V> fmt::Debug for Tooltip<V> {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 f.debug_struct("Tooltip").field("text", &self.text).finish_non_exhaustive()
61 }
62}
63
64#[cfg_attr(feature = "live", ravenrs_live::expose)]
65impl<V: View> Tooltip<V> {
66 pub fn new(content: V, text: impl Into<String>) -> Self {
68 Self { content, text: text.into(), shortcut: None }
69 }
70
71 pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
73 self.shortcut = Some(keys.into());
74 self
75 }
76}
77
78pub(crate) struct Bubble<'a>(pub &'a str);
79
80struct KeyBubble<'a> {
82 text: &'a str,
83 keys: Option<&'a str>,
84}
85
86impl View for Bubble<'_> {
87 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
88 KeyBubble { text: self.0, keys: None }.build(cx)
89 }
90}
91
92impl View for KeyBubble<'_> {
93 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
94 let theme = *cx.theme();
95 let ink = theme.color(Semantic::Canvas);
97 let mut children = vec![util::label(cx, self.text, TextStyle::Callout, Some(Weight::MEDIUM), ink)];
98 if let Some(keys) = self.keys {
99 let cap = crate::Kbd::new(keys.to_owned()).inherit_color();
100 children.push(cx.with_foreground(ink, |cx| cx.child(&cap)));
101 }
102 let node = cx.container(
103 LayoutStyle {
104 padding: Insets::symmetric(5.0, 10.0),
105 spacing: theme.spacing.s2,
106 align: Align::Center,
107 ..LayoutStyle::stack(Axis::Horizontal)
108 },
109 &children,
110 Paint::Quad(QuadPaint { corner_radius: 7.0, ..QuadPaint::new(theme.color(Semantic::Ink)) }),
111 );
112 cx.semantics(node, Semantics::hidden());
114 motion::popup(cx, node);
115 node
116 }
117}
118
119impl<V: View> View for Tooltip<V> {
120 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
121 let child = cx.child(&self.content);
122 cx.label_control(child, &self.text, self.shortcut.as_deref());
123 let node = cx.container(
124 LayoutStyle { align_self: Some(Align::Fit), ..LayoutStyle::stack(Axis::Horizontal) },
125 &[child],
126 Paint::None,
127 );
128 cx.cursor(node, CursorIcon::Default);
130 if cx.is_hover_within() {
131 let bubble = KeyBubble { text: &self.text, keys: self.shortcut.as_deref() };
132 cx.overlay(node, Placement::AboveCenter, &bubble, None);
133 }
134 node
135 }
136}
137
138struct HelpPanel<'a> {
140 title: &'a str,
141 text: &'a str,
142 how: Option<&'a str>,
143}
144
145impl View for HelpPanel<'_> {
146 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
147 let theme = *cx.theme();
148 let (ink, muted) = (theme.color(Semantic::Ink), theme.color(Semantic::InkMuted));
149 let wrapped = |cx: &mut BuildCx<'_>, text: &str, weight, color| {
150 cx.child(
151 &crate::Text::new(text.to_owned())
152 .style(TextStyle::Callout)
153 .font_weight(weight)
154 .foreground(color),
155 )
156 };
157 let mut lines = vec![
158 wrapped(cx, self.title, Weight::SEMIBOLD, ink),
159 wrapped(cx, self.text, Weight::REGULAR, muted),
160 ];
161 if let Some(how) = self.how {
162 lines.push(cx.child(&crate::Divider::horizontal()));
163 lines.push(wrapped(cx, how, Weight::REGULAR, ink));
164 }
165 let column = cx.container(
166 LayoutStyle {
167 width: Some(HELP_WIDTH),
168 spacing: theme.spacing.s2,
169 align: Align::Stretch,
170 ..LayoutStyle::stack(Axis::Vertical)
171 },
172 &lines,
173 Paint::None,
174 );
175 panel(cx, &[column], theme.spacing.s3, 0.0)
176 }
177}
178
179const HELP_WIDTH: f32 = 280.0;
180
181#[derive(Clone, Debug)]
185#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
186pub struct HelpTip {
187 title: String,
188 text: String,
189 how: Option<String>,
190}
191
192#[cfg_attr(feature = "live", ravenrs_live::expose)]
193impl HelpTip {
194 pub fn new(title: impl Into<String>, text: impl Into<String>) -> Self {
196 Self { title: title.into(), text: text.into(), how: None }
197 }
198
199 #[must_use]
201 pub fn how(mut self, how: impl Into<String>) -> Self {
202 self.how = Some(how.into());
203 self
204 }
205}
206
207impl View for HelpTip {
208 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
209 let pinned: State<bool> = cx.use_state(|| false);
210 let toggle = pinned.clone();
211 let button = IconButton::new(IconName::CircleHelp)
212 .label(format!("{}: {}", cx.labels().help, self.title))
213 .size(crate::ButtonSize::Small)
214 .style(ButtonStyle::Ghost)
215 .on_click(move || toggle.update(|open| *open = !*open));
216 let node = button.build(cx);
218 let description = match &self.how {
219 Some(how) => format!("{} {how}", self.text),
220 None => self.text.clone(),
221 };
222 cx.update_semantics(node, |s| s.description = Some(description.clone()));
223 if pinned.get() || cx.is_hovered() || cx.is_focus_visible() {
224 let panel = HelpPanel { title: &self.title, text: &self.text, how: self.how.as_deref() };
225 let close = pinned.clone();
226 let dismiss: Rc<dyn Fn()> = Rc::new(move || close.set(false));
227 cx.overlay(node, Placement::Below, &panel, pinned.get().then_some(dismiss));
228 }
229 node
230 }
231}
232
233pub struct Popover<A, C> {
249 open: Binding<bool>,
250 anchor: A,
251 content: C,
252 placement: Placement,
253 surface: bool,
254 padding: f32,
255 min_width: f32,
256}
257
258impl<A, C> fmt::Debug for Popover<A, C> {
259 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260 f.debug_struct("Popover").finish_non_exhaustive()
261 }
262}
263
264#[cfg_attr(feature = "live", ravenrs_live::expose)]
265impl<A: View, C: View> Popover<A, C> {
266 pub fn new(open: Binding<bool>, anchor: A, content: C) -> Self {
268 Self {
269 open,
270 anchor,
271 content,
272 placement: Placement::Below,
273 surface: true,
274 padding: 16.0,
275 min_width: 220.0,
276 }
277 }
278
279 pub fn placement(mut self, placement: Placement) -> Self {
281 self.placement = placement;
282 self
283 }
284
285 pub fn surface(mut self, surface: bool) -> Self {
288 self.surface = surface;
289 self
290 }
291
292 pub fn padding(mut self, padding: f32) -> Self {
294 self.padding = padding;
295 self
296 }
297
298 pub fn min_width(mut self, width: f32) -> Self {
300 self.min_width = width;
301 self
302 }
303}
304
305struct PopoverLayer<'a> {
306 content: &'a dyn View,
307 surface: bool,
308 padding: f32,
309 min_width: f32,
310}
311
312impl View for PopoverLayer<'_> {
313 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
314 let child = cx.child(self.content);
315 if self.surface {
316 return panel(cx, &[child], self.padding, self.min_width);
317 }
318 let node = cx.container(LayoutStyle::stack(Axis::Vertical), &[child], Paint::None);
319 cx.cursor(node, CursorIcon::Default);
321 motion::popup(cx, node);
322 node
323 }
324}
325
326pub(crate) struct PanelOf<'a>(pub &'a dyn View);
327
328impl View for PanelOf<'_> {
329 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
330 let child = cx.child(self.0);
331 panel(cx, &[child], 16.0, 220.0)
332 }
333}
334
335impl<A: View, C: View> View for Popover<A, C> {
336 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
337 let anchor = cx.child(&self.anchor);
338 if self.open.get() {
339 let close = self.open.clone();
340 let layer = PopoverLayer {
341 content: &self.content,
342 surface: self.surface,
343 padding: self.padding,
344 min_width: self.min_width,
345 };
346 cx.overlay(anchor, self.placement, &layer, Some(Rc::new(move || close.set(false))));
347 }
348 anchor
349 }
350}
351
352#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
365pub struct MenuItem {
366 label: String,
367 icon: Option<IconName>,
368 shortcut: Option<String>,
369 destructive: bool,
370 on_select: Option<Rc<dyn Fn()>>,
371 checked: Option<bool>,
372 element: Option<AnyView>,
373 submenu: Vec<Entry>,
374}
375
376impl fmt::Debug for MenuItem {
377 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
378 f.debug_struct("MenuItem").field("label", &self.label).finish_non_exhaustive()
379 }
380}
381
382#[cfg_attr(feature = "live", ravenrs_live::expose)]
383impl MenuItem {
384 pub fn new(label: impl Into<String>) -> Self {
386 Self {
387 label: label.into(),
388 icon: None,
389 shortcut: None,
390 destructive: false,
391 on_select: None,
392 checked: None,
393 element: None,
394 submenu: Vec::new(),
395 }
396 }
397
398 pub fn icon(mut self, icon: IconName) -> Self {
400 self.icon = Some(icon);
401 self
402 }
403
404 pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
406 self.shortcut = Some(shortcut.into());
407 self
408 }
409
410 pub fn destructive(mut self) -> Self {
412 self.destructive = true;
413 self
414 }
415
416 pub fn on_select(mut self, handler: impl Fn() + 'static) -> Self {
418 self.on_select = Some(Rc::new(handler));
419 self
420 }
421
422 pub fn checked(mut self, checked: bool) -> Self {
425 self.checked = Some(checked);
426 self
427 }
428
429 pub fn element(mut self, view: impl View + 'static) -> Self {
432 self.element = Some(Box::new(view));
433 self
434 }
435
436 pub fn item(mut self, item: MenuItem) -> Self {
440 self.submenu.push(Entry::Item(item));
441 self
442 }
443
444 pub fn separator(mut self) -> Self {
446 self.submenu.push(Entry::Separator);
447 self
448 }
449}
450
451pub(crate) enum Entry {
452 Item(MenuItem),
453 Separator,
454}
455
456#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
460pub struct DropdownMenu {
461 label: String,
462 icon: Option<IconName>,
463 style: ButtonStyle,
464 size: crate::ButtonSize,
465 icon_only: bool,
466 entries: Vec<Entry>,
467}
468
469impl fmt::Debug for DropdownMenu {
470 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
471 f.debug_struct("DropdownMenu").field("label", &self.label).finish_non_exhaustive()
472 }
473}
474
475#[cfg_attr(feature = "live", ravenrs_live::expose)]
476impl DropdownMenu {
477 pub fn new(label: impl Into<String>) -> Self {
479 Self {
480 label: label.into(),
481 icon: None,
482 style: ButtonStyle::Secondary,
483 size: crate::ButtonSize::Medium,
484 icon_only: false,
485 entries: Vec::new(),
486 }
487 }
488
489 pub fn icon_only(mut self) -> Self {
492 self.icon_only = true;
493 self
494 }
495
496 pub fn size(mut self, size: crate::ButtonSize) -> Self {
498 self.size = size;
499 self
500 }
501
502 pub fn icon(mut self, icon: IconName) -> Self {
504 self.icon = Some(icon);
505 self
506 }
507
508 pub fn style(mut self, style: ButtonStyle) -> Self {
510 self.style = style;
511 self
512 }
513
514 pub fn item(mut self, item: MenuItem) -> Self {
516 self.entries.push(Entry::Item(item));
517 self
518 }
519
520 pub fn separator(mut self) -> Self {
522 self.entries.push(Entry::Separator);
523 self
524 }
525}
526
527struct ListRow<'a> {
530 label: &'a str,
531 icon: Option<IconName>,
532 trailing: Option<&'a str>,
533 checked: bool,
534 destructive: bool,
535 close: Rc<dyn Fn()>,
536 on_click: Option<Rc<dyn Fn()>>,
537 roving: util::Roving,
538 role: Role,
540 on_side: Option<Rc<dyn Fn(bool)>>,
542 element: Option<&'a dyn View>,
544 toggled: Option<bool>,
546 submenu: &'a [Entry],
548 open_submenu: Option<State<Option<(usize, bool)>>>,
551 aim: Option<SubmenuAim>,
553 pointer_in: Option<Rc<dyn Fn()>>,
555}
556
557const SUBMENU_GRACE: f64 = 0.25;
559
560#[derive(Clone)]
564struct SubmenuAim {
565 last: Rc<Cell<Option<Point>>>,
566 waiting: State<Option<(usize, bool)>>,
568 due: Rc<Cell<Option<f64>>>,
569}
570
571#[cfg_attr(feature = "live", ravenrs_live::expose)]
572impl SubmenuAim {
573 fn sideways(&self, position: Point) -> bool {
575 self.last.replace(Some(position)).is_some_and(|from| {
576 let (dx, dy) = (position.x - from.x, position.y - from.y);
577 dx != 0.0 && dy.abs() <= dx.abs() * 2.0
578 })
579 }
580
581 fn cancel(&self) {
582 if self.waiting.peek().is_some() {
583 self.waiting.set(None);
584 }
585 }
586}
587
588fn switch_submenu(
590 open: &State<Option<(usize, bool)>>,
591 index: usize,
592 has_submenu: bool,
593 focus_to: &util::FocusTarget,
594) {
595 let current = *open.peek();
596 open.set(has_submenu.then_some((index, false)));
597 if current.is_some_and(|(_, focused)| focused) {
599 focus_to.request(index);
600 }
601}
602
603#[cfg_attr(feature = "live", ravenrs_live::expose)]
604impl ListRow<'_> {
605 fn opener(&self) -> Option<Rc<dyn Fn()>> {
607 let open = self.open_submenu.clone().filter(|_| !self.submenu.is_empty())?;
608 let index = self.roving.index;
609 Some(Rc::new(move || open.set(Some((index, true)))))
610 }
611
612 fn follow_pointer(&self, cx: &mut BuildCx<'_>, node: NodeId) {
614 let pointer_in = self.pointer_in.clone();
615 let list = self.open_submenu.clone().zip(self.aim.clone());
616 if list.is_none() && pointer_in.is_none() {
617 return;
618 }
619 let (index, has_submenu, focus_to) =
620 (self.roving.index, !self.submenu.is_empty(), self.roving.focus_to.clone());
621 cx.on_pointer_move(
622 node,
623 Rc::new(move |position, _| {
624 if let Some(pointer_in) = &pointer_in {
625 pointer_in();
626 }
627 let Some((open, aim)) = &list else { return };
628 let sideways = aim.sideways(position);
629 let current = *open.peek();
630 if current.map(|(i, _)| i) == has_submenu.then_some(index) {
631 aim.cancel();
632 return;
633 }
634 if current.is_some() && sideways {
635 if *aim.waiting.peek() != Some((index, has_submenu)) {
636 aim.waiting.set(Some((index, has_submenu)));
637 }
638 return;
639 }
640 aim.cancel();
641 switch_submenu(open, index, has_submenu, &focus_to);
642 }),
643 );
644 }
645
646 fn submenu_overlay(&self, cx: &mut BuildCx<'_>, node: NodeId) {
648 let Some(open) = &self.open_submenu else { return };
649 let Some((_, focused)) = open.get().filter(|(i, _)| *i == self.roving.index) else { return };
650 if self.submenu.is_empty() {
651 return;
652 }
653 let close_submenu: Rc<dyn Fn()> = {
654 let (open, focus_to, index) = (open.clone(), self.roving.focus_to.clone(), self.roving.index);
655 Rc::new(move || {
656 open.set(None);
657 focus_to.request(index);
658 })
659 };
660 let on_side: Rc<dyn Fn(bool)> = {
662 let (close, outer) = (close_submenu.clone(), self.on_side.clone());
663 Rc::new(move |forward| {
664 if !forward {
665 close();
666 } else if let Some(outer) = &outer {
667 outer(true);
668 }
669 })
670 };
671 let reached = self.aim.clone().map(|aim| Rc::new(move || aim.cancel()) as Rc<dyn Fn()>);
672 let list = MenuLevel {
673 entries: self.submenu,
674 close: self.close.clone(),
675 on_side: Some(on_side),
676 takes_focus: focused,
677 pointer_in: reached,
678 };
679 let key = 0x7375_626d_656e_7500 + u64::from(focused);
681 cx.keyed_overlay(key, node, Placement::Right, &list, Some(close_submenu));
682 }
683}
684
685impl View for ListRow<'_> {
686 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
687 let theme = *cx.theme();
688 let hovered = cx.is_hovered() || cx.is_focus_visible();
689 let ink = if self.destructive { theme.color(Semantic::Danger) } else { theme.color(Semantic::Ink) };
690 let muted = if self.destructive { ink } else { theme.color(Semantic::InkMuted) };
691 let mut children = Vec::new();
692 if let Some(icon) = self.icon {
693 children.push(util::icon(cx, icon, 16.0, muted));
694 }
695 let weight = self.checked.then_some(Weight::SEMIBOLD);
696 let label = match self.element {
697 Some(element) => cx.keyed_child(1, element),
698 None => util::label(cx, self.label, TextStyle::Body, weight, ink),
699 };
700 children.push(cx.container(
701 LayoutStyle { grow: 1.0, ..LayoutStyle::stack(Axis::Horizontal) },
702 &[label],
703 Paint::None,
704 ));
705 if let Some(t) = self.trailing {
706 children.push(util::label(cx, t, TextStyle::Callout, None, theme.color(Semantic::InkMuted)));
707 }
708 if self.checked {
709 children.push(util::icon(
710 cx,
711 IconName::Check,
712 16.0,
713 theme.color(Semantic::Accent).lerp(ink, 0.3),
714 ));
715 }
716 let opener = self.opener();
717 let is_open =
718 self.open_submenu.as_ref().and_then(|o| o.get()).is_some_and(|(i, _)| i == self.roving.index);
719 if opener.is_some() {
720 children.push(util::icon(cx, IconName::ChevronRight, 16.0, muted));
721 }
722 let lit = hovered || (is_open && opener.is_some());
723 let fill = if lit { theme.color(Semantic::Ink).with_alpha(0.07) } else { Color::TRANSPARENT };
724 let style = LayoutStyle {
725 padding: Insets::symmetric(0.0, 10.0),
726 height: Some(34.0),
727 spacing: 10.0,
728 align: Align::Center,
729 ..LayoutStyle::stack(Axis::Horizontal)
730 };
731 let node = cx.container(
732 style,
733 &children,
734 Paint::Quad(QuadPaint { corner_radius: 7.0, ..QuadPaint::new(fill) }),
735 );
736 motion::control(cx, node);
737 let mut semantics = Semantics::new(self.role);
738 if self.role == Role::ListBoxOption {
739 semantics = semantics.selected(self.checked);
740 }
741 if let Some(t) = self.trailing {
742 semantics = semantics.name(self.label).description(t);
743 }
744 if self.element.is_some() {
745 semantics = semantics.name(self.label);
746 }
747 if let Some(on) = self.toggled {
748 semantics = semantics.toggled(on);
749 }
750 if opener.is_some() {
751 semantics = semantics.expanded(is_open);
752 }
753 cx.semantics(node, semantics);
754 if let Some(open) = &opener {
755 let open = open.clone();
756 cx.on_click(node, Rc::new(move || open()));
757 } else {
758 let (action, close) = (self.on_click.clone(), self.close.clone());
759 cx.on_click(
760 node,
761 Rc::new(move || {
762 close();
763 if let Some(a) = &action {
764 a();
765 }
766 }),
767 );
768 }
769 cx.focusable(node, 7.0);
770 let before: Option<crate::KeyHandler> = (self.on_side.is_some() || opener.is_some()).then(|| {
771 let (side, opener) = (self.on_side.clone(), opener.clone());
772 Rc::new(move |key: &ravenrs_core::Key, _: ravenrs_core::Modifiers| match key {
773 ravenrs_core::Key::ArrowRight if opener.is_some() => {
774 if let Some(open) = &opener {
775 open();
776 }
777 true
778 }
779 ravenrs_core::Key::ArrowLeft | ravenrs_core::Key::ArrowRight if side.is_some() => {
780 if let Some(side) = &side {
781 side(*key == ravenrs_core::Key::ArrowRight);
782 }
783 true
784 }
785 _ => false,
786 }) as crate::KeyHandler
787 });
788 self.roving.wire_with(cx, node, None, before);
789 self.follow_pointer(cx, node);
790 self.submenu_overlay(cx, node);
791 cx.cursor(node, CursorIcon::Pointer);
792 node
793 }
794}
795
796fn list_focus(cx: &mut BuildCx<'_>, start: usize) -> util::FocusTarget {
798 util::roving_cell(cx, Some(start))
799}
800
801pub(crate) struct MenuList<'a> {
802 pub entries: &'a [Entry],
803 pub close: Rc<dyn Fn()>,
804 pub on_side: Option<Rc<dyn Fn(bool)>>,
806}
807
808impl View for MenuList<'_> {
809 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
810 MenuLevel {
811 entries: self.entries,
812 close: self.close.clone(),
813 on_side: self.on_side.clone(),
814 takes_focus: true,
815 pointer_in: None,
816 }
817 .build(cx)
818 }
819}
820
821struct MenuLevel<'a> {
823 entries: &'a [Entry],
824 close: Rc<dyn Fn()>,
825 on_side: Option<Rc<dyn Fn(bool)>>,
826 takes_focus: bool,
828 pointer_in: Option<Rc<dyn Fn()>>,
830}
831
832impl View for MenuLevel<'_> {
833 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
834 let mut rows = Vec::new();
835 let count = self.entries.iter().filter(|e| matches!(e, Entry::Item(_))).count();
836 let focus_to = util::roving_cell(cx, self.takes_focus.then_some(0));
837 let open_submenu: State<Option<(usize, bool)>> = cx.use_state(|| None);
838 let has_submenus =
840 self.entries.iter().any(|e| matches!(e, Entry::Item(item) if !item.submenu.is_empty()));
841 let aim = SubmenuAim {
842 last: cx.use_state(|| Rc::new(Cell::new(None))).get(),
843 waiting: cx.use_state(|| None),
844 due: cx.use_state(|| Rc::new(Cell::new(None))).get(),
845 };
846 if let Some((row, its_own)) = aim.waiting.get() {
847 let now = cx.now();
848 let due = aim.due.get().unwrap_or(now + SUBMENU_GRACE);
849 if now >= due {
850 aim.due.set(None);
851 aim.waiting.set(None);
852 switch_submenu(&open_submenu, row, its_own, &focus_to);
853 } else {
854 aim.due.set(Some(due));
855 cx.wake_at(due);
856 }
857 } else {
858 aim.due.set(None);
859 }
860 let mut item_index = 0;
861 for (i, entry) in self.entries.iter().enumerate() {
862 match entry {
863 Entry::Item(item) => {
864 let roving = util::Roving { index: item_index, count, focus_to: focus_to.clone() };
865 item_index += 1;
866 let row = ListRow {
867 roving,
868 label: &item.label,
869 icon: item.icon,
870 trailing: item.shortcut.as_deref(),
871 checked: item.checked == Some(true),
872 destructive: item.destructive,
873 close: self.close.clone(),
874 on_click: item.on_select.clone(),
875 role: Role::MenuItem,
876 on_side: self.on_side.clone(),
877 element: item.element.as_deref().map(|view| view as &dyn View),
878 toggled: item.checked,
879 submenu: &item.submenu,
880 open_submenu: has_submenus.then(|| open_submenu.clone()),
881 aim: has_submenus.then(|| aim.clone()),
882 pointer_in: self.pointer_in.clone(),
883 };
884 rows.push(cx.keyed_child(i as u64, &row));
885 }
886 Entry::Separator => {
887 let line = cx.child(&crate::Divider::horizontal());
888 rows.push(cx.container(
889 LayoutStyle {
890 padding: Insets::symmetric(4.0, 4.0),
891 align: Align::Stretch,
892 ..LayoutStyle::stack(Axis::Vertical)
893 },
894 &[line],
895 Paint::None,
896 ));
897 }
898 }
899 }
900 let node = panel(cx, &rows, 6.0, 220.0);
901 cx.semantics(node, Semantics::new(Role::Menu));
902 if let Some(pointer_in) = self.pointer_in.clone() {
903 cx.on_pointer_move(node, Rc::new(move |_, _| pointer_in()));
904 }
905 node
906 }
907}
908
909impl View for DropdownMenu {
910 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
911 let open: State<bool> = cx.use_state(|| false);
912 let toggle = open.clone();
913 let shown = if self.icon_only { String::new() } else { self.label.clone() };
914 let mut trigger = Button::new(shown)
915 .style(self.style)
916 .size(self.size)
917 .on_click(move || toggle.update(|o| *o = !*o));
918 if !self.icon_only {
919 trigger = trigger.trailing_icon(IconName::ChevronDown);
920 }
921 if let Some(icon) = self.icon {
922 trigger = trigger.icon(icon);
923 }
924 let anchor = cx.child(&trigger);
925 let is_open = open.get();
926 let name = self.icon_only.then(|| self.label.clone());
927 cx.update_semantics(anchor, |s| {
928 s.expanded = Some(is_open);
929 if let Some(name) = name {
930 s.name = Some(name);
931 }
932 });
933 let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
934 if is_open {
935 was_open.set(true);
936 let close: Rc<dyn Fn()> = {
937 let open = open.clone();
938 Rc::new(move || open.set(false))
939 };
940 cx.overlay(
941 anchor,
942 Placement::Below,
943 &MenuList { entries: &self.entries, close: close.clone(), on_side: None },
944 Some(close),
945 );
946 } else if was_open.replace(false) {
947 cx.request_focus_node(anchor);
949 }
950 anchor
951 }
952}
953
954#[derive(Debug)]
958#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
959pub struct Select {
960 selected: Binding<usize>,
961 options: Vec<String>,
962 width: f32,
963 label: Option<String>,
964 state: util::EditorState,
965 fill: bool,
966 size: crate::ButtonSize,
967 searchable: bool,
968}
969
970#[cfg_attr(feature = "live", ravenrs_live::expose)]
971impl Select {
972 pub fn new(selected: Binding<usize>, options: impl IntoIterator<Item = impl Into<String>>) -> Self {
974 Self {
975 selected,
976 options: options.into_iter().map(Into::into).collect(),
977 width: 220.0,
978 fill: false,
979 label: None,
980 state: util::EditorState::default(),
981 size: crate::ButtonSize::Medium,
982 searchable: false,
983 }
984 }
985
986 pub fn of<T: Clone + PartialEq + 'static>(
1006 value: Binding<T>,
1007 options: impl IntoIterator<Item = (T, impl Into<String>)>,
1008 ) -> Self {
1009 let (values, labels): (Vec<T>, Vec<String>) =
1010 options.into_iter().map(|(value, label)| (value, label.into())).unzip();
1011 let values = Rc::new(values);
1012 let (read, write, known) = (value.clone(), value, values.clone());
1013 let selected = Binding::new(
1015 move || {
1016 let current = read.get();
1017 known.iter().position(|v| *v == current).unwrap_or(usize::MAX)
1018 },
1019 move |i: usize| {
1020 if let Some(value) = values.get(i) {
1021 write.set(value.clone());
1022 }
1023 },
1024 );
1025 Self::new(selected, labels)
1026 }
1027
1028 pub fn searchable(mut self) -> Self {
1030 self.searchable = true;
1031 self
1032 }
1033
1034 pub fn size(mut self, size: crate::ButtonSize) -> Self {
1037 self.size = size;
1038 self
1039 }
1040
1041 pub fn fill(mut self) -> Self {
1043 self.fill = true;
1044 self
1045 }
1046
1047 pub fn width(mut self, width: f32) -> Self {
1049 self.width = width;
1050 self
1051 }
1052
1053 pub fn label(mut self, label: impl Into<String>) -> Self {
1056 self.label = Some(label.into());
1057 self
1058 }
1059
1060 pub fn disabled(mut self, disabled: bool) -> Self {
1062 self.state.disabled = disabled;
1063 self
1064 }
1065
1066 pub fn read_only(mut self, read_only: bool) -> Self {
1068 self.state.read_only = read_only;
1069 self
1070 }
1071}
1072
1073pub(crate) struct Options<'a> {
1074 pub options: &'a [String],
1075 pub selected: Binding<usize>,
1076 pub close: Rc<dyn Fn()>,
1077 pub width: f32,
1078}
1079
1080impl View for Options<'_> {
1081 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1082 let current = self.selected.get();
1083 let count = self.options.len();
1084 let focus_to = list_focus(cx, if current < count { current } else { 0 });
1086 let rows: Vec<NodeId> = self
1087 .options
1088 .iter()
1089 .enumerate()
1090 .map(|(i, option)| {
1091 let selected = self.selected.clone();
1092 let row = ListRow {
1093 roving: util::Roving { index: i, count, focus_to: focus_to.clone() },
1094 label: option,
1095 icon: None,
1096 trailing: None,
1097 checked: i == current,
1098 destructive: false,
1099 close: self.close.clone(),
1100 on_click: Some(Rc::new(move || selected.set(i))),
1101 role: Role::ListBoxOption,
1102 on_side: None,
1103 element: None,
1104 toggled: None,
1105 submenu: &[],
1106 open_submenu: None,
1107 aim: None,
1108 pointer_in: None,
1109 };
1110 cx.keyed_child(i as u64, &row)
1111 })
1112 .collect();
1113 let node = panel(cx, &rows, 6.0, self.width);
1114 cx.semantics(node, Semantics::new(Role::ListBox));
1115 node
1116 }
1117}
1118
1119impl View for Select {
1120 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1121 let theme = *cx.theme();
1122 let open: State<bool> = cx.use_state(|| false);
1123 let query: State<String> = cx.use_state(String::new);
1124 let highlight: State<usize> = cx.use_state(|| 0);
1125 let state = self.state;
1126 let is_open = open.get() && state.accepts_input();
1127 let hovered = cx.is_hovered() && state.accepts_input();
1128 let current = self.options.get(self.selected.get()).map_or("", String::as_str);
1129 let label =
1130 util::label(cx, current, self.size.text_style(), None, state.ink(theme.color(Semantic::Ink)));
1131 let label = cx.container(
1132 LayoutStyle { grow: 1.0, ..LayoutStyle::stack(Axis::Horizontal) },
1133 &[label],
1134 Paint::None,
1135 );
1136 let chevron = util::icon(
1137 cx,
1138 if is_open { IconName::ChevronUp } else { IconName::ChevronDown },
1139 if self.size == crate::ButtonSize::Mini { 12.0 } else { 16.0 },
1140 state.ink(theme.color(Semantic::InkMuted)),
1141 );
1142 let border = if is_open {
1143 Border { width: 1.5, color: theme.color(Semantic::Accent) }
1144 } else {
1145 let base = theme.color(Semantic::Border);
1146 Border {
1147 width: 1.0,
1148 color: state.ink(if hovered { base.lerp(theme.color(Semantic::Ink), 0.25) } else { base }),
1149 }
1150 };
1151 let style = LayoutStyle {
1152 padding: Insets::symmetric(
1153 0.0,
1154 if matches!(self.size, crate::ButtonSize::Small | crate::ButtonSize::Mini) {
1155 8.0
1156 } else {
1157 12.0
1158 },
1159 ),
1160 width: (!self.fill).then_some(self.width),
1161 height: Some(self.size.height()),
1162 spacing: 8.0,
1163 align: Align::Center,
1164 align_self: Some(if self.fill { Align::Stretch } else { Align::Fit }),
1165 ..LayoutStyle::stack(Axis::Horizontal)
1166 };
1167 let paint = QuadPaint {
1168 corner_radius: theme.radii.md,
1169 border: Some(border),
1170 ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
1171 };
1172 let node = cx.container(style, &[label, chevron], Paint::Quad(paint));
1173 cx.semantics(label, Semantics::hidden());
1174 let mut semantics = Semantics::new(Role::ComboBox).value(current).expanded(is_open);
1175 if let Some(label) = &self.label {
1176 semantics = semantics.name(label.clone());
1177 }
1178 cx.semantics(node, state.semantics(semantics));
1179 if state.disabled {
1180 return node;
1181 }
1182 if !state.accepts_input() {
1183 cx.focusable(node, theme.radii.md);
1184 return node;
1185 }
1186 let reset: Rc<dyn Fn()> = {
1188 let (query, highlight, selected) = (query.clone(), highlight.clone(), self.selected.clone());
1189 let count = self.options.len();
1190 Rc::new(move || {
1191 query.set(String::new());
1192 highlight.set(Some(selected.get()).filter(|i| *i < count).unwrap_or(0));
1193 })
1194 };
1195 let (toggle, new_search) = (open.clone(), reset.clone());
1196 cx.on_click(
1197 node,
1198 Rc::new(move || {
1199 new_search();
1200 toggle.update(|o| *o = !*o);
1201 }),
1202 );
1203 let opener = open.clone();
1205 cx.on_key(
1206 node,
1207 Rc::new(move |key, _| {
1208 let opens = matches!(key, ravenrs_core::Key::ArrowDown | ravenrs_core::Key::ArrowUp);
1209 if opens {
1210 reset();
1211 opener.set(true);
1212 }
1213 opens
1214 }),
1215 );
1216 cx.focusable(node, theme.radii.md);
1217 cx.cursor(node, CursorIcon::Pointer);
1218 let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
1219 if !is_open && was_open.replace(false) {
1220 cx.request_focus();
1222 }
1223 if is_open {
1224 was_open.set(true);
1225 let close: Rc<dyn Fn()> = {
1226 let open = open.clone();
1227 Rc::new(move || open.set(false))
1228 };
1229 if self.searchable {
1230 let pick: Rc<dyn Fn(usize)> = {
1231 let (selected, close) = (self.selected.clone(), close.clone());
1232 Rc::new(move |i| {
1233 selected.set(i);
1234 close();
1235 })
1236 };
1237 let list = crate::floating::SearchPanel {
1238 options: &self.options,
1239 current: Some(self.selected.get()),
1240 pick,
1241 query,
1242 highlight,
1243 open: open.clone(),
1244 width: self.width,
1245 };
1246 cx.overlay(node, Placement::Below, &list, Some(close));
1247 } else {
1248 let list = Options {
1249 options: &self.options,
1250 selected: self.selected.clone(),
1251 close: close.clone(),
1252 width: self.width,
1253 };
1254 cx.overlay(node, Placement::Below, &list, Some(close));
1255 }
1256 }
1257 node
1258 }
1259}
1260
1261#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1276pub struct Dialog {
1277 open: Binding<bool>,
1278 title: String,
1279 description: Option<String>,
1280 content: Option<AnyView>,
1281 actions: Vec<AnyView>,
1282 width: f32,
1283 close_label: Option<String>,
1284 close_on_escape: bool,
1285 close_on_backdrop: bool,
1286 close_button: bool,
1287}
1288
1289impl fmt::Debug for Dialog {
1290 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1291 f.debug_struct("Dialog").field("title", &self.title).finish_non_exhaustive()
1292 }
1293}
1294
1295#[cfg_attr(feature = "live", ravenrs_live::expose)]
1296impl Dialog {
1297 pub fn new(open: Binding<bool>, title: impl Into<String>) -> Self {
1299 Self {
1300 open,
1301 title: title.into(),
1302 description: None,
1303 content: None,
1304 actions: Vec::new(),
1305 width: 460.0,
1306 close_label: None,
1307 close_on_escape: true,
1308 close_on_backdrop: true,
1309 close_button: true,
1310 }
1311 }
1312
1313 pub fn description(mut self, text: impl Into<String>) -> Self {
1315 self.description = Some(text.into());
1316 self
1317 }
1318
1319 pub fn content(mut self, content: impl View + 'static) -> Self {
1321 self.content = Some(Box::new(content));
1322 self
1323 }
1324
1325 pub fn width(mut self, width: f32) -> Self {
1328 self.width = width;
1329 self
1330 }
1331
1332 pub fn close_label(mut self, label: impl Into<String>) -> Self {
1334 self.close_label = Some(label.into());
1335 self
1336 }
1337
1338 pub fn close_on_escape(mut self, close: bool) -> Self {
1340 self.close_on_escape = close;
1341 self
1342 }
1343
1344 pub fn close_on_backdrop(mut self, close: bool) -> Self {
1346 self.close_on_backdrop = close;
1347 self
1348 }
1349
1350 pub fn close_button(mut self, show: bool) -> Self {
1352 self.close_button = show;
1353 self
1354 }
1355
1356 pub fn action(mut self, action: impl View + 'static) -> Self {
1358 self.actions.push(Box::new(action));
1359 self
1360 }
1361}
1362
1363struct DialogLayer<'a> {
1364 dialog: &'a Dialog,
1365}
1366
1367impl View for DialogLayer<'_> {
1368 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1369 let theme = *cx.theme();
1370 let viewport = cx.viewport();
1371 let d = self.dialog;
1372 let close = d.open.clone();
1373 let title = util::label(cx, &d.title, TextStyle::Title, None, theme.color(Semantic::Ink));
1374 let title = cx.container(
1375 LayoutStyle { grow: 1.0, ..LayoutStyle::stack(Axis::Horizontal) },
1376 &[title],
1377 Paint::None,
1378 );
1379 cx.semantics(title, Semantics::new(Role::Heading).level(2));
1380 let mut header = vec![title];
1381 if d.close_button {
1382 header.push(
1383 cx.child(
1384 &IconButton::new(IconName::X)
1385 .label(d.close_label.clone().unwrap_or_else(|| cx.labels().close.clone()))
1386 .size(crate::ButtonSize::Small)
1387 .on_click(move || close.set(false)),
1388 ),
1389 );
1390 }
1391 let header = cx.container(
1392 LayoutStyle { align: Align::Center, spacing: 12.0, ..LayoutStyle::stack(Axis::Horizontal) },
1393 &header,
1394 Paint::None,
1395 );
1396 let mut body = vec![header];
1397 if let Some(text) = &d.description {
1398 body.push(util::label(cx, text, TextStyle::Body, None, theme.color(Semantic::InkMuted)));
1399 }
1400 if let Some(content) = &d.content {
1401 body.push(cx.child(content.as_ref()));
1402 }
1403 if !d.actions.is_empty() {
1404 let actions: Vec<NodeId> = d.actions.iter().map(|a| cx.child(a.as_ref())).collect();
1405 body.push(cx.container(
1406 LayoutStyle {
1407 spacing: 10.0,
1408 justify: Justify::End,
1409 padding: Insets { top: 8.0, ..Insets::ZERO },
1410 ..LayoutStyle::stack(Axis::Horizontal)
1411 },
1412 &actions,
1413 Paint::None,
1414 ));
1415 }
1416 let card = cx.container(
1417 LayoutStyle {
1418 width: Some((viewport.width - 32.0).clamp(240.0, d.width.max(240.0))),
1419 padding: Insets::uniform(24.0),
1420 spacing: 14.0,
1421 align: Align::Stretch,
1422 ..LayoutStyle::stack(Axis::Vertical)
1423 },
1424 &body,
1425 Paint::Quad(QuadPaint {
1426 corner_radius: theme.radii.xl,
1427 border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
1428 shadow: Some(theme.elevation.raised),
1429 ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
1430 }),
1431 );
1432 let mut semantics = Semantics::new(Role::Dialog).name(d.title.clone()).modal();
1433 if let Some(text) = &d.description {
1434 semantics = semantics.description(text.clone());
1435 }
1436 cx.semantics(card, semantics);
1437 cx.cursor(card, CursorIcon::Default);
1439 cx.motion(
1440 card,
1441 Motion::new()
1442 .surface_exit()
1443 .enter(Effect::moved(0.0, 8.0).with_scale(0.96), theme.motion.fast)
1444 .exit(Effect { opacity: 0.0, ..Effect::IDENTITY.with_scale(0.98) }, theme.motion.fast),
1445 );
1446 let scrim = motion::scrim(cx, viewport);
1447 let backdrop = cx.container(
1448 LayoutStyle {
1449 width: Some(viewport.width),
1450 height: Some(viewport.height),
1451 align: Align::Center,
1452 justify: Justify::Center,
1453 ..LayoutStyle::stack(Axis::Vertical)
1454 },
1455 &[scrim, card],
1456 Paint::None,
1457 );
1458 let close = d.close_on_backdrop.then(|| d.open.clone());
1460 cx.on_click(
1461 backdrop,
1462 Rc::new(move || {
1463 if let Some(close) = &close {
1464 close.set(false);
1465 }
1466 }),
1467 );
1468 backdrop
1469 }
1470}
1471
1472impl View for Dialog {
1473 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1474 let anchor = cx.empty();
1475 if self.open.get() {
1476 let close = self.close_on_escape.then(|| self.open.clone());
1478 cx.overlay(
1479 anchor,
1480 Placement::Fill,
1481 &DialogLayer { dialog: self },
1482 Some(Rc::new(move || {
1483 if let Some(close) = &close {
1484 close.set(false);
1485 }
1486 })),
1487 );
1488 }
1489 anchor
1490 }
1491}