#![forbid(unsafe_code)]
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Position, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
#[derive(Debug, Clone, Copy)]
pub struct ContextMenuPalette {
pub bg_panel: Color,
pub bg_hover: Color,
pub bg_active: Color,
pub fg_text: Color,
pub fg_active: Color,
pub fg_shortcut: Color,
pub fg_disabled: Color,
pub fg_destructive: Color,
pub fg_header: Color,
pub fg_icon: Color,
pub accent: Color,
pub border: Color,
pub separator: Color,
pub scrim: Color,
pub radius: f64,
pub panel: PanelStyle,
}
impl ContextMenuPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
let mut panel = PanelStyle::neutral(t);
panel.bg_base = t.bg_panel;
panel.radius = PANEL_RADIUS as f64;
Self {
bg_panel: t.bg_panel,
bg_hover: t.bg_row_hover,
bg_active: t.bg_selected,
fg_text: t.fg_text,
fg_active: t.fg_text,
fg_shortcut: t.fg_muted,
fg_disabled: t.fg_muted,
fg_destructive: t.fg_destructive,
fg_header: t.fg_muted,
fg_icon: t.fg_muted,
accent: t.accent,
border: t.border,
separator: t.border,
scrim: Color::from_rgba8(0, 0, 0, 64),
radius: PANEL_RADIUS as f64,
panel,
}
}
}
#[derive(Debug, Clone)]
pub struct ContextMenuItem {
pub label: String,
pub shortcut: Option<String>,
pub icon: Option<String>,
pub enabled: bool,
pub separator: bool,
pub destructive: bool,
pub children: Vec<ContextMenuItem>,
}
impl ContextMenuItem {
pub fn action(label: impl Into<String>) -> Self {
Self {
label: label.into(),
shortcut: None,
icon: None,
enabled: true,
separator: false,
destructive: false,
children: Vec::new(),
}
}
pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
pub fn icon(mut self, glyph: impl Into<String>) -> Self {
self.icon = Some(glyph.into());
self
}
pub fn disabled(mut self) -> Self {
self.enabled = false;
self
}
pub fn destructive(mut self) -> Self {
self.destructive = true;
self
}
pub fn submenu(mut self, children: Vec<ContextMenuItem>) -> Self {
self.children = children;
self
}
pub fn has_submenu(&self) -> bool {
!self.children.is_empty()
}
pub fn separator() -> Self {
Self {
label: String::new(),
shortcut: None,
icon: None,
enabled: false,
separator: true,
destructive: false,
children: Vec::new(),
}
}
}
pub struct ContextMenuSpec<Msg: Clone + 'static> {
pub anchor: (f32, f32),
pub viewport: (f32, f32),
pub header: Option<String>,
pub items: Vec<ContextMenuItem>,
pub active: usize,
pub on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync>,
pub on_dismiss: Msg,
pub palette: ContextMenuPalette,
}
pub struct ContextMenuExtras<Msg: Clone + 'static> {
pub open_sub: Option<usize>,
pub appear: f32,
pub on_pick_sub: Option<Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>>,
pub on_hover: Option<Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>>,
}
impl<Msg: Clone + 'static> Default for ContextMenuExtras<Msg> {
fn default() -> Self {
Self {
open_sub: None,
appear: 1.0,
on_pick_sub: None,
on_hover: None,
}
}
}
const PANEL_W: f32 = 252.0;
const ITEM_H: f32 = 32.0;
const SEP_H: f32 = 11.0;
const HEADER_H: f32 = 26.0;
const ICON_W: f32 = 24.0;
const ITEM_PAD_LEFT: f32 = 10.0;
const ITEM_PAD_RIGHT: f32 = 12.0;
const PANEL_RADIUS: f32 = 10.0;
const ITEM_RADIUS: f32 = 6.0;
const PANEL_PAD: f32 = 6.0;
const INDICATOR_W: f32 = 3.0;
const APPEAR_SLIDE: f32 = 8.0;
pub fn context_menu_view<Msg: Clone + 'static>(spec: ContextMenuSpec<Msg>) -> View<Msg> {
context_menu_view_ex(spec, ContextMenuExtras::default())
}
pub fn context_menu_view_ex<Msg: Clone + 'static>(
spec: ContextMenuSpec<Msg>,
extras: ContextMenuExtras<Msg>,
) -> View<Msg> {
let ContextMenuSpec {
anchor,
viewport,
header,
items,
active,
on_pick,
on_dismiss,
palette,
} = spec;
let ContextMenuExtras {
open_sub,
appear,
on_pick_sub,
on_hover,
} = extras;
let appear = appear.clamp(0.0, 1.0);
let slide = (1.0 - appear) * APPEAR_SLIDE;
let (panel, panel_x, panel_y) = panel_view(
anchor,
viewport,
&header,
&items,
active,
slide,
&on_pick,
on_hover.as_ref(),
&palette,
);
let mut layers: Vec<View<Msg>> = vec![panel];
if let (Some(pidx), Some(on_pick_sub)) = (open_sub, on_pick_sub.as_ref()) {
if let Some(parent) = items.get(pidx).filter(|it| it.has_submenu()) {
let sub_anchor = submenu_anchor(panel_x, panel_y, &header, &items, pidx);
let flyout = submenu_view(
sub_anchor,
viewport,
pidx,
&parent.children,
slide,
on_pick_sub,
&palette,
);
layers.push(flyout);
}
}
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(palette.scrim)
.alpha(appear)
.on_click(on_dismiss)
.children(layers)
}
#[allow(clippy::too_many_arguments)]
fn panel_view<Msg: Clone + 'static>(
anchor: (f32, f32),
viewport: (f32, f32),
header: &Option<String>,
items: &[ContextMenuItem],
active: usize,
slide: f32,
on_pick: &Arc<dyn Fn(usize) -> Msg + Send + Sync>,
on_hover: Option<&Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>>,
palette: &ContextMenuPalette,
) -> (View<Msg>, f32, f32) {
let header_h = if header.is_some() { HEADER_H } else { 0.0 };
let items_h: f32 = items
.iter()
.map(|it| if it.separator { SEP_H } else { ITEM_H })
.sum();
let panel_h = 2.0 + 2.0 * PANEL_PAD + header_h + items_h;
let margin = 4.0;
let x = anchor
.0
.min((viewport.0 - PANEL_W - margin).max(margin))
.max(margin);
let y = anchor
.1
.min((viewport.1 - panel_h - margin).max(margin))
.max(margin);
let mut children: Vec<View<Msg>> = Vec::with_capacity(items.len() + 1);
if let Some(text) = header {
children.push(header_view(text.clone(), palette));
}
for (i, item) in items.iter().enumerate() {
children.push(item_view(
i,
None,
item,
i == active,
on_pick,
on_hover,
palette,
));
}
let panel = panel_container(x, y + slide, panel_h, children, palette);
(panel, x, y)
}
#[allow(clippy::too_many_arguments)]
fn submenu_view<Msg: Clone + 'static>(
anchor: (f32, f32),
viewport: (f32, f32),
parent_idx: usize,
children_items: &[ContextMenuItem],
slide: f32,
on_pick_sub: &Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>,
palette: &ContextMenuPalette,
) -> View<Msg> {
let panel_h: f32 = children_items
.iter()
.map(|it| if it.separator { SEP_H } else { ITEM_H })
.sum::<f32>()
+ 2.0
+ 2.0 * PANEL_PAD;
let margin = 4.0;
let x = anchor
.0
.min((viewport.0 - PANEL_W - margin).max(margin))
.max(margin);
let y = anchor
.1
.min((viewport.1 - panel_h - margin).max(margin))
.max(margin);
let mut children: Vec<View<Msg>> = Vec::with_capacity(children_items.len());
for (j, item) in children_items.iter().enumerate() {
children.push(item_view(
j,
Some((parent_idx, on_pick_sub.clone())),
item,
false,
&dummy_pick(),
None,
palette,
));
}
panel_container(x, y + slide, panel_h, children, palette)
}
fn panel_container<Msg: Clone + 'static>(
x: f32,
y: f32,
panel_h: f32,
children: Vec<View<Msg>>,
palette: &ContextMenuPalette,
) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(x),
top: length(y),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(PANEL_W),
height: length(panel_h),
},
padding: Rect {
left: length(1.0_f32),
right: length(1.0_f32),
top: length(1.0_f32),
bottom: length(1.0_f32),
},
..Default::default()
})
.fill(palette.border)
.radius(palette.radius as f64)
.children(vec![View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(PANEL_PAD),
right: length(PANEL_PAD),
top: length(PANEL_PAD),
bottom: length(PANEL_PAD),
},
..Default::default()
})
.radius((palette.radius - 1.0) as f64)
.paint_with(panel_signature_painter(palette.panel))
.children(children)])
}
fn submenu_anchor(
panel_x: f32,
panel_y: f32,
header: &Option<String>,
items: &[ContextMenuItem],
parent_idx: usize,
) -> (f32, f32) {
let mut off = if header.is_some() { HEADER_H } else { 0.0 };
off += 1.0 + PANEL_PAD; for it in items.iter().take(parent_idx) {
off += if it.separator { SEP_H } else { ITEM_H };
}
(panel_x + PANEL_W - PANEL_PAD, panel_y + off)
}
fn header_view<Msg: Clone + 'static>(text: String, palette: &ContextMenuPalette) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(HEADER_H),
},
padding: Rect {
left: length(ITEM_PAD_LEFT + INDICATOR_W + ICON_W + 4.0),
right: length(ITEM_PAD_RIGHT),
top: length(2.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(text.to_uppercase(), 9.5, palette.fg_header, Alignment::Start)
}
#[allow(clippy::too_many_arguments)]
fn item_view<Msg: Clone + 'static>(
idx: usize,
parent: Option<(usize, Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>)>,
item: &ContextMenuItem,
is_active: bool,
on_pick: &Arc<dyn Fn(usize) -> Msg + Send + Sync>,
on_hover: Option<&Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>>,
palette: &ContextMenuPalette,
) -> View<Msg> {
if item.separator {
return separator_view(palette);
}
let (fg, fg_dim): (Color, Color) = if !item.enabled {
(palette.fg_disabled, palette.fg_disabled)
} else if item.destructive {
(palette.fg_destructive, palette.fg_shortcut)
} else if is_active {
(palette.fg_active, palette.fg_active)
} else {
(palette.fg_text, palette.fg_shortcut)
};
let icon_fg = if !item.enabled {
palette.fg_disabled
} else if is_active {
palette.accent
} else {
palette.fg_icon
};
let indicator = View::new(Style {
size: Size {
width: length(INDICATOR_W),
height: percent(0.55_f32),
},
flex_shrink: 0.0,
..Default::default()
});
let indicator = if is_active && item.enabled {
indicator.fill(palette.accent).radius(2.0)
} else {
indicator
};
let icon_cell = View::new(Style {
size: Size {
width: length(ICON_W),
height: auto(),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text_aligned(item.icon.clone().unwrap_or_default(), 13.0, icon_fg, Alignment::Center);
let label = View::new(Style {
size: Size {
width: auto(),
height: auto(),
},
flex_grow: 1.0,
..Default::default()
})
.text_aligned(item.label.clone(), 12.5, fg, Alignment::Start);
let trailing_text = if item.has_submenu() {
Some(("\u{203A}".to_string(), fg)) } else {
item.shortcut.clone().map(|s| (s, fg_dim))
};
let mut row_children: Vec<View<Msg>> = vec![indicator, icon_cell, label];
if let Some((txt, color)) = trailing_text {
row_children.push(
View::new(Style {
size: Size {
width: length(64.0_f32),
height: auto(),
},
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(txt, 11.0, color, Alignment::End),
);
}
let mut row = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(ITEM_H),
},
flex_direction: FlexDirection::Row,
padding: Rect {
left: length(ITEM_PAD_LEFT),
right: length(ITEM_PAD_RIGHT),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
gap: Size {
width: length(2.0_f32),
height: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.radius(ITEM_RADIUS as f64)
.role(llimphi_ui::Role::MenuItem)
.aria_label(item.label.clone())
.aria_disabled(!item.enabled)
.children(row_children);
if is_active && item.enabled {
row = row.fill(palette.bg_active);
}
if item.enabled {
row = row.hover_fill(palette.bg_hover);
match &parent {
Some((pidx, cb)) => {
let cb = cb.clone();
let pidx = *pidx;
row = row.on_click_at(move |_, _, _, _| Some(cb(pidx, idx)));
}
None => {
let on_pick = on_pick.clone();
row = row.on_click_at(move |_, _, _, _| Some(on_pick(idx)));
if let Some(on_hover) = on_hover {
let on_hover = on_hover.clone();
let target = if item.has_submenu() { Some(idx) } else { None };
row = row.on_pointer_enter(on_hover(target));
}
}
}
}
row
}
fn separator_view<Msg: Clone + 'static>(palette: &ContextMenuPalette) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(SEP_H),
},
flex_direction: FlexDirection::Column,
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(ITEM_PAD_LEFT),
right: length(ITEM_PAD_RIGHT),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.children(vec![View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(1.0_f32),
},
..Default::default()
})
.fill(palette.separator)])
}
fn dummy_pick<Msg: Clone + 'static>() -> Arc<dyn Fn(usize) -> Msg + Send + Sync> {
Arc::new(|_| unreachable!("submenu item usa on_pick_sub, no on_pick"))
}
pub fn step_active(items: &[ContextMenuItem], current: usize, direction: i32) -> usize {
if items.is_empty() {
return usize::MAX;
}
let n = items.len() as i32;
let start = if current == usize::MAX {
if direction >= 0 {
-1
} else {
n
}
} else {
current as i32
};
let mut i = start;
for _ in 0..n {
i += direction;
if i < 0 {
i = n - 1;
} else if i >= n {
i = 0;
}
let item = &items[i as usize];
if !item.separator && item.enabled {
return i as usize;
}
}
usize::MAX
}
#[cfg(test)]
mod tests {
use super::*;
fn it(label: &str) -> ContextMenuItem {
ContextMenuItem::action(label)
}
#[test]
fn step_active_skips_separators() {
let items = vec![it("A"), ContextMenuItem::separator(), it("B"), it("C")];
assert_eq!(step_active(&items, 0, 1), 2);
assert_eq!(step_active(&items, 2, -1), 0);
}
#[test]
fn step_active_skips_disabled() {
let items = vec![it("A"), it("B").disabled(), it("C")];
assert_eq!(step_active(&items, 0, 1), 2);
assert_eq!(step_active(&items, 2, -1), 0);
}
#[test]
fn step_active_wraps_around() {
let items = vec![it("A"), it("B"), it("C")];
assert_eq!(step_active(&items, 2, 1), 0);
assert_eq!(step_active(&items, 0, -1), 2);
}
#[test]
fn submenu_y_icono_se_setean() {
let item = it("Tema")
.icon("◐")
.submenu(vec![it("Oscuro"), it("Claro")]);
assert!(item.has_submenu());
assert_eq!(item.children.len(), 2);
assert_eq!(item.icon.as_deref(), Some("◐"));
}
#[test]
fn extras_default_es_menu_clasico() {
let extras: ContextMenuExtras<u8> = ContextMenuExtras::default();
assert_eq!(extras.appear, 1.0);
assert!(extras.open_sub.is_none());
assert!(extras.on_hover.is_none());
assert!(extras.on_pick_sub.is_none());
}
}