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}