Skip to main content

llimphi_widget_context_menu/
lib.rs

1//! `llimphi-widget-context-menu` — menú contextual con look tawasuyu.
2//!
3//! Distintivo y minimalista:
4//!
5//! ```text
6//!   ┃ B5                          ← header (uppercase tiny)
7//!   ┃ ✂  Cortar          Ctrl+X
8//!   ┃ ⧉  Copiar          Ctrl+C   ← gutter de íconos + barra accent (3px)
9//!   ┃ ⎘  Pegar           Ctrl+V
10//!   ┃ ─────────────────────
11//!   ┃ ◐  Tema             ▸       ← submenú (flyout a la derecha)
12//! ```
13//!
14//! Cada fila: barra accent vertical (firma) · gutter de ícono · label
15//! (centrado vertical) · atajo o chevron de submenú. Sin radios, sin
16//! sombras: color sólido + tipografía + la barra accent.
17//!
18//! Se monta como `View<Msg>` que se devuelve desde
19//! [`llimphi_ui::App::view_overlay`]. Internamente arma:
20//! 1. Un **scrim** full-screen con `on_click = on_dismiss` que cierra
21//!    el menú al click-fuera.
22//! 2. Un **panel** absoluto (clampeado al viewport).
23//! 3. Si hay un submenú abierto ([`ContextMenuSpec::open_sub`]), un
24//!    segundo panel-flyout a la derecha del item padre.
25//!
26//! Animación: [`ContextMenuSpec::appear`] (0..1) controla un fade + un
27//! leve desplazamiento vertical de entrada. La app que quiera animarlo
28//! guarda un `Tween` y lo va subiendo; pasar `1.0` lo muestra fijo.
29
30#![forbid(unsafe_code)]
31
32use std::sync::Arc;
33
34use llimphi_ui::llimphi_layout::taffy::{
35    prelude::{auto, length, percent, FlexDirection, Position, Size, Style},
36    AlignItems, JustifyContent, Rect,
37};
38use llimphi_ui::llimphi_raster::peniko::Color;
39use llimphi_ui::llimphi_text::Alignment;
40use llimphi_ui::View;
41use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
42
43/// Paleta del menú — estilo "webpage" elegante derivado del theme:
44/// panel redondeado con borde hairline, filas como píldoras con hover
45/// suave (`bg_hover`) y resaltado de teclado (`bg_active`, más un
46/// indicador accent a la izquierda). Defaults dark; override por la app.
47#[derive(Debug, Clone, Copy)]
48pub struct ContextMenuPalette {
49    pub bg_panel: Color,
50    /// Fila bajo el cursor (hover) — tinte suave.
51    pub bg_hover: Color,
52    /// Fila activa por teclado (flechas) — algo más marcado que el hover.
53    pub bg_active: Color,
54    pub fg_text: Color,
55    /// Texto de la fila activa/hover (legible sobre el tinte suave).
56    pub fg_active: Color,
57    pub fg_shortcut: Color,
58    pub fg_disabled: Color,
59    pub fg_destructive: Color,
60    pub fg_header: Color,
61    /// Ícono en gutter (estado normal) — algo más apagado que el texto.
62    pub fg_icon: Color,
63    pub accent: Color,
64    pub border: Color,
65    pub separator: Color,
66    pub scrim: Color,
67    /// Radio de las esquinas del panel.
68    pub radius: f64,
69    pub panel: PanelStyle,
70}
71
72impl ContextMenuPalette {
73    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
74        // El panel se eleva sobre el fondo: usa `bg_panel` (no `bg_app`)
75        // con su gradiente sutil + esquinas redondeadas.
76        let mut panel = PanelStyle::neutral(t);
77        panel.bg_base = t.bg_panel;
78        panel.radius = PANEL_RADIUS as f64;
79        Self {
80            bg_panel: t.bg_panel,
81            bg_hover: t.bg_row_hover,
82            bg_active: t.bg_selected,
83            fg_text: t.fg_text,
84            fg_active: t.fg_text,
85            fg_shortcut: t.fg_muted,
86            fg_disabled: t.fg_muted,
87            fg_destructive: t.fg_destructive,
88            fg_header: t.fg_muted,
89            fg_icon: t.fg_muted,
90            accent: t.accent,
91            border: t.border,
92            separator: t.border,
93            scrim: Color::from_rgba8(0, 0, 0, 64),
94            radius: PANEL_RADIUS as f64,
95            panel,
96        }
97    }
98}
99
100/// Un item del menú. `separator = true` ignora el resto y pinta una
101/// línea. `children` no vacío → es un submenú (muestra chevron ▸ y, si
102/// está abierto, despliega un flyout). `icon` es un glifo opcional que
103/// se pinta en el gutter izquierdo.
104#[derive(Debug, Clone)]
105pub struct ContextMenuItem {
106    pub label: String,
107    pub shortcut: Option<String>,
108    pub icon: Option<String>,
109    pub enabled: bool,
110    pub separator: bool,
111    pub destructive: bool,
112    /// Items del submenú. Vacío = acción simple.
113    pub children: Vec<ContextMenuItem>,
114}
115
116impl ContextMenuItem {
117    pub fn action(label: impl Into<String>) -> Self {
118        Self {
119            label: label.into(),
120            shortcut: None,
121            icon: None,
122            enabled: true,
123            separator: false,
124            destructive: false,
125            children: Vec::new(),
126        }
127    }
128
129    pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
130        self.shortcut = Some(shortcut.into());
131        self
132    }
133
134    /// Glifo del gutter izquierdo (unicode; no acopla a `llimphi-icons`).
135    pub fn icon(mut self, glyph: impl Into<String>) -> Self {
136        self.icon = Some(glyph.into());
137        self
138    }
139
140    pub fn disabled(mut self) -> Self {
141        self.enabled = false;
142        self
143    }
144
145    pub fn destructive(mut self) -> Self {
146        self.destructive = true;
147        self
148    }
149
150    /// Convierte el item en submenú con estos hijos.
151    pub fn submenu(mut self, children: Vec<ContextMenuItem>) -> Self {
152        self.children = children;
153        self
154    }
155
156    pub fn has_submenu(&self) -> bool {
157        !self.children.is_empty()
158    }
159
160    pub fn separator() -> Self {
161        Self {
162            label: String::new(),
163            shortcut: None,
164            icon: None,
165            enabled: false,
166            separator: true,
167            destructive: false,
168            children: Vec::new(),
169        }
170    }
171}
172
173/// Especificación del menú. Mantiene los 8 campos clásicos para no
174/// romper los call-sites por literal; las capacidades nuevas (submenús,
175/// animación, hover) viajan aparte en [`ContextMenuExtras`] vía
176/// [`context_menu_view_ex`].
177pub struct ContextMenuSpec<Msg: Clone + 'static> {
178    pub anchor: (f32, f32),
179    pub viewport: (f32, f32),
180    pub header: Option<String>,
181    pub items: Vec<ContextMenuItem>,
182    /// Índice resaltado por keyboard. `usize::MAX` = ninguno.
183    pub active: usize,
184    /// Click en un item de nivel raíz (índice).
185    pub on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync>,
186    /// Msg al click-fuera (scrim) o Esc.
187    pub on_dismiss: Msg,
188    pub palette: ContextMenuPalette,
189}
190
191/// Capacidades extra opcionales para [`context_menu_view_ex`]: submenús
192/// (flyout), animación de aparición y hover. Su `Default` reproduce el
193/// menú clásico (sin animación ni submenús).
194pub struct ContextMenuExtras<Msg: Clone + 'static> {
195    /// Índice del item-submenú desplegado (flyout). La app lo guarda y lo
196    /// actualiza vía `on_hover`.
197    pub open_sub: Option<usize>,
198    /// Progreso de aparición 0..1 (fade + leve slide). `1.0` = fijo.
199    pub appear: f32,
200    /// Click en un item de submenú: `(parent_idx, child_idx)`.
201    pub on_pick_sub: Option<Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>>,
202    /// Hover sobre un item raíz: `Some(idx)` si es submenú (abrir flyout),
203    /// `None` si es item normal (cerrar). La app guarda el resultado en
204    /// `open_sub`.
205    pub on_hover: Option<Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>>,
206}
207
208impl<Msg: Clone + 'static> Default for ContextMenuExtras<Msg> {
209    fn default() -> Self {
210        Self {
211            open_sub: None,
212            appear: 1.0,
213            on_pick_sub: None,
214            on_hover: None,
215        }
216    }
217}
218
219const PANEL_W: f32 = 252.0;
220/// Altura de cada item (no-separator).
221const ITEM_H: f32 = 32.0;
222const SEP_H: f32 = 11.0;
223const HEADER_H: f32 = 26.0;
224/// Gutter del ícono a la izquierda del label.
225const ICON_W: f32 = 24.0;
226const ITEM_PAD_LEFT: f32 = 10.0;
227const ITEM_PAD_RIGHT: f32 = 12.0;
228/// Radio de las esquinas del panel (estilo webpage).
229const PANEL_RADIUS: f32 = 10.0;
230/// Radio de la píldora de hover/activo de cada fila.
231const ITEM_RADIUS: f32 = 6.0;
232/// Padding interno del panel (entre el borde y la columna de píldoras).
233const PANEL_PAD: f32 = 6.0;
234/// Ancho del indicador accent vertical de la fila activa.
235const INDICATOR_W: f32 = 3.0;
236/// Desplazamiento vertical de entrada (px) cuando `appear` = 0.
237const APPEAR_SLIDE: f32 = 8.0;
238
239/// Compone el menú clásico (sin submenús ni animación) como `View<Msg>`
240/// para `App::view_overlay`. Íconos, centrado vertical y separadores ya
241/// vienen incluidos.
242pub fn context_menu_view<Msg: Clone + 'static>(spec: ContextMenuSpec<Msg>) -> View<Msg> {
243    context_menu_view_ex(spec, ContextMenuExtras::default())
244}
245
246/// Como [`context_menu_view`] pero con [`ContextMenuExtras`]: submenús
247/// (flyout en hover), animación de aparición y hover.
248pub fn context_menu_view_ex<Msg: Clone + 'static>(
249    spec: ContextMenuSpec<Msg>,
250    extras: ContextMenuExtras<Msg>,
251) -> View<Msg> {
252    let ContextMenuSpec {
253        anchor,
254        viewport,
255        header,
256        items,
257        active,
258        on_pick,
259        on_dismiss,
260        palette,
261    } = spec;
262    let ContextMenuExtras {
263        open_sub,
264        appear,
265        on_pick_sub,
266        on_hover,
267    } = extras;
268
269    let appear = appear.clamp(0.0, 1.0);
270    let slide = (1.0 - appear) * APPEAR_SLIDE;
271
272    let (panel, panel_x, panel_y) = panel_view(
273        anchor,
274        viewport,
275        &header,
276        &items,
277        active,
278        slide,
279        &on_pick,
280        on_hover.as_ref(),
281        &palette,
282    );
283
284    let mut layers: Vec<View<Msg>> = vec![panel];
285
286    // Flyout del submenú abierto (sólo si la app provee `on_pick_sub`).
287    if let (Some(pidx), Some(on_pick_sub)) = (open_sub, on_pick_sub.as_ref()) {
288        if let Some(parent) = items.get(pidx).filter(|it| it.has_submenu()) {
289            let sub_anchor = submenu_anchor(panel_x, panel_y, &header, &items, pidx);
290            let flyout = submenu_view(
291                sub_anchor,
292                viewport,
293                pidx,
294                &parent.children,
295                slide,
296                on_pick_sub,
297                &palette,
298            );
299            layers.push(flyout);
300        }
301    }
302
303    // Scrim full-screen: cualquier click "fuera" dismissa.
304    View::new(Style {
305        size: Size {
306            width: percent(1.0_f32),
307            height: percent(1.0_f32),
308        },
309        ..Default::default()
310    })
311    .fill(palette.scrim)
312    .alpha(appear)
313    .on_click(on_dismiss)
314    .children(layers)
315}
316
317/// Arma el panel raíz y devuelve `(view, x, y)` ya clampeados.
318#[allow(clippy::too_many_arguments)]
319fn panel_view<Msg: Clone + 'static>(
320    anchor: (f32, f32),
321    viewport: (f32, f32),
322    header: &Option<String>,
323    items: &[ContextMenuItem],
324    active: usize,
325    slide: f32,
326    on_pick: &Arc<dyn Fn(usize) -> Msg + Send + Sync>,
327    on_hover: Option<&Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>>,
328    palette: &ContextMenuPalette,
329) -> (View<Msg>, f32, f32) {
330    let header_h = if header.is_some() { HEADER_H } else { 0.0 };
331    let items_h: f32 = items
332        .iter()
333        .map(|it| if it.separator { SEP_H } else { ITEM_H })
334        .sum();
335    // borde (1+1) + padding interno (PANEL_PAD ×2) + header + items.
336    let panel_h = 2.0 + 2.0 * PANEL_PAD + header_h + items_h;
337
338    let margin = 4.0;
339    let x = anchor
340        .0
341        .min((viewport.0 - PANEL_W - margin).max(margin))
342        .max(margin);
343    let y = anchor
344        .1
345        .min((viewport.1 - panel_h - margin).max(margin))
346        .max(margin);
347
348    let mut children: Vec<View<Msg>> = Vec::with_capacity(items.len() + 1);
349    if let Some(text) = header {
350        children.push(header_view(text.clone(), palette));
351    }
352    for (i, item) in items.iter().enumerate() {
353        children.push(item_view(
354            i,
355            None,
356            item,
357            i == active,
358            on_pick,
359            on_hover,
360            palette,
361        ));
362    }
363
364    let panel = panel_container(x, y + slide, panel_h, children, palette);
365    (panel, x, y)
366}
367
368/// Flyout del submenú: mismo look, posicionado a la derecha del padre.
369#[allow(clippy::too_many_arguments)]
370fn submenu_view<Msg: Clone + 'static>(
371    anchor: (f32, f32),
372    viewport: (f32, f32),
373    parent_idx: usize,
374    children_items: &[ContextMenuItem],
375    slide: f32,
376    on_pick_sub: &Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>,
377    palette: &ContextMenuPalette,
378) -> View<Msg> {
379    let panel_h: f32 = children_items
380        .iter()
381        .map(|it| if it.separator { SEP_H } else { ITEM_H })
382        .sum::<f32>()
383        + 2.0
384        + 2.0 * PANEL_PAD;
385    let margin = 4.0;
386    let x = anchor
387        .0
388        .min((viewport.0 - PANEL_W - margin).max(margin))
389        .max(margin);
390    let y = anchor
391        .1
392        .min((viewport.1 - panel_h - margin).max(margin))
393        .max(margin);
394
395    let mut children: Vec<View<Msg>> = Vec::with_capacity(children_items.len());
396    for (j, item) in children_items.iter().enumerate() {
397        children.push(item_view(
398            j,
399            Some((parent_idx, on_pick_sub.clone())),
400            item,
401            false,
402            // on_pick raíz no se usa cuando hay parent; pasamos un dummy.
403            &dummy_pick(),
404            None,
405            palette,
406        ));
407    }
408    panel_container(x, y + slide, panel_h, children, palette)
409}
410
411/// El contenedor visual: panel redondeado con borde hairline (un nodo
412/// exterior del color del borde + uno interior con el gradiente del
413/// PanelStyle) y padding interno para que las píldoras de cada fila
414/// queden inset — el look de menú de webpage.
415fn panel_container<Msg: Clone + 'static>(
416    x: f32,
417    y: f32,
418    panel_h: f32,
419    children: Vec<View<Msg>>,
420    palette: &ContextMenuPalette,
421) -> View<Msg> {
422    View::new(Style {
423        position: Position::Absolute,
424        inset: Rect {
425            left: length(x),
426            top: length(y),
427            right: auto(),
428            bottom: auto(),
429        },
430        size: Size {
431            width: length(PANEL_W),
432            height: length(panel_h),
433        },
434        padding: Rect {
435            left: length(1.0_f32),
436            right: length(1.0_f32),
437            top: length(1.0_f32),
438            bottom: length(1.0_f32),
439        },
440        ..Default::default()
441    })
442    .fill(palette.border)
443    .radius(palette.radius as f64)
444    .children(vec![View::new(Style {
445        flex_direction: FlexDirection::Column,
446        flex_grow: 1.0,
447        size: Size {
448            width: percent(1.0_f32),
449            height: percent(1.0_f32),
450        },
451        padding: Rect {
452            left: length(PANEL_PAD),
453            right: length(PANEL_PAD),
454            top: length(PANEL_PAD),
455            bottom: length(PANEL_PAD),
456        },
457        ..Default::default()
458    })
459    .radius((palette.radius - 1.0) as f64)
460    .paint_with(panel_signature_painter(palette.panel))
461    .children(children)])
462}
463
464/// Ancla del flyout: a la derecha del panel padre, alineado al item.
465fn submenu_anchor(
466    panel_x: f32,
467    panel_y: f32,
468    header: &Option<String>,
469    items: &[ContextMenuItem],
470    parent_idx: usize,
471) -> (f32, f32) {
472    let mut off = if header.is_some() { HEADER_H } else { 0.0 };
473    off += 1.0 + PANEL_PAD; // borde + padding interno del contenedor
474    for it in items.iter().take(parent_idx) {
475        off += if it.separator { SEP_H } else { ITEM_H };
476    }
477    // pequeño solape para que el flyout se lea continuo con el padre.
478    (panel_x + PANEL_W - PANEL_PAD, panel_y + off)
479}
480
481fn header_view<Msg: Clone + 'static>(text: String, palette: &ContextMenuPalette) -> View<Msg> {
482    View::new(Style {
483        size: Size {
484            width: percent(1.0_f32),
485            height: length(HEADER_H),
486        },
487        padding: Rect {
488            left: length(ITEM_PAD_LEFT + INDICATOR_W + ICON_W + 4.0),
489            right: length(ITEM_PAD_RIGHT),
490            top: length(2.0_f32),
491            bottom: length(0.0_f32),
492        },
493        align_items: Some(AlignItems::Center),
494        ..Default::default()
495    })
496    .text_aligned(text.to_uppercase(), 9.5, palette.fg_header, Alignment::Start)
497}
498
499/// Pinta una fila. Si `parent` es `Some((pidx, cb))`, es un item de
500/// submenú y clickea vía `cb(pidx, idx)`; si es `None`, es raíz y usa
501/// `on_pick(idx)` + (si corresponde) `on_hover` para abrir su flyout.
502#[allow(clippy::too_many_arguments)]
503fn item_view<Msg: Clone + 'static>(
504    idx: usize,
505    parent: Option<(usize, Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>)>,
506    item: &ContextMenuItem,
507    is_active: bool,
508    on_pick: &Arc<dyn Fn(usize) -> Msg + Send + Sync>,
509    on_hover: Option<&Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>>,
510    palette: &ContextMenuPalette,
511) -> View<Msg> {
512    if item.separator {
513        return separator_view(palette);
514    }
515
516    // Color del texto y del atajo según estado.
517    let (fg, fg_dim): (Color, Color) = if !item.enabled {
518        (palette.fg_disabled, palette.fg_disabled)
519    } else if item.destructive {
520        (palette.fg_destructive, palette.fg_shortcut)
521    } else if is_active {
522        (palette.fg_active, palette.fg_active)
523    } else {
524        (palette.fg_text, palette.fg_shortcut)
525    };
526    // Ícono: accent cuando la fila está activa (cue del menú), si no
527    // apagado.
528    let icon_fg = if !item.enabled {
529        palette.fg_disabled
530    } else if is_active {
531        palette.accent
532    } else {
533        palette.fg_icon
534    };
535
536    // Indicador accent vertical a la izquierda — visible sólo en la fila
537    // activa; reserva su ancho siempre para que el texto no salte.
538    let indicator = View::new(Style {
539        size: Size {
540            width: length(INDICATOR_W),
541            height: percent(0.55_f32),
542        },
543        flex_shrink: 0.0,
544        ..Default::default()
545    });
546    let indicator = if is_active && item.enabled {
547        indicator.fill(palette.accent).radius(2.0)
548    } else {
549        indicator
550    };
551
552    // Gutter de ícono — auto height para que el row lo centre vertical.
553    let icon_cell = View::new(Style {
554        size: Size {
555            width: length(ICON_W),
556            height: auto(),
557        },
558        flex_shrink: 0.0,
559        align_items: Some(AlignItems::Center),
560        justify_content: Some(JustifyContent::Center),
561        ..Default::default()
562    })
563    .text_aligned(item.icon.clone().unwrap_or_default(), 13.0, icon_fg, Alignment::Center);
564
565    // Label — auto height (lo centra el align_items Center del row).
566    let label = View::new(Style {
567        size: Size {
568            width: auto(),
569            height: auto(),
570        },
571        flex_grow: 1.0,
572        ..Default::default()
573    })
574    .text_aligned(item.label.clone(), 12.5, fg, Alignment::Start);
575
576    // Cola: chevron de submenú o atajo de teclado.
577    let trailing_text = if item.has_submenu() {
578        Some(("\u{203A}".to_string(), fg)) // ›
579    } else {
580        item.shortcut.clone().map(|s| (s, fg_dim))
581    };
582    let mut row_children: Vec<View<Msg>> = vec![indicator, icon_cell, label];
583    if let Some((txt, color)) = trailing_text {
584        row_children.push(
585            View::new(Style {
586                size: Size {
587                    width: length(64.0_f32),
588                    height: auto(),
589                },
590                flex_shrink: 0.0,
591                ..Default::default()
592            })
593            .text_aligned(txt, 11.0, color, Alignment::End),
594        );
595    }
596
597    let mut row = View::new(Style {
598        size: Size {
599            width: percent(1.0_f32),
600            height: length(ITEM_H),
601        },
602        flex_direction: FlexDirection::Row,
603        padding: Rect {
604            left: length(ITEM_PAD_LEFT),
605            right: length(ITEM_PAD_RIGHT),
606            top: length(0.0_f32),
607            bottom: length(0.0_f32),
608        },
609        gap: Size {
610            width: length(2.0_f32),
611            height: length(0.0_f32),
612        },
613        align_items: Some(AlignItems::Center),
614        ..Default::default()
615    })
616    .radius(ITEM_RADIUS as f64)
617    // Semántica del ítem: rol MenuItem + label visible. `disabled` se
618    // refleja del `enabled` invertido. AccessKit lo expone como
619    // navegable por TTS dentro del menú.
620    .role(llimphi_ui::Role::MenuItem)
621    .aria_label(item.label.clone())
622    .aria_disabled(!item.enabled)
623    .children(row_children);
624
625    // Fondo: píldora suave en activo (teclado). El hover lo aporta
626    // `hover_fill` (tinte aún más suave) para no competir con el activo.
627    if is_active && item.enabled {
628        row = row.fill(palette.bg_active);
629    }
630
631    if item.enabled {
632        row = row.hover_fill(palette.bg_hover);
633        match &parent {
634            Some((pidx, cb)) => {
635                let cb = cb.clone();
636                let pidx = *pidx;
637                row = row.on_click_at(move |_, _, _, _| Some(cb(pidx, idx)));
638            }
639            None => {
640                let on_pick = on_pick.clone();
641                row = row.on_click_at(move |_, _, _, _| Some(on_pick(idx)));
642                // Hover abre/cierra el flyout según sea submenú o no.
643                if let Some(on_hover) = on_hover {
644                    let on_hover = on_hover.clone();
645                    let target = if item.has_submenu() { Some(idx) } else { None };
646                    row = row.on_pointer_enter(on_hover(target));
647                }
648            }
649        }
650    }
651    row
652}
653
654fn separator_view<Msg: Clone + 'static>(palette: &ContextMenuPalette) -> View<Msg> {
655    View::new(Style {
656        size: Size {
657            width: percent(1.0_f32),
658            height: length(SEP_H),
659        },
660        flex_direction: FlexDirection::Column,
661        justify_content: Some(JustifyContent::Center),
662        align_items: Some(AlignItems::Center),
663        padding: Rect {
664            left: length(ITEM_PAD_LEFT),
665            right: length(ITEM_PAD_RIGHT),
666            top: length(0.0_f32),
667            bottom: length(0.0_f32),
668        },
669        ..Default::default()
670    })
671    .children(vec![View::new(Style {
672        size: Size {
673            width: percent(1.0_f32),
674            height: length(1.0_f32),
675        },
676        ..Default::default()
677    })
678    .fill(palette.separator)])
679}
680
681/// `on_pick` dummy para los items de submenú (que usan `on_pick_sub`).
682/// Nunca se invoca: `item_view` con `parent=Some` ignora `on_pick`.
683fn dummy_pick<Msg: Clone + 'static>() -> Arc<dyn Fn(usize) -> Msg + Send + Sync> {
684    Arc::new(|_| unreachable!("submenu item usa on_pick_sub, no on_pick"))
685}
686
687/// Navegación por teclado: dado el activo + dirección (`+1`/`-1`), el
688/// siguiente índice válido (saltea separators y disabled). `usize::MAX`
689/// si no hay elegibles.
690pub fn step_active(items: &[ContextMenuItem], current: usize, direction: i32) -> usize {
691    if items.is_empty() {
692        return usize::MAX;
693    }
694    let n = items.len() as i32;
695    let start = if current == usize::MAX {
696        if direction >= 0 {
697            -1
698        } else {
699            n
700        }
701    } else {
702        current as i32
703    };
704    let mut i = start;
705    for _ in 0..n {
706        i += direction;
707        if i < 0 {
708            i = n - 1;
709        } else if i >= n {
710            i = 0;
711        }
712        let item = &items[i as usize];
713        if !item.separator && item.enabled {
714            return i as usize;
715        }
716    }
717    usize::MAX
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    fn it(label: &str) -> ContextMenuItem {
725        ContextMenuItem::action(label)
726    }
727
728    #[test]
729    fn step_active_skips_separators() {
730        let items = vec![it("A"), ContextMenuItem::separator(), it("B"), it("C")];
731        assert_eq!(step_active(&items, 0, 1), 2);
732        assert_eq!(step_active(&items, 2, -1), 0);
733    }
734
735    #[test]
736    fn step_active_skips_disabled() {
737        let items = vec![it("A"), it("B").disabled(), it("C")];
738        assert_eq!(step_active(&items, 0, 1), 2);
739        assert_eq!(step_active(&items, 2, -1), 0);
740    }
741
742    #[test]
743    fn step_active_wraps_around() {
744        let items = vec![it("A"), it("B"), it("C")];
745        assert_eq!(step_active(&items, 2, 1), 0);
746        assert_eq!(step_active(&items, 0, -1), 2);
747    }
748
749    #[test]
750    fn submenu_y_icono_se_setean() {
751        let item = it("Tema")
752            .icon("◐")
753            .submenu(vec![it("Oscuro"), it("Claro")]);
754        assert!(item.has_submenu());
755        assert_eq!(item.children.len(), 2);
756        assert_eq!(item.icon.as_deref(), Some("◐"));
757    }
758
759    #[test]
760    fn extras_default_es_menu_clasico() {
761        let extras: ContextMenuExtras<u8> = ContextMenuExtras::default();
762        assert_eq!(extras.appear, 1.0);
763        assert!(extras.open_sub.is_none());
764        assert!(extras.on_hover.is_none());
765        assert!(extras.on_pick_sub.is_none());
766    }
767}