tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ main widgets/context-menu/src/lib.rs Rust · 1034 lines · 35.7 KB History Who touched what raw

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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}