tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1//! `llimphi-widget-context-menu` — menú contextual con look tawasuyu.
2//!
3//! Distintivo y minimalista:
4//!
5//! ```text
6//! ┌─────────────────────────┐ ← panel redondeado + borde hairline + sombra
7//! │ B5 │ ← header (uppercase tiny)
8//! │ ✂ Cortar Ctrl+X │
9//! │ ⧉ Copiar Ctrl+C │ ← gutter (ícono o ✓) · fila-píldora
10//! │ ⎘ Pegar Ctrl+V │
11//! │ ─────────────────────── │
12//! │ ✓ Ajustar texto │ ← item chequeable
13//! │ ◐ Tema ▸ │ ← submenú (flyout, flipea si no cabe)
14//! └─────────────────────────┘
15//! ```
16//!
17//! Cada fila es una píldora: indicador accent (firma, sólo en la activa) ·
18//! gutter (ícono vectorial o ✓ de check) · label (centrado vertical) ·
19//! atajo o chevron de submenú. El panel se eleva con esquinas redondeadas,
20//! borde hairline y una sombra suave (look "webpage" moderno).
21//!
22//! **Teclado (helpers puros que el host cablea):** [`step_active`] (↑/↓,
23//! saltea sep/disabled), [`edge_active`] (Home/End), [`type_ahead`]
24//! (mnemónico por primera letra). El submenú se navega llamando
25//! [`step_active`] sobre `parent.children` (ver `examples/menu_demo.rs`).
26//!
27//! Se monta como `View<Msg>` que se devuelve desde
28//! [`llimphi_compositor::App::view_overlay`]. Internamente arma:
29//! 1. Un **scrim** full-screen con `on_click = on_dismiss` que cierra
30//! el menú al click-fuera.
31//! 2. Un **panel** absoluto (clampeado al viewport).
32//! 3. Si hay un submenú abierto ([`ContextMenuSpec::open_sub`]), un
33//! segundo panel-flyout a la derecha del item padre.
34//!
35//! Animación: [`ContextMenuSpec::appear`] (0..1) controla un fade + un
36//! leve desplazamiento vertical de entrada. La app que quiera animarlo
37//! guarda un `Tween` y lo va subiendo; pasar `1.0` lo muestra fijo.
38
39#![forbid(unsafe_code)]
40
41
42// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
43// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
44// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
45#![no_std]
46
47extern crate alloc;
48
49use alloc::string::String;
50use alloc::vec::Vec;
51use alloc::vec;
52use alloc::string::ToString;
53use alloc::sync::Arc;
54
55use llimphi_compositor::llimphi_layout::taffy::{
56 prelude::{auto, length, percent, FlexDirection, Position, Size, Style},
57 AlignItems, JustifyContent, Rect,
58};
59use llimphi_compositor::peniko::Color;
60use llimphi_compositor::llimphi_text::Alignment;
61use llimphi_compositor::View;
62use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
63
64/// Paleta del menú — estilo "webpage" elegante derivado del theme:
65/// panel redondeado con borde hairline, filas como píldoras con hover
66/// suave (`bg_hover`) y resaltado de teclado (`bg_active`, más un
67/// indicador accent a la izquierda). Defaults dark; override por la app.
68#[derive(Debug, Clone, Copy)]
69pub struct ContextMenuPalette {
70 pub bg_panel: Color,
71 /// Fila bajo el cursor (hover) — tinte suave.
72 pub bg_hover: Color,
73 /// Fila activa por teclado (flechas) — algo más marcado que el hover.
74 pub bg_active: Color,
75 pub fg_text: Color,
76 /// Texto de la fila activa/hover (legible sobre el tinte suave).
77 pub fg_active: Color,
78 pub fg_shortcut: Color,
79 pub fg_disabled: Color,
80 pub fg_destructive: Color,
81 pub fg_header: Color,
82 /// Ícono en gutter (estado normal) — algo más apagado que el texto.
83 pub fg_icon: Color,
84 pub accent: Color,
85 pub border: Color,
86 pub separator: Color,
87 pub scrim: Color,
88 /// Radio de las esquinas del panel.
89 pub radius: f64,
90 pub panel: PanelStyle,
91}
92
93impl ContextMenuPalette {
94 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
95 // El panel se eleva sobre el fondo: usa `bg_panel` (no `bg_app`)
96 // con su gradiente sutil + esquinas redondeadas.
97 let mut panel = PanelStyle::neutral(t);
98 panel.bg_base = t.bg_panel;
99 panel.radius = PANEL_RADIUS as f64;
100 Self {
101 bg_panel: t.bg_panel,
102 bg_hover: t.bg_row_hover,
103 bg_active: t.bg_selected,
104 fg_text: t.fg_text,
105 fg_active: t.fg_text,
106 fg_shortcut: t.fg_muted,
107 fg_disabled: t.fg_muted,
108 fg_destructive: t.fg_destructive,
109 fg_header: t.fg_muted,
110 fg_icon: t.fg_muted,
111 accent: t.accent,
112 border: t.border,
113 separator: t.border,
114 scrim: Color::from_rgba8(0, 0, 0, 64),
115 radius: PANEL_RADIUS as f64,
116 panel,
117 }
118 }
119}
120
121/// Un item del menú. `separator = true` ignora el resto y pinta una
122/// línea. `children` no vacío → es un submenú (muestra chevron ▸ y, si
123/// está abierto, despliega un flyout). `icon` es un glifo opcional que
124/// se pinta en el gutter izquierdo.
125#[derive(Debug, Clone)]
126pub struct ContextMenuItem {
127 pub label: String,
128 pub shortcut: Option<String>,
129 pub icon: Option<String>,
130 pub enabled: bool,
131 pub separator: bool,
132 pub destructive: bool,
133 /// Estado chequeable (Material `CheckedPopupMenuItem` / check nativo).
134 /// `None` = no chequeable (usa `icon`). `Some(true/false)` = chequeable:
135 /// `true` pinta ✓ en el gutter (accent), `false` deja el gutter vacío
136 /// pero reservado para que el label no salte al alternar.
137 pub checked: Option<bool>,
138 /// Items del submenú. Vacío = acción simple.
139 pub children: Vec<ContextMenuItem>,
140}
141
142impl ContextMenuItem {
143 pub fn action(label: impl Into<String>) -> Self {
144 Self {
145 label: label.into(),
146 shortcut: None,
147 icon: None,
148 enabled: true,
149 separator: false,
150 destructive: false,
151 checked: None,
152 children: Vec::new(),
153 }
154 }
155
156 pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
157 self.shortcut = Some(shortcut.into());
158 self
159 }
160
161 /// Glifo del gutter izquierdo (unicode; no acopla a `llimphi-icons`).
162 pub fn icon(mut self, glyph: impl Into<String>) -> Self {
163 self.icon = Some(glyph.into());
164 self
165 }
166
167 pub fn disabled(mut self) -> Self {
168 self.enabled = false;
169 self
170 }
171
172 pub fn destructive(mut self) -> Self {
173 self.destructive = true;
174 self
175 }
176
177 /// Marca el item como chequeable con el estado dado (✓ cuando `on`).
178 /// El gutter muestra el check en vez del ícono.
179 pub fn checked(mut self, on: bool) -> Self {
180 self.checked = Some(on);
181 self
182 }
183
184 pub fn is_checked(&self) -> bool {
185 self.checked == Some(true)
186 }
187
188 /// Convierte el item en submenú con estos hijos.
189 pub fn submenu(mut self, children: Vec<ContextMenuItem>) -> Self {
190 self.children = children;
191 self
192 }
193
194 pub fn has_submenu(&self) -> bool {
195 !self.children.is_empty()
196 }
197
198 pub fn separator() -> Self {
199 Self {
200 label: String::new(),
201 shortcut: None,
202 icon: None,
203 enabled: false,
204 separator: true,
205 destructive: false,
206 checked: None,
207 children: Vec::new(),
208 }
209 }
210}
211
212/// Especificación del menú. Mantiene los 8 campos clásicos para no
213/// romper los call-sites por literal; las capacidades nuevas (submenús,
214/// animación, hover) viajan aparte en [`ContextMenuExtras`] vía
215/// [`context_menu_view_ex`].
216pub struct ContextMenuSpec<Msg: Clone + 'static> {
217 pub anchor: (f32, f32),
218 pub viewport: (f32, f32),
219 pub header: Option<String>,
220 pub items: Vec<ContextMenuItem>,
221 /// Índice resaltado por keyboard. `usize::MAX` = ninguno.
222 pub active: usize,
223 /// Click en un item de nivel raíz (índice).
224 pub on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync>,
225 /// Msg al click-fuera (scrim) o Esc.
226 pub on_dismiss: Msg,
227 pub palette: ContextMenuPalette,
228}
229
230/// Capacidades extra opcionales para [`context_menu_view_ex`]: submenús
231/// (flyout), animación de aparición y hover. Su `Default` reproduce el
232/// menú clásico (sin animación ni submenús).
233pub struct ContextMenuExtras<Msg: Clone + 'static> {
234 /// Índice del item-submenú desplegado (flyout). La app lo guarda y lo
235 /// actualiza vía `on_hover`.
236 pub open_sub: Option<usize>,
237 /// Progreso de aparición 0..1 (fade + leve slide). `1.0` = fijo.
238 pub appear: f32,
239 /// Click en un item de submenú: `(parent_idx, child_idx)`.
240 pub on_pick_sub: Option<Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>>,
241 /// Hover sobre un item raíz: `Some(idx)` si es submenú (abrir flyout),
242 /// `None` si es item normal (cerrar). La app guarda el resultado en
243 /// `open_sub`.
244 pub on_hover: Option<Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>>,
245}
246
247impl<Msg: Clone + 'static> Default for ContextMenuExtras<Msg> {
248 fn default() -> Self {
249 Self {
250 open_sub: None,
251 appear: 1.0,
252 on_pick_sub: None,
253 on_hover: None,
254 }
255 }
256}
257
258const PANEL_W: f32 = 268.0;
259/// Altura de cada item (no-separator).
260const ITEM_H: f32 = 32.0;
261const SEP_H: f32 = 9.0;
262/// Dónde arranca la columna del **rótulo**, medido desde el borde interno del
263/// panel: el padding de la fila + el indicador + el gutter del ícono + el gap.
264/// Es lo que sangra los separadores para que se alineen con el texto.
265const SANGRIA_TEXTO: f32 = ITEM_PAD_LEFT + INDICATOR_W + 2.0 + ICON_W;
266const HEADER_H: f32 = 26.0;
267/// Gutter del ícono a la izquierda del label.
268const ICON_W: f32 = 24.0;
269const ITEM_PAD_LEFT: f32 = 10.0;
270const ITEM_PAD_RIGHT: f32 = 12.0;
271/// Radio de las esquinas del panel (estilo webpage).
272const PANEL_RADIUS: f32 = 10.0;
273/// Radio de la píldora de hover/activo de cada fila.
274const ITEM_RADIUS: f32 = 6.0;
275/// Padding interno del panel (entre el borde y la columna de píldoras).
276const PANEL_PAD: f32 = 6.0;
277/// Ancho del indicador accent vertical de la fila activa.
278const INDICATOR_W: f32 = 3.0;
279/// Desplazamiento vertical de entrada (px) cuando `appear` = 0.
280const APPEAR_SLIDE: f32 = 8.0;
281
282/// Compone el menú clásico (sin submenús ni animación) como `View<Msg>`
283/// para `App::view_overlay`. Íconos, centrado vertical y separadores ya
284/// vienen incluidos.
285pub fn context_menu_view<Msg: Clone + 'static>(spec: ContextMenuSpec<Msg>) -> View<Msg> {
286 context_menu_view_ex(spec, ContextMenuExtras::default())
287}
288
289/// Como [`context_menu_view`] pero con [`ContextMenuExtras`]: submenús
290/// (flyout en hover), animación de aparición y hover.
291pub fn context_menu_view_ex<Msg: Clone + 'static>(
292 spec: ContextMenuSpec<Msg>,
293 extras: ContextMenuExtras<Msg>,
294) -> View<Msg> {
295 let ContextMenuSpec {
296 anchor,
297 viewport,
298 header,
299 items,
300 active,
301 on_pick,
302 on_dismiss,
303 palette,
304 } = spec;
305 let ContextMenuExtras {
306 open_sub,
307 appear,
308 on_pick_sub,
309 on_hover,
310 } = extras;
311
312 let appear = appear.clamp(0.0, 1.0);
313 let slide = (1.0 - appear) * APPEAR_SLIDE;
314
315 let (panel, panel_x, panel_y) = panel_view(
316 anchor,
317 viewport,
318 &header,
319 &items,
320 active,
321 slide,
322 &on_pick,
323 on_hover.as_ref(),
324 &palette,
325 );
326
327 let mut layers: Vec<View<Msg>> = vec![panel];
328
329 // Flyout del submenú abierto (sólo si la app provee `on_pick_sub`).
330 if let (Some(pidx), Some(on_pick_sub)) = (open_sub, on_pick_sub.as_ref()) {
331 if let Some(parent) = items.get(pidx).filter(|it| it.has_submenu()) {
332 let sub_anchor = submenu_anchor(panel_x, panel_y, viewport, &header, &items, pidx);
333 let flyout = submenu_view(
334 sub_anchor,
335 viewport,
336 pidx,
337 &parent.children,
338 slide,
339 on_pick_sub,
340 &palette,
341 );
342 layers.push(flyout);
343 }
344 }
345
346 // Scrim full-screen: cualquier click "fuera" dismissa.
347 View::new(Style {
348 size: Size {
349 width: percent(1.0_f32),
350 height: percent(1.0_f32),
351 },
352 ..Default::default()
353 })
354 .fill(palette.scrim)
355 .alpha(appear)
356 // El scrim: el click en el vacío cierra el menú. Es la pantalla entera y era
357 // el único nodo mudo que quedaba en shuma y en puriy — uno por menú abierto,
358 // y lo abren la barra de menús, los contextuales y los desplegables.
359 .role(llimphi_compositor::Role::Button)
360 .aria_label("Cerrar el menú")
361 .on_click(on_dismiss)
362 .children(layers)
363}
364
365/// Arma el panel raíz y devuelve `(view, x, y)` ya clampeados.
366#[allow(clippy::too_many_arguments)]
367fn panel_view<Msg: Clone + 'static>(
368 anchor: (f32, f32),
369 viewport: (f32, f32),
370 header: &Option<String>,
371 items: &[ContextMenuItem],
372 active: usize,
373 slide: f32,
374 on_pick: &Arc<dyn Fn(usize) -> Msg + Send + Sync>,
375 on_hover: Option<&Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>>,
376 palette: &ContextMenuPalette,
377) -> (View<Msg>, f32, f32) {
378 let header_h = if header.is_some() { HEADER_H } else { 0.0 };
379 let items_h: f32 = items
380 .iter()
381 .map(|it| if it.separator { SEP_H } else { ITEM_H })
382 .sum();
383 // borde (1+1) + padding interno (PANEL_PAD ×2) + header + items.
384 let panel_h = 2.0 + 2.0 * PANEL_PAD + header_h + items_h;
385
386 let margin = 4.0;
387 // **Y nunca más alto que la pantalla.** Sin este tope, un menú con muchas
388 // entradas se pintaba TAL CUAL: 843 px de panel sobre un escritorio de 800,
389 // clampeado a y=4 y sobrando por abajo. Las entradas de la cola no eran
390 // alcanzables de ninguna manera —estaban fuera de la pantalla— y encima el
391 // panel se comía el borde. Con el tope, el panel termina donde termina la
392 // pantalla y lo que no entra queda recortado por el `clip` del contenedor.
393 let panel_h = panel_h.min(viewport.1 - 2.0 * margin);
394 let x = anchor
395 .0
396 .min((viewport.0 - PANEL_W - margin).max(margin))
397 .max(margin);
398 let y = anchor
399 .1
400 .min((viewport.1 - panel_h - margin).max(margin))
401 .max(margin);
402
403 let mut children: Vec<View<Msg>> = Vec::with_capacity(items.len() + 1);
404 if let Some(text) = header {
405 children.push(header_view(text.clone(), palette));
406 }
407 for (i, item) in items.iter().enumerate() {
408 children.push(item_view(
409 i,
410 None,
411 item,
412 i == active,
413 on_pick,
414 on_hover,
415 palette,
416 ));
417 }
418
419 let panel = panel_container(
420 x,
421 y_con_slide(y, slide, panel_h, viewport.1, margin),
422 panel_h,
423 children,
424 palette,
425 );
426 (panel, x, y)
427}
428
429/// Flyout del submenú: mismo look, posicionado a la derecha del padre.
430#[allow(clippy::too_many_arguments)]
431fn submenu_view<Msg: Clone + 'static>(
432 anchor: (f32, f32),
433 viewport: (f32, f32),
434 parent_idx: usize,
435 children_items: &[ContextMenuItem],
436 slide: f32,
437 on_pick_sub: &Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>,
438 palette: &ContextMenuPalette,
439) -> View<Msg> {
440 let panel_h: f32 = children_items
441 .iter()
442 .map(|it| if it.separator { SEP_H } else { ITEM_H })
443 .sum::<f32>()
444 + 2.0
445 + 2.0 * PANEL_PAD;
446 let margin = 4.0;
447 // Mismo tope que el panel raíz: un submenú de veinte «Abrir con» medía más
448 // que el escritorio entero.
449 let panel_h = panel_h.min(viewport.1 - 2.0 * margin);
450 let x = anchor
451 .0
452 .min((viewport.0 - PANEL_W - margin).max(margin))
453 .max(margin);
454 let y = anchor
455 .1
456 .min((viewport.1 - panel_h - margin).max(margin))
457 .max(margin);
458
459 let mut children: Vec<View<Msg>> = Vec::with_capacity(children_items.len());
460 for (j, item) in children_items.iter().enumerate() {
461 children.push(item_view(
462 j,
463 Some((parent_idx, on_pick_sub.clone())),
464 item,
465 false,
466 // on_pick raíz no se usa cuando hay parent; pasamos un dummy.
467 &dummy_pick(),
468 None,
469 palette,
470 ));
471 }
472 panel_container(
473 x,
474 y_con_slide(y, slide, panel_h, viewport.1, margin),
475 panel_h,
476 children,
477 palette,
478 )
479}
480
481/// El contenedor visual: panel redondeado con borde hairline (un nodo
482/// exterior del color del borde + uno interior con el gradiente del
483/// PanelStyle) y padding interno para que las píldoras de cada fila
484/// queden inset — el look de menú de webpage.
485fn panel_container<Msg: Clone + 'static>(
486 x: f32,
487 y: f32,
488 panel_h: f32,
489 children: Vec<View<Msg>>,
490 palette: &ContextMenuPalette,
491) -> View<Msg> {
492 View::new(Style {
493 position: Position::Absolute,
494 inset: Rect {
495 left: length(x),
496 top: length(y),
497 right: auto(),
498 bottom: auto(),
499 },
500 size: Size {
501 width: length(PANEL_W),
502 height: length(panel_h),
503 },
504 padding: Rect {
505 left: length(1.0_f32),
506 right: length(1.0_f32),
507 top: length(1.0_f32),
508 bottom: length(1.0_f32),
509 },
510 ..Default::default()
511 })
512 .fill(palette.border)
513 .radius(palette.radius as f64)
514 // Elevación: el menú flota sobre el contenido. Sombra suave hacia abajo
515 // (misma técnica del `llimphi-button`, más difusa por ser overlay). El
516 // fill opaco del borde tapa el solape, así sólo asoma el halo.
517 .shadow(llimphi_compositor::Shadow {
518 color: Color::from_rgba8(0, 0, 0, 56),
519 blur: 18.0,
520 dx: 0.0,
521 dy: 7.0,
522 spread: -2.0,
523 })
524 // Recorta: con el panel topado a la pantalla, lo que no entra se queda
525 // adentro del panel en vez de pintarse encima de todo lo demás.
526 .clip(true)
527 .children(vec![View::new(Style {
528 flex_direction: FlexDirection::Column,
529 flex_grow: 1.0,
530 size: Size {
531 width: percent(1.0_f32),
532 height: percent(1.0_f32),
533 },
534 padding: Rect {
535 left: length(PANEL_PAD),
536 right: length(PANEL_PAD),
537 top: length(PANEL_PAD),
538 bottom: length(PANEL_PAD),
539 },
540 ..Default::default()
541 })
542 .radius((palette.radius - 1.0) as f64)
543 .paint_with(panel_signature_painter(palette.panel))
544 .children(children)])
545}
546
547/// Ancla del flyout: por defecto a la derecha del panel padre alineado al
548/// item; si no cabe a la derecha del viewport, **flipea** y abre a la
549/// izquierda (comportamiento nativo). El pequeño solape (`PANEL_PAD`) hace
550/// que el flyout se lea continuo con el padre.
551fn submenu_anchor(
552 panel_x: f32,
553 panel_y: f32,
554 viewport: (f32, f32),
555 header: &Option<String>,
556 items: &[ContextMenuItem],
557 parent_idx: usize,
558) -> (f32, f32) {
559 let mut off = if header.is_some() { HEADER_H } else { 0.0 };
560 off += 1.0 + PANEL_PAD; // borde + padding interno del contenedor
561 for it in items.iter().take(parent_idx) {
562 off += if it.separator { SEP_H } else { ITEM_H };
563 }
564 let margin = 4.0;
565 let right_x = panel_x + PANEL_W - PANEL_PAD;
566 // Si el flyout a la derecha se saldría del viewport, abrirlo a la
567 // izquierda del panel padre.
568 let x = if right_x + PANEL_W + margin > viewport.0 {
569 panel_x - PANEL_W + PANEL_PAD
570 } else {
571 right_x
572 };
573 (x, panel_y + off)
574}
575
576fn header_view<Msg: Clone + 'static>(text: String, palette: &ContextMenuPalette) -> View<Msg> {
577 View::new(Style {
578 size: Size {
579 width: percent(1.0_f32),
580 height: length(HEADER_H),
581 },
582 padding: Rect {
583 left: length(ITEM_PAD_LEFT + INDICATOR_W + ICON_W + 4.0),
584 right: length(ITEM_PAD_RIGHT),
585 top: length(2.0_f32),
586 bottom: length(0.0_f32),
587 },
588 align_items: Some(AlignItems::CENTER),
589 ..Default::default()
590 })
591 .text_aligned(text.to_uppercase(), 9.5, palette.fg_header, Alignment::Start)
592}
593
594/// Pinta una fila. Si `parent` es `Some((pidx, cb))`, es un item de
595/// submenú y clickea vía `cb(pidx, idx)`; si es `None`, es raíz y usa
596/// `on_pick(idx)` + (si corresponde) `on_hover` para abrir su flyout.
597#[allow(clippy::too_many_arguments)]
598fn item_view<Msg: Clone + 'static>(
599 idx: usize,
600 parent: Option<(usize, Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>)>,
601 item: &ContextMenuItem,
602 is_active: bool,
603 on_pick: &Arc<dyn Fn(usize) -> Msg + Send + Sync>,
604 on_hover: Option<&Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>>,
605 palette: &ContextMenuPalette,
606) -> View<Msg> {
607 if item.separator {
608 return separator_view(palette);
609 }
610
611 // Color del texto y del atajo según estado.
612 let (fg, fg_dim): (Color, Color) = if !item.enabled {
613 (palette.fg_disabled, palette.fg_disabled)
614 } else if item.destructive {
615 (palette.fg_destructive, palette.fg_shortcut)
616 } else if is_active {
617 (palette.fg_active, palette.fg_active)
618 } else {
619 (palette.fg_text, palette.fg_shortcut)
620 };
621 // Ícono: accent cuando la fila está activa o chequeada (cue del menú),
622 // si no apagado.
623 let icon_fg = if !item.enabled {
624 palette.fg_disabled
625 } else if item.is_checked() || is_active {
626 palette.accent
627 } else {
628 palette.fg_icon
629 };
630
631 // Indicador accent vertical a la izquierda — visible sólo en la fila
632 // activa; reserva su ancho siempre para que el texto no salte.
633 let indicator = View::new(Style {
634 size: Size {
635 width: length(INDICATOR_W),
636 height: percent(0.55_f32),
637 },
638 flex_shrink: 0.0,
639 ..Default::default()
640 });
641 let indicator = if is_active && item.enabled {
642 indicator.fill(palette.accent).radius(2.0)
643 } else {
644 indicator
645 };
646
647 // Gutter — para un item chequeable pinta ✓ (o vacío si está off); si no,
648 // el glifo declarado se resuelve a un ícono VECTORIAL (determinista en
649 // toda máquina); si el catálogo no lo conoce, cae a texto.
650 let glifo = match item.checked {
651 Some(true) => "\u{2713}".to_string(), // ✓
652 Some(false) => String::new(),
653 None => item.icon.clone().unwrap_or_default(),
654 };
655 let icon_inner: View<Msg> = match llimphi_icons::Icon::from_glyph(&glifo) {
656 Some(icon) => View::new(Style {
657 size: Size { width: length(15.0_f32), height: length(15.0_f32) },
658 flex_shrink: 0.0,
659 ..Default::default()
660 })
661 .children(vec![llimphi_icons::icon_view(icon, icon_fg, 1.6)]),
662 None => View::new(Style { size: Size { width: auto(), height: auto() }, ..Default::default() })
663 .text_aligned(glifo, 13.0, icon_fg, Alignment::Center),
664 };
665 let icon_cell = View::new(Style {
666 size: Size {
667 width: length(ICON_W),
668 height: auto(),
669 },
670 flex_shrink: 0.0,
671 align_items: Some(AlignItems::CENTER),
672 justify_content: Some(JustifyContent::CENTER),
673 ..Default::default()
674 })
675 .children(vec![icon_inner]);
676
677 // Label — auto height (lo centra el align_items Center del row).
678 let label = View::new(Style {
679 size: Size {
680 width: auto(),
681 height: auto(),
682 },
683 flex_grow: 1.0,
684 ..Default::default()
685 })
686 .text_aligned(item.label.clone(), 12.5, fg, Alignment::Start);
687
688 // Cola: chevron de submenú o atajo de teclado.
689 let trailing_text = if item.has_submenu() {
690 Some(("\u{203A}".to_string(), fg)) // ›
691 } else {
692 item.shortcut.clone().map(|s| (s, fg_dim))
693 };
694 let mut row_children: Vec<View<Msg>> = vec![indicator, icon_cell, label];
695 if let Some((txt, color)) = trailing_text {
696 row_children.push(
697 View::new(Style {
698 // Ancho **mínimo**, no fijo: con 64 px clavados, «Shift+Supr»
699 // no entraba y se comía el rótulo de al lado. Alineado a la
700 // derecha, la columna de atajos queda pareja igual, y el que
701 // no entra empuja su propia caja en vez de desbordar.
702 size: Size { width: auto(), height: auto() },
703 min_size: Size { width: length(58.0_f32), height: auto() },
704 flex_shrink: 0.0,
705 ..Default::default()
706 })
707 .text_aligned(txt, 11.0, color, Alignment::End),
708 );
709 }
710
711 let mut row = View::new(Style {
712 size: Size {
713 width: percent(1.0_f32),
714 height: length(ITEM_H),
715 },
716 flex_direction: FlexDirection::Row,
717 padding: Rect {
718 left: length(ITEM_PAD_LEFT),
719 right: length(ITEM_PAD_RIGHT),
720 top: length(0.0_f32),
721 bottom: length(0.0_f32),
722 },
723 gap: Size {
724 // Dos píxeles entre el rótulo y su atajo los dejaba pegados. El
725 // atajo es información secundaria: necesita aire para leerse como
726 // secundaria y no como parte del nombre de la acción.
727 width: length(10.0_f32),
728 height: length(0.0_f32),
729 },
730 align_items: Some(AlignItems::CENTER),
731 ..Default::default()
732 })
733 .radius(ITEM_RADIUS as f64)
734 // Semántica del ítem: rol MenuItem + label visible. `disabled` se
735 // refleja del `enabled` invertido. AccessKit lo expone como
736 // navegable por TTS dentro del menú.
737 .role(llimphi_compositor::Role::MenuItem)
738 .aria_label(item.label.clone())
739 .aria_disabled(!item.enabled)
740 .children(row_children);
741
742 // Fondo: píldora suave en activo (teclado). El hover lo aporta
743 // `hover_fill` (tinte aún más suave) para no competir con el activo.
744 if is_active && item.enabled {
745 row = row.fill(palette.bg_active);
746 }
747
748 if item.enabled {
749 row = row.hover_fill(palette.bg_hover);
750 match &parent {
751 Some((pidx, cb)) => {
752 let cb = cb.clone();
753 let pidx = *pidx;
754 row = row.on_click_at(move |_, _, _, _| Some(cb(pidx, idx)));
755 }
756 None => {
757 let on_pick = on_pick.clone();
758 row = row.on_click_at(move |_, _, _, _| Some(on_pick(idx)));
759 // Hover abre/cierra el flyout según sea submenú o no.
760 if let Some(on_hover) = on_hover {
761 let on_hover = on_hover.clone();
762 let target = if item.has_submenu() { Some(idx) } else { None };
763 row = row.on_pointer_enter(on_hover(target));
764 }
765 }
766 }
767 }
768 row
769}
770
771fn separator_view<Msg: Clone + 'static>(palette: &ContextMenuPalette) -> View<Msg> {
772 View::new(Style {
773 size: Size {
774 width: percent(1.0_f32),
775 height: length(SEP_H),
776 },
777 flex_direction: FlexDirection::Column,
778 justify_content: Some(JustifyContent::CENTER),
779 align_items: Some(AlignItems::CENTER),
780 // **Arranca donde arranca el texto**, no en el borde del panel. Una
781 // línea de borde a borde parte el menú en cajones y, con seis o siete
782 // grupos, el menú se lee como una tabla y no como una lista. Sangrada
783 // hasta la columna del rótulo, las líneas "cuelgan" del texto y los
784 // grupos se leen sin cortar el panel.
785 padding: Rect {
786 left: length(SANGRIA_TEXTO),
787 right: length(ITEM_PAD_RIGHT),
788 top: length(0.0_f32),
789 bottom: length(0.0_f32),
790 },
791 ..Default::default()
792 })
793 .children(vec![View::new(Style {
794 size: Size {
795 width: percent(1.0_f32),
796 height: length(1.0_f32),
797 },
798 ..Default::default()
799 })
800 .fill(palette.separator)])
801}
802
803/// **El desplazamiento de entrada, ya acotado a la pantalla.**
804///
805/// El slide baja el panel unos píxeles mientras aparece. Cuando el menú se
806/// abre pegado al borde de abajo —que es donde el panel ya está topado contra
807/// la pantalla— esos píxeles lo empujan afuera: el `y` clampeado más el slide
808/// deja el panel sobresaliendo justo lo que dura la animación. Se ve como un
809/// menú que "rebota" contra el borde inferior y, en un layout medido, como un
810/// rect fuera del viewport.
811///
812/// Acá se vuelve a aplicar el mismo clamp que fijó el `y`: con lugar, el slide
813/// entra entero; sin lugar, se recorta a lo que haya.
814fn y_con_slide(y: f32, slide: f32, panel_h: f32, viewport_h: f32, margin: f32) -> f32 {
815 (y + slide)
816 .min((viewport_h - panel_h - margin).max(margin))
817 .max(margin)
818}
819
820/// `on_pick` dummy para los items de submenú (que usan `on_pick_sub`).
821/// Nunca se invoca: `item_view` con `parent=Some` ignora `on_pick`.
822fn dummy_pick<Msg: Clone + 'static>() -> Arc<dyn Fn(usize) -> Msg + Send + Sync> {
823 Arc::new(|_| unreachable!("submenu item usa on_pick_sub, no on_pick"))
824}
825
826/// Navegación por teclado: dado el activo + dirección (`+1`/`-1`), el
827/// siguiente índice válido (saltea separators y disabled). `usize::MAX`
828/// si no hay elegibles.
829pub fn step_active(items: &[ContextMenuItem], current: usize, direction: i32) -> usize {
830 if items.is_empty() {
831 return usize::MAX;
832 }
833 let n = items.len() as i32;
834 let start = if current == usize::MAX {
835 if direction >= 0 {
836 -1
837 } else {
838 n
839 }
840 } else {
841 current as i32
842 };
843 let mut i = start;
844 for _ in 0..n {
845 i += direction;
846 if i < 0 {
847 i = n - 1;
848 } else if i >= n {
849 i = 0;
850 }
851 let item = &items[i as usize];
852 if !item.separator && item.enabled {
853 return i as usize;
854 }
855 }
856 usize::MAX
857}
858
859/// Home/End: primer (`to_end=false`) o último (`to_end=true`) item
860/// elegible (saltea separators y disabled). `usize::MAX` si no hay ninguno.
861pub fn edge_active(items: &[ContextMenuItem], to_end: bool) -> usize {
862 let pickable = |it: &ContextMenuItem| !it.separator && it.enabled;
863 if to_end {
864 items.iter().rposition(pickable)
865 } else {
866 items.iter().position(pickable)
867 }
868 .unwrap_or(usize::MAX)
869}
870
871/// Type-ahead / mnemónico: siguiente item elegible cuyo label empieza por
872/// `ch` (case-insensitive), buscando desde `current + 1` y ciclando (así
873/// teclar la misma letra rota entre coincidencias). `usize::MAX` si ninguno.
874pub fn type_ahead(items: &[ContextMenuItem], current: usize, ch: char) -> usize {
875 let n = items.len();
876 if n == 0 {
877 return usize::MAX;
878 }
879 let needle = ch.to_ascii_lowercase();
880 let start = if current == usize::MAX { 0 } else { current };
881 for k in 1..=n {
882 let i = (start + k) % n;
883 let it = &items[i];
884 if it.separator || !it.enabled {
885 continue;
886 }
887 let first = it.label.chars().next().map(|c| c.to_ascii_lowercase());
888 if first == Some(needle) {
889 return i;
890 }
891 }
892 usize::MAX
893}
894
895#[cfg(test)]
896mod tests {
897 #[test]
898 fn el_slide_de_entrada_no_empuja_el_menu_fuera_de_la_pantalla() {
899 // Con lugar de sobra, el slide entra entero.
900 assert_eq!(y_con_slide(100.0, 8.0, 200.0, 800.0, 4.0), 108.0);
901 // Contra el borde de abajo —un menú abierto en la esquina, que es
902 // donde el panel YA está topado— el slide se recorta: sin esto, el
903 // menú sobresalía justo mientras duraba la animación.
904 let panel_h = 792.0;
905 let y = y_con_slide(4.0, 8.0, panel_h, 800.0, 4.0);
906 assert!(y + panel_h <= 800.0, "y={y} + {panel_h} se sale de 800");
907 assert_eq!(y, 4.0);
908 // Y nunca negativo, aunque el panel no entre.
909 assert!(y_con_slide(4.0, 8.0, 2000.0, 800.0, 4.0) >= 0.0);
910 }
911
912 use super::*;
913
914 fn it(label: &str) -> ContextMenuItem {
915 ContextMenuItem::action(label)
916 }
917
918 #[test]
919 fn step_active_skips_separators() {
920 let items = vec![it("A"), ContextMenuItem::separator(), it("B"), it("C")];
921 assert_eq!(step_active(&items, 0, 1), 2);
922 assert_eq!(step_active(&items, 2, -1), 0);
923 }
924
925 #[test]
926 fn step_active_skips_disabled() {
927 let items = vec![it("A"), it("B").disabled(), it("C")];
928 assert_eq!(step_active(&items, 0, 1), 2);
929 assert_eq!(step_active(&items, 2, -1), 0);
930 }
931
932 #[test]
933 fn step_active_wraps_around() {
934 let items = vec![it("A"), it("B"), it("C")];
935 assert_eq!(step_active(&items, 2, 1), 0);
936 assert_eq!(step_active(&items, 0, -1), 2);
937 }
938
939 #[test]
940 fn submenu_y_icono_se_setean() {
941 let item = it("Tema")
942 .icon("◐")
943 .submenu(vec![it("Oscuro"), it("Claro")]);
944 assert!(item.has_submenu());
945 assert_eq!(item.children.len(), 2);
946 assert_eq!(item.icon.as_deref(), Some("◐"));
947 }
948
949 #[test]
950 fn edge_active_home_end_saltea_bordes() {
951 let items = vec![
952 ContextMenuItem::separator(),
953 it("A").disabled(),
954 it("B"),
955 it("C"),
956 it("D").disabled(),
957 ContextMenuItem::separator(),
958 ];
959 // Home = primer elegible (B, idx 2), no el separator ni el disabled.
960 assert_eq!(edge_active(&items, false), 2);
961 // End = último elegible (C, idx 3), no el disabled ni el separator.
962 assert_eq!(edge_active(&items, true), 3);
963 }
964
965 #[test]
966 fn edge_active_sin_elegibles_es_max() {
967 let items = vec![ContextMenuItem::separator(), it("X").disabled()];
968 assert_eq!(edge_active(&items, false), usize::MAX);
969 assert_eq!(edge_active(&items, true), usize::MAX);
970 }
971
972 #[test]
973 fn type_ahead_salta_por_primera_letra_y_cicla() {
974 let items = vec![it("Abrir"), it("Buscar"), it("Borrar"), it("Cerrar")];
975 // Desde ninguno, 'b' → primer "B" (Buscar, idx 1).
976 assert_eq!(type_ahead(&items, usize::MAX, 'b'), 1);
977 // Estando en Buscar, otra 'b' → siguiente "B" (Borrar, idx 2).
978 assert_eq!(type_ahead(&items, 1, 'b'), 2);
979 // Estando en Borrar, otra 'b' → cicla de vuelta a Buscar.
980 assert_eq!(type_ahead(&items, 2, 'b'), 1);
981 // Case-insensitive.
982 assert_eq!(type_ahead(&items, usize::MAX, 'C'), 3);
983 // Sin coincidencia.
984 assert_eq!(type_ahead(&items, usize::MAX, 'z'), usize::MAX);
985 }
986
987 #[test]
988 fn type_ahead_saltea_disabled_y_separators() {
989 let items = vec![
990 it("Alfa").disabled(),
991 ContextMenuItem::separator(),
992 it("Ajuste"),
993 ];
994 assert_eq!(type_ahead(&items, usize::MAX, 'a'), 2);
995 }
996
997 #[test]
998 fn checked_builder_y_gutter() {
999 let on = it("Ajustar texto").checked(true);
1000 let off = it("Ajustar texto").checked(false);
1001 assert_eq!(on.checked, Some(true));
1002 assert!(on.is_checked());
1003 assert_eq!(off.checked, Some(false));
1004 assert!(!off.is_checked());
1005 // Un item normal no es chequeable.
1006 assert_eq!(it("X").checked, None);
1007 }
1008
1009 #[test]
1010 fn submenu_flipea_a_la_izquierda_sin_espacio() {
1011 let items = vec![it("Tema").submenu(vec![it("Oscuro")])];
1012 // Panel pegado al borde derecho: no cabe a la derecha → abre a la izq.
1013 let vp = (400.0, 600.0);
1014 let panel_x = vp.0 - PANEL_W - 4.0; // clampeado al borde derecho
1015 let (x_flip, _) = submenu_anchor(panel_x, 20.0, vp, &None, &items, 0);
1016 assert!(
1017 x_flip < panel_x,
1018 "sin espacio a la derecha, el flyout debe abrir a la izquierda (x_flip={x_flip}, panel_x={panel_x})"
1019 );
1020 // Con panel a la izquierda y viewport ancho: abre a la derecha.
1021 let vp_wide = (2000.0, 600.0);
1022 let (x_right, _) = submenu_anchor(20.0, 20.0, vp_wide, &None, &items, 0);
1023 assert!(x_right > 20.0, "con espacio, abre a la derecha");
1024 }
1025
1026 #[test]
1027 fn extras_default_es_menu_clasico() {
1028 let extras: ContextMenuExtras<u8> = ContextMenuExtras::default();
1029 assert_eq!(extras.appear, 1.0);
1030 assert!(extras.open_sub.is_none());
1031 assert!(extras.on_hover.is_none());
1032 assert!(extras.on_pick_sub.is_none());
1033 }
1034}