1#![forbid(unsafe_code)]
31
32use std::sync::Arc;
33
34use llimphi_ui::llimphi_layout::taffy::{
35 prelude::{auto, length, percent, FlexDirection, Position, Size, Style},
36 AlignItems, JustifyContent, Rect,
37};
38use llimphi_ui::llimphi_raster::peniko::Color;
39use llimphi_ui::llimphi_text::Alignment;
40use llimphi_ui::View;
41use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
42
43#[derive(Debug, Clone, Copy)]
48pub struct ContextMenuPalette {
49 pub bg_panel: Color,
50 pub bg_hover: Color,
52 pub bg_active: Color,
54 pub fg_text: Color,
55 pub fg_active: Color,
57 pub fg_shortcut: Color,
58 pub fg_disabled: Color,
59 pub fg_destructive: Color,
60 pub fg_header: Color,
61 pub fg_icon: Color,
63 pub accent: Color,
64 pub border: Color,
65 pub separator: Color,
66 pub scrim: Color,
67 pub radius: f64,
69 pub panel: PanelStyle,
70}
71
72impl ContextMenuPalette {
73 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
74 let mut panel = PanelStyle::neutral(t);
77 panel.bg_base = t.bg_panel;
78 panel.radius = PANEL_RADIUS as f64;
79 Self {
80 bg_panel: t.bg_panel,
81 bg_hover: t.bg_row_hover,
82 bg_active: t.bg_selected,
83 fg_text: t.fg_text,
84 fg_active: t.fg_text,
85 fg_shortcut: t.fg_muted,
86 fg_disabled: t.fg_muted,
87 fg_destructive: t.fg_destructive,
88 fg_header: t.fg_muted,
89 fg_icon: t.fg_muted,
90 accent: t.accent,
91 border: t.border,
92 separator: t.border,
93 scrim: Color::from_rgba8(0, 0, 0, 64),
94 radius: PANEL_RADIUS as f64,
95 panel,
96 }
97 }
98}
99
100#[derive(Debug, Clone)]
105pub struct ContextMenuItem {
106 pub label: String,
107 pub shortcut: Option<String>,
108 pub icon: Option<String>,
109 pub enabled: bool,
110 pub separator: bool,
111 pub destructive: bool,
112 pub children: Vec<ContextMenuItem>,
114}
115
116impl ContextMenuItem {
117 pub fn action(label: impl Into<String>) -> Self {
118 Self {
119 label: label.into(),
120 shortcut: None,
121 icon: None,
122 enabled: true,
123 separator: false,
124 destructive: false,
125 children: Vec::new(),
126 }
127 }
128
129 pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
130 self.shortcut = Some(shortcut.into());
131 self
132 }
133
134 pub fn icon(mut self, glyph: impl Into<String>) -> Self {
136 self.icon = Some(glyph.into());
137 self
138 }
139
140 pub fn disabled(mut self) -> Self {
141 self.enabled = false;
142 self
143 }
144
145 pub fn destructive(mut self) -> Self {
146 self.destructive = true;
147 self
148 }
149
150 pub fn submenu(mut self, children: Vec<ContextMenuItem>) -> Self {
152 self.children = children;
153 self
154 }
155
156 pub fn has_submenu(&self) -> bool {
157 !self.children.is_empty()
158 }
159
160 pub fn separator() -> Self {
161 Self {
162 label: String::new(),
163 shortcut: None,
164 icon: None,
165 enabled: false,
166 separator: true,
167 destructive: false,
168 children: Vec::new(),
169 }
170 }
171}
172
173pub struct ContextMenuSpec<Msg: Clone + 'static> {
178 pub anchor: (f32, f32),
179 pub viewport: (f32, f32),
180 pub header: Option<String>,
181 pub items: Vec<ContextMenuItem>,
182 pub active: usize,
184 pub on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync>,
186 pub on_dismiss: Msg,
188 pub palette: ContextMenuPalette,
189}
190
191pub struct ContextMenuExtras<Msg: Clone + 'static> {
195 pub open_sub: Option<usize>,
198 pub appear: f32,
200 pub on_pick_sub: Option<Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>>,
202 pub on_hover: Option<Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>>,
206}
207
208impl<Msg: Clone + 'static> Default for ContextMenuExtras<Msg> {
209 fn default() -> Self {
210 Self {
211 open_sub: None,
212 appear: 1.0,
213 on_pick_sub: None,
214 on_hover: None,
215 }
216 }
217}
218
219const PANEL_W: f32 = 252.0;
220const ITEM_H: f32 = 32.0;
222const SEP_H: f32 = 11.0;
223const HEADER_H: f32 = 26.0;
224const ICON_W: f32 = 24.0;
226const ITEM_PAD_LEFT: f32 = 10.0;
227const ITEM_PAD_RIGHT: f32 = 12.0;
228const PANEL_RADIUS: f32 = 10.0;
230const ITEM_RADIUS: f32 = 6.0;
232const PANEL_PAD: f32 = 6.0;
234const INDICATOR_W: f32 = 3.0;
236const APPEAR_SLIDE: f32 = 8.0;
238
239pub fn context_menu_view<Msg: Clone + 'static>(spec: ContextMenuSpec<Msg>) -> View<Msg> {
243 context_menu_view_ex(spec, ContextMenuExtras::default())
244}
245
246pub fn context_menu_view_ex<Msg: Clone + 'static>(
249 spec: ContextMenuSpec<Msg>,
250 extras: ContextMenuExtras<Msg>,
251) -> View<Msg> {
252 let ContextMenuSpec {
253 anchor,
254 viewport,
255 header,
256 items,
257 active,
258 on_pick,
259 on_dismiss,
260 palette,
261 } = spec;
262 let ContextMenuExtras {
263 open_sub,
264 appear,
265 on_pick_sub,
266 on_hover,
267 } = extras;
268
269 let appear = appear.clamp(0.0, 1.0);
270 let slide = (1.0 - appear) * APPEAR_SLIDE;
271
272 let (panel, panel_x, panel_y) = panel_view(
273 anchor,
274 viewport,
275 &header,
276 &items,
277 active,
278 slide,
279 &on_pick,
280 on_hover.as_ref(),
281 &palette,
282 );
283
284 let mut layers: Vec<View<Msg>> = vec![panel];
285
286 if let (Some(pidx), Some(on_pick_sub)) = (open_sub, on_pick_sub.as_ref()) {
288 if let Some(parent) = items.get(pidx).filter(|it| it.has_submenu()) {
289 let sub_anchor = submenu_anchor(panel_x, panel_y, &header, &items, pidx);
290 let flyout = submenu_view(
291 sub_anchor,
292 viewport,
293 pidx,
294 &parent.children,
295 slide,
296 on_pick_sub,
297 &palette,
298 );
299 layers.push(flyout);
300 }
301 }
302
303 View::new(Style {
305 size: Size {
306 width: percent(1.0_f32),
307 height: percent(1.0_f32),
308 },
309 ..Default::default()
310 })
311 .fill(palette.scrim)
312 .alpha(appear)
313 .on_click(on_dismiss)
314 .children(layers)
315}
316
317#[allow(clippy::too_many_arguments)]
319fn panel_view<Msg: Clone + 'static>(
320 anchor: (f32, f32),
321 viewport: (f32, f32),
322 header: &Option<String>,
323 items: &[ContextMenuItem],
324 active: usize,
325 slide: f32,
326 on_pick: &Arc<dyn Fn(usize) -> Msg + Send + Sync>,
327 on_hover: Option<&Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>>,
328 palette: &ContextMenuPalette,
329) -> (View<Msg>, f32, f32) {
330 let header_h = if header.is_some() { HEADER_H } else { 0.0 };
331 let items_h: f32 = items
332 .iter()
333 .map(|it| if it.separator { SEP_H } else { ITEM_H })
334 .sum();
335 let panel_h = 2.0 + 2.0 * PANEL_PAD + header_h + items_h;
337
338 let margin = 4.0;
339 let x = anchor
340 .0
341 .min((viewport.0 - PANEL_W - margin).max(margin))
342 .max(margin);
343 let y = anchor
344 .1
345 .min((viewport.1 - panel_h - margin).max(margin))
346 .max(margin);
347
348 let mut children: Vec<View<Msg>> = Vec::with_capacity(items.len() + 1);
349 if let Some(text) = header {
350 children.push(header_view(text.clone(), palette));
351 }
352 for (i, item) in items.iter().enumerate() {
353 children.push(item_view(
354 i,
355 None,
356 item,
357 i == active,
358 on_pick,
359 on_hover,
360 palette,
361 ));
362 }
363
364 let panel = panel_container(x, y + slide, panel_h, children, palette);
365 (panel, x, y)
366}
367
368#[allow(clippy::too_many_arguments)]
370fn submenu_view<Msg: Clone + 'static>(
371 anchor: (f32, f32),
372 viewport: (f32, f32),
373 parent_idx: usize,
374 children_items: &[ContextMenuItem],
375 slide: f32,
376 on_pick_sub: &Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>,
377 palette: &ContextMenuPalette,
378) -> View<Msg> {
379 let panel_h: f32 = children_items
380 .iter()
381 .map(|it| if it.separator { SEP_H } else { ITEM_H })
382 .sum::<f32>()
383 + 2.0
384 + 2.0 * PANEL_PAD;
385 let margin = 4.0;
386 let x = anchor
387 .0
388 .min((viewport.0 - PANEL_W - margin).max(margin))
389 .max(margin);
390 let y = anchor
391 .1
392 .min((viewport.1 - panel_h - margin).max(margin))
393 .max(margin);
394
395 let mut children: Vec<View<Msg>> = Vec::with_capacity(children_items.len());
396 for (j, item) in children_items.iter().enumerate() {
397 children.push(item_view(
398 j,
399 Some((parent_idx, on_pick_sub.clone())),
400 item,
401 false,
402 &dummy_pick(),
404 None,
405 palette,
406 ));
407 }
408 panel_container(x, y + slide, panel_h, children, palette)
409}
410
411fn panel_container<Msg: Clone + 'static>(
416 x: f32,
417 y: f32,
418 panel_h: f32,
419 children: Vec<View<Msg>>,
420 palette: &ContextMenuPalette,
421) -> View<Msg> {
422 View::new(Style {
423 position: Position::Absolute,
424 inset: Rect {
425 left: length(x),
426 top: length(y),
427 right: auto(),
428 bottom: auto(),
429 },
430 size: Size {
431 width: length(PANEL_W),
432 height: length(panel_h),
433 },
434 padding: Rect {
435 left: length(1.0_f32),
436 right: length(1.0_f32),
437 top: length(1.0_f32),
438 bottom: length(1.0_f32),
439 },
440 ..Default::default()
441 })
442 .fill(palette.border)
443 .radius(palette.radius as f64)
444 .children(vec![View::new(Style {
445 flex_direction: FlexDirection::Column,
446 flex_grow: 1.0,
447 size: Size {
448 width: percent(1.0_f32),
449 height: percent(1.0_f32),
450 },
451 padding: Rect {
452 left: length(PANEL_PAD),
453 right: length(PANEL_PAD),
454 top: length(PANEL_PAD),
455 bottom: length(PANEL_PAD),
456 },
457 ..Default::default()
458 })
459 .radius((palette.radius - 1.0) as f64)
460 .paint_with(panel_signature_painter(palette.panel))
461 .children(children)])
462}
463
464fn submenu_anchor(
466 panel_x: f32,
467 panel_y: f32,
468 header: &Option<String>,
469 items: &[ContextMenuItem],
470 parent_idx: usize,
471) -> (f32, f32) {
472 let mut off = if header.is_some() { HEADER_H } else { 0.0 };
473 off += 1.0 + PANEL_PAD; for it in items.iter().take(parent_idx) {
475 off += if it.separator { SEP_H } else { ITEM_H };
476 }
477 (panel_x + PANEL_W - PANEL_PAD, panel_y + off)
479}
480
481fn header_view<Msg: Clone + 'static>(text: String, palette: &ContextMenuPalette) -> View<Msg> {
482 View::new(Style {
483 size: Size {
484 width: percent(1.0_f32),
485 height: length(HEADER_H),
486 },
487 padding: Rect {
488 left: length(ITEM_PAD_LEFT + INDICATOR_W + ICON_W + 4.0),
489 right: length(ITEM_PAD_RIGHT),
490 top: length(2.0_f32),
491 bottom: length(0.0_f32),
492 },
493 align_items: Some(AlignItems::Center),
494 ..Default::default()
495 })
496 .text_aligned(text.to_uppercase(), 9.5, palette.fg_header, Alignment::Start)
497}
498
499#[allow(clippy::too_many_arguments)]
503fn item_view<Msg: Clone + 'static>(
504 idx: usize,
505 parent: Option<(usize, Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>)>,
506 item: &ContextMenuItem,
507 is_active: bool,
508 on_pick: &Arc<dyn Fn(usize) -> Msg + Send + Sync>,
509 on_hover: Option<&Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>>,
510 palette: &ContextMenuPalette,
511) -> View<Msg> {
512 if item.separator {
513 return separator_view(palette);
514 }
515
516 let (fg, fg_dim): (Color, Color) = if !item.enabled {
518 (palette.fg_disabled, palette.fg_disabled)
519 } else if item.destructive {
520 (palette.fg_destructive, palette.fg_shortcut)
521 } else if is_active {
522 (palette.fg_active, palette.fg_active)
523 } else {
524 (palette.fg_text, palette.fg_shortcut)
525 };
526 let icon_fg = if !item.enabled {
529 palette.fg_disabled
530 } else if is_active {
531 palette.accent
532 } else {
533 palette.fg_icon
534 };
535
536 let indicator = View::new(Style {
539 size: Size {
540 width: length(INDICATOR_W),
541 height: percent(0.55_f32),
542 },
543 flex_shrink: 0.0,
544 ..Default::default()
545 });
546 let indicator = if is_active && item.enabled {
547 indicator.fill(palette.accent).radius(2.0)
548 } else {
549 indicator
550 };
551
552 let icon_cell = View::new(Style {
554 size: Size {
555 width: length(ICON_W),
556 height: auto(),
557 },
558 flex_shrink: 0.0,
559 align_items: Some(AlignItems::Center),
560 justify_content: Some(JustifyContent::Center),
561 ..Default::default()
562 })
563 .text_aligned(item.icon.clone().unwrap_or_default(), 13.0, icon_fg, Alignment::Center);
564
565 let label = View::new(Style {
567 size: Size {
568 width: auto(),
569 height: auto(),
570 },
571 flex_grow: 1.0,
572 ..Default::default()
573 })
574 .text_aligned(item.label.clone(), 12.5, fg, Alignment::Start);
575
576 let trailing_text = if item.has_submenu() {
578 Some(("\u{203A}".to_string(), fg)) } else {
580 item.shortcut.clone().map(|s| (s, fg_dim))
581 };
582 let mut row_children: Vec<View<Msg>> = vec![indicator, icon_cell, label];
583 if let Some((txt, color)) = trailing_text {
584 row_children.push(
585 View::new(Style {
586 size: Size {
587 width: length(64.0_f32),
588 height: auto(),
589 },
590 flex_shrink: 0.0,
591 ..Default::default()
592 })
593 .text_aligned(txt, 11.0, color, Alignment::End),
594 );
595 }
596
597 let mut row = View::new(Style {
598 size: Size {
599 width: percent(1.0_f32),
600 height: length(ITEM_H),
601 },
602 flex_direction: FlexDirection::Row,
603 padding: Rect {
604 left: length(ITEM_PAD_LEFT),
605 right: length(ITEM_PAD_RIGHT),
606 top: length(0.0_f32),
607 bottom: length(0.0_f32),
608 },
609 gap: Size {
610 width: length(2.0_f32),
611 height: length(0.0_f32),
612 },
613 align_items: Some(AlignItems::Center),
614 ..Default::default()
615 })
616 .radius(ITEM_RADIUS as f64)
617 .role(llimphi_ui::Role::MenuItem)
621 .aria_label(item.label.clone())
622 .aria_disabled(!item.enabled)
623 .children(row_children);
624
625 if is_active && item.enabled {
628 row = row.fill(palette.bg_active);
629 }
630
631 if item.enabled {
632 row = row.hover_fill(palette.bg_hover);
633 match &parent {
634 Some((pidx, cb)) => {
635 let cb = cb.clone();
636 let pidx = *pidx;
637 row = row.on_click_at(move |_, _, _, _| Some(cb(pidx, idx)));
638 }
639 None => {
640 let on_pick = on_pick.clone();
641 row = row.on_click_at(move |_, _, _, _| Some(on_pick(idx)));
642 if let Some(on_hover) = on_hover {
644 let on_hover = on_hover.clone();
645 let target = if item.has_submenu() { Some(idx) } else { None };
646 row = row.on_pointer_enter(on_hover(target));
647 }
648 }
649 }
650 }
651 row
652}
653
654fn separator_view<Msg: Clone + 'static>(palette: &ContextMenuPalette) -> View<Msg> {
655 View::new(Style {
656 size: Size {
657 width: percent(1.0_f32),
658 height: length(SEP_H),
659 },
660 flex_direction: FlexDirection::Column,
661 justify_content: Some(JustifyContent::Center),
662 align_items: Some(AlignItems::Center),
663 padding: Rect {
664 left: length(ITEM_PAD_LEFT),
665 right: length(ITEM_PAD_RIGHT),
666 top: length(0.0_f32),
667 bottom: length(0.0_f32),
668 },
669 ..Default::default()
670 })
671 .children(vec![View::new(Style {
672 size: Size {
673 width: percent(1.0_f32),
674 height: length(1.0_f32),
675 },
676 ..Default::default()
677 })
678 .fill(palette.separator)])
679}
680
681fn dummy_pick<Msg: Clone + 'static>() -> Arc<dyn Fn(usize) -> Msg + Send + Sync> {
684 Arc::new(|_| unreachable!("submenu item usa on_pick_sub, no on_pick"))
685}
686
687pub fn step_active(items: &[ContextMenuItem], current: usize, direction: i32) -> usize {
691 if items.is_empty() {
692 return usize::MAX;
693 }
694 let n = items.len() as i32;
695 let start = if current == usize::MAX {
696 if direction >= 0 {
697 -1
698 } else {
699 n
700 }
701 } else {
702 current as i32
703 };
704 let mut i = start;
705 for _ in 0..n {
706 i += direction;
707 if i < 0 {
708 i = n - 1;
709 } else if i >= n {
710 i = 0;
711 }
712 let item = &items[i as usize];
713 if !item.separator && item.enabled {
714 return i as usize;
715 }
716 }
717 usize::MAX
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 fn it(label: &str) -> ContextMenuItem {
725 ContextMenuItem::action(label)
726 }
727
728 #[test]
729 fn step_active_skips_separators() {
730 let items = vec![it("A"), ContextMenuItem::separator(), it("B"), it("C")];
731 assert_eq!(step_active(&items, 0, 1), 2);
732 assert_eq!(step_active(&items, 2, -1), 0);
733 }
734
735 #[test]
736 fn step_active_skips_disabled() {
737 let items = vec![it("A"), it("B").disabled(), it("C")];
738 assert_eq!(step_active(&items, 0, 1), 2);
739 assert_eq!(step_active(&items, 2, -1), 0);
740 }
741
742 #[test]
743 fn step_active_wraps_around() {
744 let items = vec![it("A"), it("B"), it("C")];
745 assert_eq!(step_active(&items, 2, 1), 0);
746 assert_eq!(step_active(&items, 0, -1), 2);
747 }
748
749 #[test]
750 fn submenu_y_icono_se_setean() {
751 let item = it("Tema")
752 .icon("◐")
753 .submenu(vec![it("Oscuro"), it("Claro")]);
754 assert!(item.has_submenu());
755 assert_eq!(item.children.len(), 2);
756 assert_eq!(item.icon.as_deref(), Some("◐"));
757 }
758
759 #[test]
760 fn extras_default_es_menu_clasico() {
761 let extras: ContextMenuExtras<u8> = ContextMenuExtras::default();
762 assert_eq!(extras.appear, 1.0);
763 assert!(extras.open_sub.is_none());
764 assert!(extras.on_hover.is_none());
765 assert!(extras.on_pick_sub.is_none());
766 }
767}