feat: llimphi standalone — framework UI soberano extraído del monorepo

Motor gráfico Llimphi como workspace independiente: bucle Elm
(input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley.
Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets
+ módulos, sin dependencias al resto del monorepo. cargo check --workspace
pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 04:23:42 +00:00
commit e65e9cc623
286 changed files with 46136 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-widget-context-menu"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-context-menu — menú contextual gioser: panel negro, barra accent vertical de 3px a la izquierda, sin esquinas redondeadas ni sombras, header en uppercase tiny. Se monta sobre App::view_overlay con un scrim full-screen que dismissa al click-fuera."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-panel = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-context-menu
> Menú contextual para [llimphi](../../README.md).
Look distintivo: barra accent vertical 3px, hard edges, header tiny. API: `View::on_right_click[_at]` + `App::view_overlay`.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-context-menu
> Context menu for [llimphi](../../README.md).
Distinctive look: 3px vertical accent bar, hard edges, tiny header. API: `View::on_right_click[_at]` + `App::view_overlay`.
+761
View File
@@ -0,0 +1,761 @@
//! `llimphi-widget-context-menu` — menú contextual con look gioser.
//!
//! Distintivo y minimalista:
//!
//! ```text
//! ┃ B5 ← header (uppercase tiny)
//! ┃ ✂ Cortar Ctrl+X
//! ┃ ⧉ Copiar Ctrl+C ← gutter de íconos + barra accent (3px)
//! ┃ ⎘ Pegar Ctrl+V
//! ┃ ─────────────────────
//! ┃ ◐ Tema ▸ ← submenú (flyout a la derecha)
//! ```
//!
//! Cada fila: barra accent vertical (firma) · gutter de ícono · label
//! (centrado vertical) · atajo o chevron de submenú. Sin radios, sin
//! sombras: color sólido + tipografía + la barra accent.
//!
//! Se monta como `View<Msg>` que se devuelve desde
//! [`llimphi_ui::App::view_overlay`]. Internamente arma:
//! 1. Un **scrim** full-screen con `on_click = on_dismiss` que cierra
//! el menú al click-fuera.
//! 2. Un **panel** absoluto (clampeado al viewport).
//! 3. Si hay un submenú abierto ([`ContextMenuSpec::open_sub`]), un
//! segundo panel-flyout a la derecha del item padre.
//!
//! Animación: [`ContextMenuSpec::appear`] (0..1) controla un fade + un
//! leve desplazamiento vertical de entrada. La app que quiera animarlo
//! guarda un `Tween` y lo va subiendo; pasar `1.0` lo muestra fijo.
#![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};
/// Paleta del menú — estilo "webpage" elegante derivado del theme:
/// panel redondeado con borde hairline, filas como píldoras con hover
/// suave (`bg_hover`) y resaltado de teclado (`bg_active`, más un
/// indicador accent a la izquierda). Defaults dark; override por la app.
#[derive(Debug, Clone, Copy)]
pub struct ContextMenuPalette {
pub bg_panel: Color,
/// Fila bajo el cursor (hover) — tinte suave.
pub bg_hover: Color,
/// Fila activa por teclado (flechas) — algo más marcado que el hover.
pub bg_active: Color,
pub fg_text: Color,
/// Texto de la fila activa/hover (legible sobre el tinte suave).
pub fg_active: Color,
pub fg_shortcut: Color,
pub fg_disabled: Color,
pub fg_destructive: Color,
pub fg_header: Color,
/// Ícono en gutter (estado normal) — algo más apagado que el texto.
pub fg_icon: Color,
pub accent: Color,
pub border: Color,
pub separator: Color,
pub scrim: Color,
/// Radio de las esquinas del panel.
pub radius: f64,
pub panel: PanelStyle,
}
impl ContextMenuPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
// El panel se eleva sobre el fondo: usa `bg_panel` (no `bg_app`)
// con su gradiente sutil + esquinas redondeadas.
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,
}
}
}
/// Un item del menú. `separator = true` ignora el resto y pinta una
/// línea. `children` no vacío → es un submenú (muestra chevron ▸ y, si
/// está abierto, despliega un flyout). `icon` es un glifo opcional que
/// se pinta en el gutter izquierdo.
#[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,
/// Items del submenú. Vacío = acción simple.
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
}
/// Glifo del gutter izquierdo (unicode; no acopla a `llimphi-icons`).
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
}
/// Convierte el item en submenú con estos hijos.
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(),
}
}
}
/// Especificación del menú. Mantiene los 8 campos clásicos para no
/// romper los call-sites por literal; las capacidades nuevas (submenús,
/// animación, hover) viajan aparte en [`ContextMenuExtras`] vía
/// [`context_menu_view_ex`].
pub struct ContextMenuSpec<Msg: Clone + 'static> {
pub anchor: (f32, f32),
pub viewport: (f32, f32),
pub header: Option<String>,
pub items: Vec<ContextMenuItem>,
/// Índice resaltado por keyboard. `usize::MAX` = ninguno.
pub active: usize,
/// Click en un item de nivel raíz (índice).
pub on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync>,
/// Msg al click-fuera (scrim) o Esc.
pub on_dismiss: Msg,
pub palette: ContextMenuPalette,
}
/// Capacidades extra opcionales para [`context_menu_view_ex`]: submenús
/// (flyout), animación de aparición y hover. Su `Default` reproduce el
/// menú clásico (sin animación ni submenús).
pub struct ContextMenuExtras<Msg: Clone + 'static> {
/// Índice del item-submenú desplegado (flyout). La app lo guarda y lo
/// actualiza vía `on_hover`.
pub open_sub: Option<usize>,
/// Progreso de aparición 0..1 (fade + leve slide). `1.0` = fijo.
pub appear: f32,
/// Click en un item de submenú: `(parent_idx, child_idx)`.
pub on_pick_sub: Option<Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>>,
/// Hover sobre un item raíz: `Some(idx)` si es submenú (abrir flyout),
/// `None` si es item normal (cerrar). La app guarda el resultado en
/// `open_sub`.
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;
/// Altura de cada item (no-separator).
const ITEM_H: f32 = 32.0;
const SEP_H: f32 = 11.0;
const HEADER_H: f32 = 26.0;
/// Gutter del ícono a la izquierda del label.
const ICON_W: f32 = 24.0;
const ITEM_PAD_LEFT: f32 = 10.0;
const ITEM_PAD_RIGHT: f32 = 12.0;
/// Radio de las esquinas del panel (estilo webpage).
const PANEL_RADIUS: f32 = 10.0;
/// Radio de la píldora de hover/activo de cada fila.
const ITEM_RADIUS: f32 = 6.0;
/// Padding interno del panel (entre el borde y la columna de píldoras).
const PANEL_PAD: f32 = 6.0;
/// Ancho del indicador accent vertical de la fila activa.
const INDICATOR_W: f32 = 3.0;
/// Desplazamiento vertical de entrada (px) cuando `appear` = 0.
const APPEAR_SLIDE: f32 = 8.0;
/// Compone el menú clásico (sin submenús ni animación) como `View<Msg>`
/// para `App::view_overlay`. Íconos, centrado vertical y separadores ya
/// vienen incluidos.
pub fn context_menu_view<Msg: Clone + 'static>(spec: ContextMenuSpec<Msg>) -> View<Msg> {
context_menu_view_ex(spec, ContextMenuExtras::default())
}
/// Como [`context_menu_view`] pero con [`ContextMenuExtras`]: submenús
/// (flyout en hover), animación de aparición y hover.
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];
// Flyout del submenú abierto (sólo si la app provee `on_pick_sub`).
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);
}
}
// Scrim full-screen: cualquier click "fuera" dismissa.
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)
}
/// Arma el panel raíz y devuelve `(view, x, y)` ya clampeados.
#[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();
// borde (1+1) + padding interno (PANEL_PAD ×2) + header + items.
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)
}
/// Flyout del submenú: mismo look, posicionado a la derecha del padre.
#[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,
// on_pick raíz no se usa cuando hay parent; pasamos un dummy.
&dummy_pick(),
None,
palette,
));
}
panel_container(x, y + slide, panel_h, children, palette)
}
/// El contenedor visual: panel redondeado con borde hairline (un nodo
/// exterior del color del borde + uno interior con el gradiente del
/// PanelStyle) y padding interno para que las píldoras de cada fila
/// queden inset — el look de menú de webpage.
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)])
}
/// Ancla del flyout: a la derecha del panel padre, alineado al item.
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; // borde + padding interno del contenedor
for it in items.iter().take(parent_idx) {
off += if it.separator { SEP_H } else { ITEM_H };
}
// pequeño solape para que el flyout se lea continuo con el padre.
(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)
}
/// Pinta una fila. Si `parent` es `Some((pidx, cb))`, es un item de
/// submenú y clickea vía `cb(pidx, idx)`; si es `None`, es raíz y usa
/// `on_pick(idx)` + (si corresponde) `on_hover` para abrir su flyout.
#[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);
}
// Color del texto y del atajo según estado.
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)
};
// Ícono: accent cuando la fila está activa (cue del menú), si no
// apagado.
let icon_fg = if !item.enabled {
palette.fg_disabled
} else if is_active {
palette.accent
} else {
palette.fg_icon
};
// Indicador accent vertical a la izquierda — visible sólo en la fila
// activa; reserva su ancho siempre para que el texto no salte.
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
};
// Gutter de ícono — auto height para que el row lo centre vertical.
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);
// Label — auto height (lo centra el align_items Center del row).
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);
// Cola: chevron de submenú o atajo de teclado.
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)
.children(row_children);
// Fondo: píldora suave en activo (teclado). El hover lo aporta
// `hover_fill` (tinte aún más suave) para no competir con el activo.
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)));
// Hover abre/cierra el flyout según sea submenú o no.
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)])
}
/// `on_pick` dummy para los items de submenú (que usan `on_pick_sub`).
/// Nunca se invoca: `item_view` con `parent=Some` ignora `on_pick`.
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"))
}
/// Navegación por teclado: dado el activo + dirección (`+1`/`-1`), el
/// siguiente índice válido (saltea separators y disabled). `usize::MAX`
/// si no hay elegibles.
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());
}
}