//! Demo de `llimphi-widget-context-menu`: paridad con un menú contextual //! nativo / Material, en vivo. //! //! Corre con: `cargo run -p llimphi-widget-context-menu --example menu_demo --release`. //! //! Qué mirar / probar: //! - **Teclado** (el foco lo maneja este host con los helpers puros del widget): //! - `↑`/`↓` navegan (saltean separadores y deshabilitados) — `step_active`. //! - `Inicio`/`Fin` van al primero/último — `edge_active`. //! - **Teclea una letra** para saltar por mnemónico (repetí para rotar) — //! `type_ahead`. Ej: `t` salta a «Tema», `e` a «Exportar»/«Eliminar». //! - `→` entra al submenú resaltado, `←` vuelve, `Enter` activa, `Esc` cierra. //! - **Items chequeables**: «Ajustar texto» / «Números de línea» muestran ✓ y //! alternan al activarlos (accent en el gutter). //! - **Submenús con flip**: «Tema» y «Exportar» abren un flyout. El menú arranca //! pegado a la izquierda → abre a la derecha; **muévelo con `Tab`** para //! anclarlo al borde derecho y ver el flyout **flipear a la izquierda**. //! - **Elevación**: el panel flota con sombra suave + borde hairline + esquinas //! redondeadas (look webpage). Aparición animada (fade + leve slide). //! - Separador, item deshabilitado («Pegar»), item destructivo («Eliminar»). use std::sync::Arc; use llimphi_motion::{animate, motion, Tween}; use llimphi_ui::llimphi_layout::taffy::{ prelude::{length, percent, FlexDirection, Size, Style}, AlignItems, Rect, }; use llimphi_ui::llimphi_raster::peniko::Color; use llimphi_ui::llimphi_text::Alignment; use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View}; use llimphi_widget_context_menu::{ context_menu_view_ex, edge_active, step_active, type_ahead, ContextMenuExtras, ContextMenuItem, ContextMenuPalette, ContextMenuSpec, }; #[derive(Clone, Debug)] enum Msg { Pick(usize), PickSub(usize, usize), Hover(Option), Key(KeyEvent), Resize(f32, f32), Dismiss, Tick, } struct Model { items: Vec, active: usize, open_sub: Option, sub_active: usize, appear: Tween, viewport: (f32, f32), /// Posiciones de ancla que rotan con `Tab` (para probar el flip). anchors: Vec<(f32, f32)>, anchor_idx: usize, log: String, } /// Construye el menú raíz. `wrap`/`line_nums` son el estado de los checkables. fn build_items(wrap: bool, line_nums: bool) -> Vec { vec![ ContextMenuItem::action("Cortar") .icon("✂") .with_shortcut("Ctrl+X"), ContextMenuItem::action("Copiar") .icon("⧉") .with_shortcut("Ctrl+C"), ContextMenuItem::action("Pegar") .icon("⎘") .with_shortcut("Ctrl+V") .disabled(), ContextMenuItem::separator(), ContextMenuItem::action("Ajustar texto").checked(wrap), ContextMenuItem::action("Números de línea").checked(line_nums), ContextMenuItem::separator(), ContextMenuItem::action("Tema").icon("◐").submenu(vec![ ContextMenuItem::action("Oscuro").checked(true), ContextMenuItem::action("Claro").checked(false), ContextMenuItem::action("Sistema").checked(false), ]), ContextMenuItem::action("Exportar").icon("⇪").submenu(vec![ ContextMenuItem::action("PDF"), ContextMenuItem::action("PNG"), ContextMenuItem::action("SVG").disabled(), ]), ContextMenuItem::separator(), ContextMenuItem::action("Eliminar") .icon("🗑") .with_shortcut("Supr") .destructive(), ] } struct Demo; impl Demo { fn reappear(m: &mut Model, handle: &Handle) { m.appear = Tween::new(0.0, 1.0, motion::NORMAL, motion::ease_out_cubic); animate(handle, motion::NORMAL, || Msg::Tick); } } impl App for Demo { type Model = Model; type Msg = Msg; fn title() -> &'static str { "llimphi · menú contextual (paridad nativa)" } fn initial_size() -> (u32, u32) { (920, 640) } fn init(handle: &Handle) -> Model { animate(handle, motion::NORMAL, || Msg::Tick); Model { items: build_items(true, false), active: usize::MAX, open_sub: None, sub_active: usize::MAX, appear: Tween::new(0.0, 1.0, motion::NORMAL, motion::ease_out_cubic), viewport: (920.0, 640.0), anchors: vec![(60.0, 90.0), (430.0, 90.0), (9000.0, 90.0)], anchor_idx: 0, log: String::from("Prueba el teclado: ↑↓ · Inicio/Fin · una letra (type-ahead) · → submenú · Enter · Tab mueve el ancla"), } } fn on_resize(_m: &Model, w: u32, h: u32) -> Option { Some(Msg::Resize(w as f32, h as f32)) } fn on_key(_m: &Model, event: &KeyEvent) -> Option { (event.state == KeyState::Pressed).then(|| Msg::Key(event.clone())) } fn update(model: Model, msg: Msg, handle: &Handle) -> Model { let mut m = model; match msg { Msg::Tick => {} Msg::Resize(w, h) => m.viewport = (w, h), Msg::Hover(sub) => { m.open_sub = sub; if let Some(p) = sub { // Al abrir por hover, resalta el primero del submenú. if let Some(parent) = m.items.get(p) { m.sub_active = edge_active(&parent.children, false); } } else { m.sub_active = usize::MAX; } } Msg::Pick(i) => Demo::pick_root(&mut m, i, handle), Msg::PickSub(p, c) => Demo::pick_sub(&mut m, p, c), Msg::Dismiss => { m.active = usize::MAX; m.open_sub = None; m.sub_active = usize::MAX; m.log = String::from("(cerrado — clickea o navega para reabrir)"); Demo::reappear(&mut m, handle); } Msg::Key(e) => Demo::on_key_press(&mut m, e, handle), } m } fn view(model: &Model) -> View { // Fondo con instrucciones + el log de la última acción. let bg = View::new(Style { flex_direction: FlexDirection::Column, size: Size { width: percent(1.0_f32), height: percent(1.0_f32), }, padding: Rect { left: length(40.0_f32), right: length(40.0_f32), top: length(28.0_f32), bottom: length(28.0_f32), }, gap: Size { width: length(0.0_f32), height: length(10.0_f32), }, align_items: Some(AlignItems::FLEX_END), ..Default::default() }) .fill(Color::from_rgba8(16, 19, 26, 255)) .children(vec![View::new(Style { size: Size { width: percent(0.62_f32), height: length(28.0_f32), }, ..Default::default() }) .text_aligned( model.log.clone(), 13.0, Color::from_rgba8(150, 160, 180, 255), Alignment::End, )]); bg } fn view_overlay(model: &Model) -> Option> { let mut pal = ContextMenuPalette::from_theme(&llimphi_theme::Theme::default()); pal.scrim = Color::from_rgba8(0, 0, 0, 90); let items = model.items.clone(); let spec = ContextMenuSpec { anchor: model.anchors[model.anchor_idx], viewport: model.viewport, header: Some("Editar".into()), items, active: model.active, on_pick: Arc::new(Msg::Pick), on_dismiss: Msg::Dismiss, palette: pal, }; let extras = ContextMenuExtras { open_sub: model.open_sub, appear: model.appear.value(), on_pick_sub: Some(Arc::new(Msg::PickSub)), on_hover: Some(Arc::new(Msg::Hover)), }; Some(context_menu_view_ex(spec, extras)) } } impl Demo { fn pick_root(m: &mut Model, i: usize, handle: &Handle) { let Some(item) = m.items.get(i) else { return }; if item.has_submenu() { // Click en un item con submenú: alterná su flyout. m.open_sub = if m.open_sub == Some(i) { None } else { Some(i) }; m.sub_active = edge_active(&item.children.clone(), false); return; } // Toggle de los checkables por label (demo). match item.label.as_str() { "Ajustar texto" => { let cur = item.is_checked(); Self::rebuild(m, |w| *w = !cur, |_| {}); m.log = format!("Ajustar texto → {}", if !cur { "sí" } else { "no" }); } "Números de línea" => { let cur = item.is_checked(); Self::rebuild(m, |_| {}, |l| *l = !cur); m.log = format!("Números de línea → {}", if !cur { "sí" } else { "no" }); } other => m.log = format!("Elegiste: {other}"), } m.open_sub = None; Self::reappear(m, handle); } fn pick_sub(m: &mut Model, p: usize, c: usize) { let label = m .items .get(p) .and_then(|it| it.children.get(c)) .map(|it| it.label.clone()) .unwrap_or_default(); let parent = m.items.get(p).map(|it| it.label.clone()).unwrap_or_default(); m.log = format!("{parent} → {label}"); m.open_sub = None; m.sub_active = usize::MAX; } /// Reconstruye los items preservando el estado de los dos checkables. fn rebuild(m: &mut Model, mutate_wrap: impl FnOnce(&mut bool), mutate_ln: impl FnOnce(&mut bool)) { let mut wrap = m .items .iter() .find(|it| it.label == "Ajustar texto") .map(|it| it.is_checked()) .unwrap_or(false); let mut line_nums = m .items .iter() .find(|it| it.label == "Números de línea") .map(|it| it.is_checked()) .unwrap_or(false); mutate_wrap(&mut wrap); mutate_ln(&mut line_nums); m.items = build_items(wrap, line_nums); } fn on_key_press(m: &mut Model, e: KeyEvent, handle: &Handle) { match e.key { Key::Named(NamedKey::ArrowDown) => { if let Some(p) = m.open_sub { let ch = m.items[p].children.clone(); m.sub_active = step_active(&ch, m.sub_active, 1); } else { m.active = step_active(&m.items, m.active, 1); } } Key::Named(NamedKey::ArrowUp) => { if let Some(p) = m.open_sub { let ch = m.items[p].children.clone(); m.sub_active = step_active(&ch, m.sub_active, -1); } else { m.active = step_active(&m.items, m.active, -1); } } Key::Named(NamedKey::Home) => { if let Some(p) = m.open_sub { m.sub_active = edge_active(&m.items[p].children.clone(), false); } else { m.active = edge_active(&m.items, false); } } Key::Named(NamedKey::End) => { if let Some(p) = m.open_sub { m.sub_active = edge_active(&m.items[p].children.clone(), true); } else { m.active = edge_active(&m.items, true); } } Key::Named(NamedKey::ArrowRight) => { if m.open_sub.is_none() && m.active != usize::MAX && m.items.get(m.active).map(|it| it.has_submenu()) == Some(true) { m.open_sub = Some(m.active); m.sub_active = edge_active(&m.items[m.active].children.clone(), false); } } Key::Named(NamedKey::ArrowLeft) => { if m.open_sub.is_some() { m.open_sub = None; m.sub_active = usize::MAX; } } Key::Named(NamedKey::Enter) => { if let Some(p) = m.open_sub { if m.sub_active != usize::MAX { Self::pick_sub(m, p, m.sub_active); } } else if m.active != usize::MAX { Self::pick_root(m, m.active, handle); } } Key::Named(NamedKey::Escape) => { if m.open_sub.is_some() { m.open_sub = None; m.sub_active = usize::MAX; } else { m.active = usize::MAX; } } Key::Named(NamedKey::Tab) => { m.anchor_idx = (m.anchor_idx + 1) % m.anchors.len(); m.log = format!("Ancla #{} — mira de qué lado abre el submenú", m.anchor_idx + 1); } _ => { // Type-ahead: primera letra del texto tecleado. if let Some(ch) = e.text.as_deref().and_then(|t| t.chars().next()) { if ch.is_alphanumeric() { if let Some(p) = m.open_sub { let sub = type_ahead(&m.items[p].children.clone(), m.sub_active, ch); if sub != usize::MAX { m.sub_active = sub; } } else { let a = type_ahead(&m.items, m.active, ch); if a != usize::MAX { m.active = a; } } } } } } } } fn main() { llimphi_ui::run::(); }