Skip to main content

sequoia_elements/
floating_menu.rs

1#![forbid(unsafe_code)]
2
3use std::cell::Cell;
4use std::rc::Rc;
5
6use sequoia_core::{Color, Edges, Length, Point};
7use sequoia_input::{CursorIcon, Event, EventPhase, Key, PointerButton, PointerEvent};
8use sequoia_layout::LayoutStyle;
9use sequoia_reactive::{Environment, Signal};
10use sequoia_style::{Backdrop, FloatingMenuStyle, Theme, VisualState};
11use sequoia_view::{
12    Align, Anchor, AnyView, Column, Dismiss, Easing, EventCtx, Icon, OverlayEntry, OverlayLayer,
13    PaintCtx, Placement, Presence, Row, Spacer, Text, Unit, View,
14};
15
16use crate::container::Container;
17use crate::kbd::Kbd;
18use crate::modal::OverlayPanel;
19use crate::popup::PopupState;
20
21pub const MENU_WIDTH: f32 = 220.0;
22
23pub(crate) const MENU_ENTER_SECS: f32 = 0.16;
24const MENU_ENTER_EASE: Easing = Easing::CubicBezier(0.3, 1.2, 0.4, 1.0);
25const MENU_ENTER_DY: f32 = 6.0;
26
27#[derive(Clone)]
28enum MenuAction {
29    Plain(Rc<dyn Fn()>),
30    Env(Rc<dyn Fn(&Environment)>),
31}
32
33enum MenuEntry {
34    Item {
35        icon: Option<String>,
36        label: String,
37        shortcut: Option<String>,
38        danger: bool,
39        disabled: bool,
40        action: MenuAction,
41    },
42    Custom {
43        content: AnyView,
44        disabled: bool,
45        action: MenuAction,
46    },
47    Separator,
48}
49
50pub struct FloatingMenu {
51    child: AnyView,
52    rows: Vec<MenuEntry>,
53    open: Signal<bool>,
54    popup: PopupState,
55
56    open_at: Rc<Cell<Option<Point>>>,
57    style: Option<FloatingMenuStyle>,
58    width: Length,
59    background: Option<Color>,
60    border: Option<Color>,
61    text_color: Option<Color>,
62    radius: Option<f32>,
63    padding: Option<(f32, f32)>,
64    font_size: Option<f32>,
65    backdrop: Option<Backdrop>,
66    below_invoker: bool,
67    click_opens: bool,
68    margin: Edges,
69}
70
71#[must_use]
72pub struct MenuItem {
73    icon: Option<String>,
74    label: String,
75    shortcut: Option<String>,
76    danger: bool,
77    disabled: bool,
78    menu: FloatingMenu,
79}
80
81impl MenuItem {
82    pub fn icon(mut self, icon: impl Into<String>) -> Self {
83        self.icon = Some(icon.into());
84        self
85    }
86
87    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
88        self.shortcut = Some(shortcut.into());
89        self
90    }
91
92    pub fn danger(mut self, danger: bool) -> Self {
93        self.danger = danger;
94        self
95    }
96
97    pub fn disabled(mut self, disabled: bool) -> Self {
98        self.disabled = disabled;
99        self
100    }
101
102    pub fn on_click(self, action: impl Fn() + 'static) -> FloatingMenu {
103        let mut menu = self.menu;
104        menu.push_item(
105            self.icon,
106            self.label,
107            self.shortcut,
108            self.danger,
109            self.disabled,
110            MenuAction::Plain(Rc::new(action)),
111        );
112        menu
113    }
114
115    pub fn on_click_env(self, action: impl Fn(&Environment) + 'static) -> FloatingMenu {
116        let mut menu = self.menu;
117        menu.push_item(
118            self.icon,
119            self.label,
120            self.shortcut,
121            self.danger,
122            self.disabled,
123            MenuAction::Env(Rc::new(action)),
124        );
125        menu
126    }
127}
128
129#[must_use]
130pub struct CustomMenuItem {
131    content: AnyView,
132    disabled: bool,
133    menu: FloatingMenu,
134}
135
136impl CustomMenuItem {
137    pub fn disabled(mut self, disabled: bool) -> Self {
138        self.disabled = disabled;
139        self
140    }
141
142    pub fn on_click(self, action: impl Fn() + 'static) -> FloatingMenu {
143        let mut menu = self.menu;
144        menu.rows.push(MenuEntry::Custom {
145            content: self.content,
146            disabled: self.disabled,
147            action: MenuAction::Plain(Rc::new(action)),
148        });
149        menu
150    }
151
152    pub fn on_click_env(self, action: impl Fn(&Environment) + 'static) -> FloatingMenu {
153        let mut menu = self.menu;
154        menu.rows.push(MenuEntry::Custom {
155            content: self.content,
156            disabled: self.disabled,
157            action: MenuAction::Env(Rc::new(action)),
158        });
159        menu
160    }
161}
162
163impl FloatingMenu {
164    pub fn new(child: impl View + 'static) -> Self {
165        Self::wrap(child)
166    }
167
168    pub fn wrap(child: impl View + 'static) -> Self {
169        let open = Signal::new(false);
170        let popup = PopupState::new(open.clone());
171        Self {
172            child: AnyView::new(child),
173            rows: Vec::new(),
174            open,
175            popup,
176            open_at: Rc::new(Cell::new(None)),
177            style: None,
178            width: MENU_WIDTH.into(),
179            background: None,
180            border: None,
181            text_color: None,
182            radius: None,
183            padding: None,
184            font_size: None,
185            backdrop: None,
186            below_invoker: false,
187            click_opens: false,
188            margin: Edges::ZERO,
189        }
190    }
191
192    pub fn open_signal(&self) -> Signal<bool> {
193        self.open.clone()
194    }
195
196    pub fn is_open(&self) -> bool {
197        self.open.get_untracked()
198    }
199
200    pub fn row(
201        mut self,
202        icon: impl Into<String>,
203        label: impl Into<String>,
204        action: impl Fn() + 'static,
205    ) -> Self {
206        self.rows.push(MenuEntry::Item {
207            icon: Some(icon.into()),
208            label: label.into(),
209            shortcut: None,
210            danger: false,
211            disabled: false,
212            action: MenuAction::Plain(Rc::new(action)),
213        });
214        self
215    }
216
217    pub fn row_env(
218        mut self,
219        icon: impl Into<String>,
220        label: impl Into<String>,
221        action: impl Fn(&Environment) + 'static,
222    ) -> Self {
223        self.rows.push(MenuEntry::Item {
224            icon: Some(icon.into()),
225            label: label.into(),
226            shortcut: None,
227            danger: false,
228            disabled: false,
229            action: MenuAction::Env(Rc::new(action)),
230        });
231        self
232    }
233
234    pub fn shortcut_row(
235        mut self,
236        icon: impl Into<String>,
237        label: impl Into<String>,
238        shortcut: impl Into<String>,
239        action: impl Fn() + 'static,
240    ) -> Self {
241        self.rows.push(MenuEntry::Item {
242            icon: Some(icon.into()),
243            label: label.into(),
244            shortcut: Some(shortcut.into()),
245            danger: false,
246            disabled: false,
247            action: MenuAction::Plain(Rc::new(action)),
248        });
249        self
250    }
251
252    pub fn text_row(mut self, label: impl Into<String>, action: impl Fn() + 'static) -> Self {
253        self.rows.push(MenuEntry::Item {
254            icon: None,
255            label: label.into(),
256            shortcut: None,
257            danger: false,
258            disabled: false,
259            action: MenuAction::Plain(Rc::new(action)),
260        });
261        self
262    }
263
264    pub fn text_row_env(
265        mut self,
266        label: impl Into<String>,
267        action: impl Fn(&Environment) + 'static,
268    ) -> Self {
269        self.rows.push(MenuEntry::Item {
270            icon: None,
271            label: label.into(),
272            shortcut: None,
273            danger: false,
274            disabled: false,
275            action: MenuAction::Env(Rc::new(action)),
276        });
277        self
278    }
279
280    pub fn danger_row(
281        mut self,
282        icon: impl Into<String>,
283        label: impl Into<String>,
284        action: impl Fn() + 'static,
285    ) -> Self {
286        self.rows.push(MenuEntry::Item {
287            icon: Some(icon.into()),
288            label: label.into(),
289            shortcut: None,
290            danger: true,
291            disabled: false,
292            action: MenuAction::Plain(Rc::new(action)),
293        });
294        self
295    }
296
297    pub fn item_raw(
298        mut self,
299        icon: Option<String>,
300        label: impl Into<String>,
301        shortcut: Option<String>,
302        danger: bool,
303        disabled: bool,
304        action: impl Fn() + 'static,
305    ) -> Self {
306        self.push_item(
307            icon,
308            label.into(),
309            shortcut,
310            danger,
311            disabled,
312            MenuAction::Plain(Rc::new(action)),
313        );
314        self
315    }
316
317    pub fn item(self, label: impl Into<String>) -> MenuItem {
318        MenuItem {
319            icon: None,
320            label: label.into(),
321            shortcut: None,
322            danger: false,
323            disabled: false,
324            menu: self,
325        }
326    }
327
328    pub fn custom_item(self, content: impl View + 'static) -> CustomMenuItem {
329        CustomMenuItem {
330            content: AnyView::new(content),
331            disabled: false,
332            menu: self,
333        }
334    }
335
336    pub fn custom_row(mut self, content: impl View + 'static, action: impl Fn() + 'static) -> Self {
337        self.rows.push(MenuEntry::Custom {
338            content: AnyView::new(content),
339            disabled: false,
340            action: MenuAction::Plain(Rc::new(action)),
341        });
342        self
343    }
344
345    pub fn custom_row_env(
346        mut self,
347        content: impl View + 'static,
348        action: impl Fn(&Environment) + 'static,
349    ) -> Self {
350        self.rows.push(MenuEntry::Custom {
351            content: AnyView::new(content),
352            disabled: false,
353            action: MenuAction::Env(Rc::new(action)),
354        });
355        self
356    }
357
358    fn push_item(
359        &mut self,
360        icon: Option<String>,
361        label: String,
362        shortcut: Option<String>,
363        danger: bool,
364        disabled: bool,
365        action: MenuAction,
366    ) {
367        self.rows.push(MenuEntry::Item {
368            icon,
369            label,
370            shortcut,
371            danger,
372            disabled,
373            action,
374        });
375    }
376
377    pub fn separator(mut self) -> Self {
378        self.rows.push(MenuEntry::Separator);
379        self
380    }
381
382    pub fn style(mut self, style: FloatingMenuStyle) -> Self {
383        self.style = Some(style);
384        self
385    }
386
387    pub fn popup_width(mut self, width: impl Into<Length>) -> Self {
388        self.width = width.into();
389        self
390    }
391
392    #[deprecated(
393        since = "0.1.0",
394        note = "misleading name - use `popup_width()` (popup width, not invoker width)"
395    )]
396    pub fn width(mut self, width: impl Into<Length>) -> Self {
397        self.width = width.into();
398        self
399    }
400
401    pub fn background(mut self, color: Color) -> Self {
402        self.background = Some(color);
403        self
404    }
405
406    pub fn border(mut self, color: Color) -> Self {
407        self.border = Some(color);
408        self
409    }
410
411    pub fn text_color(mut self, color: Color) -> Self {
412        self.text_color = Some(color);
413        self
414    }
415
416    pub fn radius(mut self, radius: f32) -> Self {
417        self.radius = Some(radius.max(0.0));
418        self
419    }
420
421    pub fn font_size(mut self, size: f32) -> Self {
422        self.font_size = Some(size);
423        self
424    }
425
426    pub fn padding_xy(mut self, pad_x: f32, pad_y: f32) -> Self {
427        self.padding = Some((pad_x, pad_y));
428        self
429    }
430
431    pub fn backdrop(mut self, backdrop: Backdrop) -> Self {
432        self.backdrop = Some(backdrop);
433        self
434    }
435
436    pub fn below_invoker(mut self) -> Self {
437        self.below_invoker = true;
438        self
439    }
440
441    pub fn open_on_click(mut self) -> Self {
442        self.click_opens = true;
443        self
444    }
445
446    pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
447        let m = margin.into();
448        self.margin = Edges::only(
449            sanitize_margin(m.left),
450            sanitize_margin(m.top),
451            sanitize_margin(m.right),
452            sanitize_margin(m.bottom),
453        );
454        self
455    }
456
457    pub fn margin_xy(self, x: f32, y: f32) -> Self {
458        self.margin(Edges::xy(x, y))
459    }
460
461    pub fn margin_x(mut self, x: f32) -> Self {
462        let x = sanitize_margin(x);
463        self.margin.left = x;
464        self.margin.right = x;
465        self
466    }
467
468    pub fn margin_y(mut self, y: f32) -> Self {
469        let y = sanitize_margin(y);
470        self.margin.top = y;
471        self.margin.bottom = y;
472        self
473    }
474
475    pub fn margin_left(mut self, v: f32) -> Self {
476        self.margin.left = sanitize_margin(v);
477        self
478    }
479
480    pub fn margin_right(mut self, v: f32) -> Self {
481        self.margin.right = sanitize_margin(v);
482        self
483    }
484
485    pub fn margin_top(mut self, v: f32) -> Self {
486        self.margin.top = sanitize_margin(v);
487        self
488    }
489
490    pub fn margin_bottom(mut self, v: f32) -> Self {
491        self.margin.bottom = sanitize_margin(v);
492        self
493    }
494
495    fn menu_view(&self, theme: &Theme) -> AnyView {
496        let style = self.style.unwrap_or_default();
497        let font_size = self.font_size.or(style.font_size).unwrap_or(13.5);
498        let text = self.text_color.or(style.text).unwrap_or(theme.text);
499        let danger = Color::hex(0xC2452D);
500        let (pad_x, pad_y) = self.padding.or(style.padding).unwrap_or((6.0, 6.0));
501
502        let mut col = Column::new().fill_width().gap(2.0);
503        for row in &self.rows {
504            match row {
505                MenuEntry::Separator => {
506                    col = col.child(
507                        Container::new()
508                            .fill_width()
509                            .height(1.0)
510                            .background(theme.border),
511                    );
512                }
513                MenuEntry::Item {
514                    icon,
515                    label,
516                    shortcut,
517                    danger: is_danger,
518                    disabled: is_disabled,
519                    action,
520                } => {
521                    let color = if *is_disabled {
522                        theme.text_secondary
523                    } else if *is_danger {
524                        danger
525                    } else {
526                        text
527                    };
528                    let popup = self.popup.clone();
529                    let action = action.clone();
530                    let mut line = Row::new().fill_width().gap(10.0).align_items(Align::Center);
531                    if let Some(name) = icon {
532                        line = line.child(Icon::new(name.clone()).size(16.0).color(color));
533                    }
534                    line = line
535                        .child(Text::new(label.clone()).size(font_size).color(color))
536                        .child(Spacer::flex(1.0));
537                    if let Some(kb) = shortcut {
538                        line = line.child(Kbd::new(kb.clone()));
539                    }
540                    let cell = Container::new()
541                        .fill_width()
542                        .radius(8.0)
543                        .padding_xy(10.0, 7.0)
544                        .child(line);
545
546                    if *is_disabled {
547                        col = col.child(cell);
548                        continue;
549                    }
550                    col = col.child({
551                        match action {
552                            MenuAction::Plain(f) => cell.on_click(move || {
553                                f();
554                                popup.close();
555                            }),
556                            MenuAction::Env(f) => cell.on_click_env(move |env| {
557                                f(env);
558                                popup.close();
559                            }),
560                        }
561                    });
562                }
563                MenuEntry::Custom {
564                    content,
565                    disabled: is_disabled,
566                    action,
567                } => {
568                    let popup = self.popup.clone();
569                    let action = action.clone();
570                    let cell = Container::new()
571                        .fill_width()
572                        .radius(8.0)
573                        .padding_xy(10.0, 7.0)
574                        .child(content.clone());
575
576                    if *is_disabled {
577                        col = col.child(cell);
578                        continue;
579                    }
580                    col = col.child({
581                        match action {
582                            MenuAction::Plain(f) => cell.on_click(move || {
583                                f();
584                                popup.close();
585                            }),
586                            MenuAction::Env(f) => cell.on_click_env(move |env| {
587                                f(env);
588                                popup.close();
589                            }),
590                        }
591                    });
592                }
593            }
594        }
595        AnyView::new(
596            Container::new()
597                .fill_width()
598                .padding_xy(pad_x, pad_y)
599                .child(col),
600        )
601    }
602}
603
604impl View for FloatingMenu {
605    fn body(&self) -> impl View + 'static {
606        self.popup.subscribe();
607        Unit
608    }
609
610    fn children_override(&self) -> Option<Vec<AnyView>> {
611        Some(vec![self.child.clone()])
612    }
613
614    fn layout_style(&self) -> LayoutStyle {
615        let mut s = self.child.inner_layout_style();
616        if self.margin != Edges::ZERO {
617            s.margin = self.margin;
618        }
619        s
620    }
621
622    fn focusable(&self) -> bool {
623        true
624    }
625
626    fn has_visual_state(&self) -> bool {
627        true
628    }
629
630    fn cursor_icon(&self, _state: VisualState) -> CursorIcon {
631        CursorIcon::Default
632    }
633
634    fn on_event(&self, event: &Event, ctx: &mut EventCtx) {
635        match event {
636            Event::Pointer(PointerEvent::Down { button, pos, .. }) => {
637                if self.rows.is_empty() {
638                    return;
639                }
640                match button {
641                    PointerButton::Right => {
642                        if ctx.phase == EventPhase::Capture {
643                            return;
644                        }
645
646                        self.open_at.set(Some(*pos));
647
648                        self.popup.open();
649                        ctx.handled();
650                    }
651
652                    PointerButton::Left if self.click_opens => {
653                        if ctx.phase != EventPhase::Capture {
654                            return;
655                        }
656                        self.popup.open();
657                    }
658                    _ => {}
659                }
660            }
661            Event::Key(key) => {
662                if ctx.phase != EventPhase::Target || !ctx.state.focused {
663                    return;
664                }
665                if key.key == Key::ESCAPE {
666                    self.popup.close_in(ctx);
667                }
668            }
669            _ => {}
670        }
671    }
672
673    fn on_unmount(&self) {
674        self.popup.forget();
675    }
676
677    fn paint_self(&self, ctx: &mut PaintCtx) {
678        if self.rows.is_empty() {
679            return;
680        }
681        let theme = ctx.theme();
682        let style = self.style.unwrap_or_default();
683        let radius = self.radius.or(style.radius).unwrap_or(4.0);
684        let panel = OverlayPanel {
685            child: self.menu_view(&theme),
686            width: self.width,
687            height: Length::Fit,
688            padding: Edges::ZERO,
689            background: self
690                .background
691                .or(style.background)
692                .unwrap_or(theme.surface),
693            border: self.border.or(style.border).unwrap_or(theme.border),
694            border_width: 1.0,
695            radius,
696            backdrop: self.backdrop.unwrap_or(Backdrop::XS),
697        };
698
699        let enter = Presence::new(Signal::new(true))
700            .from(0.0)
701            .enter_duration(MENU_ENTER_SECS)
702            .enter_easing(MENU_ENTER_EASE)
703            .slide(0.0, MENU_ENTER_DY)
704            .child(panel);
705        let below = self.below_invoker;
706        let el = ctx.element;
707
708        if !self.popup.is_open() {
709            self.open_at.set(None);
710        }
711        let open_at = self.open_at.clone();
712        self.popup.sync_show(ctx, move || {
713            let entry = OverlayEntry::new(enter)
714                .layer(OverlayLayer::Menu)
715                .dismiss(Dismiss::OutsideClick);
716            if below {
717                entry
718                    .anchor(Anchor::View(el))
719                    .placement(Placement::BottomStart)
720                    .offset(0.0, 4.0)
721            } else {
722                let anchor = open_at.get().map(Anchor::Point).unwrap_or(Anchor::Cursor);
723                entry.anchor(anchor).placement(Placement::Cursor)
724            }
725        });
726    }
727}
728
729fn sanitize_margin(v: f32) -> f32 {
730    if v.is_finite() { v.max(0.0) } else { 0.0 }
731}