tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Definiciones

⎇ main widgets/select/src/lib.rs Rust · 1245 líneas · 47.0 KB Historia Quién tocó qué crudo

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

1//! `llimphi-widget-select` — control *select* / dropdown moderno.
2//!
3//! Modelado como un **gradiente de complejidad**: la misma pieza sirve
4//! desde el select más tonto hasta uno con carga asíncrona y badges, sin
5//! cambiar de widget — sólo se van encendiendo capas.
6//!
7//! ```text
8//!   ┌──────────────────────────────┐
9//!   │ ◷  Pendiente            ●  ▾ │   ← disparador (cerrado)
10//!   └──────────────────────────────┘
11//!        │ click → view_overlay
12//!        ▼
13//!   ┃ ⌕ Buscar…                     ← caja de búsqueda (capa 2)
14//!   ┃ ✓ ◷  Pendiente        12      ← ítem rico: check · icono · label · badge (capa 3)
15//!   ┃   ◔  En curso          3
16//!   ┃   ◉  Hecho             ·       ← badge dot
17//!   ┃ ─────────────────────
18//!   ┃ ⟳  Cargando…                  ← estado async (capa 4): Loading / Error+reintento / vacío
19//! ```
20//!
21//! ## Las cuatro capas
22//!
23//! 1. **Select simple.** [`SelectItem::new`] + [`select_trigger_view`] (el
24//!    botón cerrado que muestra lo elegido) + [`select_menu_view`] con
25//!    [`SelectPhase::Ready`] montado en [`App::view_overlay`]. `on_pick(idx)`
26//!    devuelve el índice **original** del ítem elegido; la app lo guarda y
27//!    cierra el menú. Eso es todo.
28//! 2. **Buscable (combobox).** Pon `searchable: true`. La caja de arriba
29//!    pinta `query`; el tecleo lo rutea la app por `on_key` a un
30//!    `TextInputState` y re-filtra con [`filter`]. [`step_active`] mueve el
31//!    resaltado con ↑/↓ saltando deshabilitados; [`resolve`] traduce la
32//!    posición resaltada al índice original para Enter.
33//! 3. **Ítems ricos + badges.** Cada [`SelectItem`] lleva `icon`, `sublabel`
34//!    y un [`SelectBadge`] (conteo `12`, chip de texto `beta`, o dot de
35//!    estado) — reusa `llimphi-widget-badge`. La selección múltiple sale
36//!    sola: pasa varios índices en `selected` y el menú pinta el check ✓ en
37//!    cada uno (la semántica toggle/cerrar la decide la app).
38//! 4. **Carga asíncrona.** El menú no exige tener los datos: [`SelectPhase`]
39//!    distingue `Loading` (fila "Cargando…"), `Error` (mensaje + acción
40//!    `on_retry`) y `Ready`. El patrón: la app abre el menú, lanza
41//!    `Handle::spawn`, pinta `Loading`, y al volver el worker cambia a
42//!    `Ready`. Una *generación* (`u64`) descarta respuestas viejas si el
43//!    usuario reabrió/retecleó — ver el ejemplo `select_demo`.
44//!
45//! El look es el de `context-menu`: panel redondeado con borde hairline,
46//! filas como píldoras con hover suave, indicador accent en la fila activa,
47//! scrim full-screen que dismissa al click-fuera.
48
49#![forbid(unsafe_code)]
50
51
52// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
53// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
54// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
55#![no_std]
56
57extern crate alloc;
58
59use alloc::boxed::Box;
60use alloc::string::String;
61use alloc::vec::Vec;
62use alloc::vec;
63use alloc::string::ToString;
64use alloc::sync::Arc;
65
66use llimphi_compositor::llimphi_layout::taffy::{
67    prelude::{auto, length, percent, FlexDirection, Position, Size, Style},
68    AlignItems, JustifyContent, Rect,
69};
70use llimphi_compositor::peniko::{Color, ImageBrush as Image};
71use llimphi_compositor::llimphi_text::Alignment;
72use llimphi_compositor::View;
73use llimphi_widget_badge::{count_badge_view, dot_badge_view};
74use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
75
76/// Re-export: el badge semántico se elige con `BadgeKind`, así el consumidor
77/// no necesita depender de `llimphi-widget-badge` directamente.
78pub use llimphi_widget_badge::BadgeKind;
79
80// ─────────────────────────────────────────────────────────────────────────
81// Paleta
82// ─────────────────────────────────────────────────────────────────────────
83
84/// Paleta del select, derivada del `Theme`. El disparador se lee como un
85/// input; el menú flotante como un panel elevado (igual que `context-menu`).
86#[derive(Debug, Clone, Copy)]
87pub struct SelectPalette {
88    pub bg_trigger: Color,
89    pub bg_trigger_hover: Color,
90    pub bg_panel: Color,
91    pub bg_hover: Color,
92    pub bg_active: Color,
93    pub fg_text: Color,
94    pub fg_active: Color,
95    pub fg_muted: Color,
96    pub fg_placeholder: Color,
97    pub fg_destructive: Color,
98    pub accent: Color,
99    pub border: Color,
100    pub border_focus: Color,
101    pub separator: Color,
102    pub scrim: Color,
103    pub radius: f64,
104    pub panel: PanelStyle,
105}
106
107impl SelectPalette {
108    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
109        let mut panel = PanelStyle::neutral(t);
110        panel.bg_base = t.bg_panel;
111        panel.radius = PANEL_RADIUS as f64;
112        Self {
113            bg_trigger: t.bg_input,
114            bg_trigger_hover: t.bg_input_focus,
115            bg_panel: t.bg_panel,
116            bg_hover: t.bg_row_hover,
117            bg_active: t.bg_selected,
118            fg_text: t.fg_text,
119            fg_active: t.fg_text,
120            fg_muted: t.fg_muted,
121            fg_placeholder: t.fg_placeholder,
122            fg_destructive: t.fg_destructive,
123            accent: t.accent,
124            border: t.border,
125            border_focus: t.border_focus,
126            separator: t.border,
127            scrim: Color::from_rgba8(0, 0, 0, 64),
128            radius: PANEL_RADIUS as f64,
129            panel,
130        }
131    }
132}
133
134// ─────────────────────────────────────────────────────────────────────────
135// Badges
136// ─────────────────────────────────────────────────────────────────────────
137
138/// Badge opcional al final de un ítem. Tres formas, todas sobre la paleta
139/// semántica de `llimphi-widget-badge`:
140/// - [`SelectBadge::count`] — chip ovalado con número (`12`, `99+`).
141/// - [`SelectBadge::label`] — chip de texto corto (`beta`, `nuevo`).
142/// - [`SelectBadge::dot`] — punto de estado sin texto (online/idle/…).
143#[derive(Debug, Clone)]
144pub struct SelectBadge {
145    pub kind: BadgeKind,
146    /// Texto del chip. `Some` → chip de texto; `None` con `count` → conteo;
147    /// ambos `None` → dot.
148    pub label: Option<String>,
149    pub count: Option<u32>,
150}
151
152impl SelectBadge {
153    pub fn count(n: u32, kind: BadgeKind) -> Self {
154        Self { kind, label: None, count: Some(n) }
155    }
156    pub fn label(text: impl Into<String>, kind: BadgeKind) -> Self {
157        Self { kind, label: Some(text.into()), count: None }
158    }
159    pub fn dot(kind: BadgeKind) -> Self {
160        Self { kind, label: None, count: None }
161    }
162
163    fn view<Msg: Clone + 'static>(&self) -> View<Msg> {
164        match (&self.label, self.count) {
165            (Some(text), _) => label_chip_view(text, self.kind),
166            (None, Some(n)) => count_badge_view(n, self.kind),
167            (None, None) => dot_badge_view(self.kind),
168        }
169    }
170}
171
172/// Chip de texto corto (no existe en `llimphi-widget-badge`, que sólo da
173/// count y dot). Mismo lenguaje visual: bg sólido semántico + texto claro.
174fn label_chip_view<Msg: Clone + 'static>(text: &str, kind: BadgeKind) -> View<Msg> {
175    // Ancho por glifos reales (no `chars*const` sobre fuente proporcional).
176    let w = (llimphi_compositor::llimphi_text::text_width(text, 10.0) + 14.0).max(18.0);
177    View::new(Style {
178        size: Size { width: length(w), height: length(16.0_f32) },
179        align_items: Some(AlignItems::CENTER),
180        justify_content: Some(JustifyContent::CENTER),
181        flex_shrink: 0.0,
182        ..Default::default()
183    })
184    .fill(kind.bg())
185    .radius(4.0)
186    .text_aligned(text.to_string(), 10.0, kind.fg(), Alignment::Center)
187}
188
189// ─────────────────────────────────────────────────────────────────────────
190// Ítems
191// ─────────────────────────────────────────────────────────────────────────
192
193/// Una opción del select. `icon`/`sublabel`/`badge` son opcionales: un
194/// select simple sólo setea `label`.
195#[derive(Debug, Clone)]
196pub struct SelectItem {
197    pub label: String,
198    pub sublabel: Option<String>,
199    pub icon: Option<String>,
200    /// Imagen del gutter izquierdo (avatar, favicon, thumbnail…). Gana sobre el
201    /// glifo [`Self::icon`] si ambos están. Decodificada (peniko::Image).
202    pub image: Option<Image>,
203    pub badge: Option<SelectBadge>,
204    pub enabled: bool,
205}
206
207impl SelectItem {
208    pub fn new(label: impl Into<String>) -> Self {
209        Self {
210            label: label.into(),
211            sublabel: None,
212            icon: None,
213            image: None,
214            badge: None,
215            enabled: true,
216        }
217    }
218    /// Segunda línea, más apagada y chica (ej. descripción, id, ruta).
219    pub fn with_sublabel(mut self, sub: impl Into<String>) -> Self {
220        self.sublabel = Some(sub.into());
221        self
222    }
223    /// Glifo del gutter izquierdo (unicode; no acopla a `llimphi-icons`).
224    pub fn icon(mut self, glyph: impl Into<String>) -> Self {
225        self.icon = Some(glyph.into());
226        self
227    }
228    /// Imagen del gutter izquierdo (avatar/thumbnail). Gana sobre el glifo.
229    pub fn with_image(mut self, image: Image) -> Self {
230        self.image = Some(image);
231        self
232    }
233    pub fn badge(mut self, badge: SelectBadge) -> Self {
234        self.badge = Some(badge);
235        self
236    }
237    pub fn disabled(mut self) -> Self {
238        self.enabled = false;
239        self
240    }
241
242    fn row_height(&self) -> f32 {
243        if self.sublabel.is_some() {
244            ITEM_H_SUB
245        } else {
246            ITEM_H
247        }
248    }
249}
250
251// ─────────────────────────────────────────────────────────────────────────
252// Fase async
253// ─────────────────────────────────────────────────────────────────────────
254
255/// En qué punto está el contenido del menú. La app lo deriva de su estado
256/// de carga (`Handle::spawn` → resultado). Toma prestados los ítems para no
257/// clonar la lista por frame.
258pub enum SelectPhase<'a> {
259    /// Trabajo en vuelo: el menú pinta una fila "Cargando…".
260    Loading,
261    /// Falló: mensaje + (si hay `on_retry`) acción de reintento.
262    Error(&'a str),
263    /// Listo: estos son los ítems (todos; `visible` dice cuáles tras filtro).
264    Ready(&'a [SelectItem]),
265}
266
267// ─────────────────────────────────────────────────────────────────────────
268// Constantes de layout
269// ─────────────────────────────────────────────────────────────────────────
270
271const PANEL_RADIUS: f32 = 10.0;
272const ITEM_RADIUS: f32 = 6.0;
273const PANEL_PAD: f32 = 6.0;
274const INDICATOR_W: f32 = 3.0;
275const CHECK_W: f32 = 18.0;
276const ICON_W: f32 = 22.0;
277const ITEM_H: f32 = 34.0;
278const ITEM_H_SUB: f32 = 46.0;
279const STATUS_H: f32 = 40.0;
280const SEARCH_H: f32 = 34.0;
281const ITEM_PAD_LEFT: f32 = 8.0;
282const ITEM_PAD_RIGHT: f32 = 10.0;
283const TRIGGER_H: f32 = 34.0;
284const APPEAR_SLIDE: f32 = 8.0;
285
286// ─────────────────────────────────────────────────────────────────────────
287// Disparador (estado cerrado)
288// ─────────────────────────────────────────────────────────────────────────
289
290/// El botón cerrado del select. Muestra el ítem elegido (icono + label +
291/// badge) o el `placeholder` si no hay nada. El chevron ▾/▴ refleja `open`.
292/// Click emite `on_toggle` (la app alterna su flag de menú abierto).
293pub fn select_trigger_view<Msg, F>(
294    selected: Option<&SelectItem>,
295    placeholder: &str,
296    open: bool,
297    width: Option<f32>,
298    palette: &SelectPalette,
299    on_toggle: F,
300) -> View<Msg>
301where
302    Msg: Clone + 'static,
303    // El disparador se cablea con `on_click_screen`: al abrir, el handler recibe
304    // el RECT ABSOLUTO del disparador `(x, y, w, h)` en coords de ventana, para
305    // anclar el menú a su borde inferior y copiar su ancho. Callers que no lo
306    // necesiten ignoran los args.
307    F: Fn(f32, f32, f32, f32) -> Msg + Send + Sync + 'static,
308{
309    let size_width = match width {
310        Some(w) => length(w),
311        None => percent(1.0_f32),
312    };
313
314    // Disparador **compacto**: sin valor elegido y sin placeholder no hay nada
315    // que rotular — es un botón de chevron a secas. Lo usa cada fila del árbol
316    // del sidebar de wawapanel, donde el valor ya se lee en la propia fila.
317    // Con la fila normal el chevron queda contra el canto derecho (los 10+8 px
318    // de padding más el hueco flexible del rótulo lo empujan), y en un ancho de
319    // 38 px eso se ve como una caja con la flecha descentrada. Compacto, el
320    // chevron es el único hijo y va centrado en la caja.
321    let compacto = selected.is_none() && placeholder.is_empty();
322
323    let mut row: Vec<View<Msg>> = Vec::with_capacity(4);
324
325    if compacto {
326        // Sin rótulo ni hueco: abajo va sólo el chevron, centrado.
327    } else if let Some(item) = selected {
328        if let Some(g) = gutter_view(item, palette.fg_muted) {
329            row.push(g);
330        }
331        row.push(
332            View::new(Style {
333                size: Size { width: auto(), height: auto() },
334                flex_grow: 1.0,
335                ..Default::default()
336            })
337            .text_aligned(item.label.clone(), 13.0, palette.fg_text, Alignment::Start),
338        );
339        if let Some(badge) = &item.badge {
340            row.push(badge.view());
341        }
342    } else {
343        row.push(
344            View::new(Style {
345                size: Size { width: auto(), height: auto() },
346                flex_grow: 1.0,
347                ..Default::default()
348            })
349            .text_aligned(
350                placeholder.to_string(),
351                13.0,
352                palette.fg_placeholder,
353                Alignment::Start,
354            ),
355        );
356    }
357
358    // Chevron — gira según abierto/cerrado.
359    let chevron = if open { "\u{25B4}" } else { "\u{25BE}" }; // ▴ / ▾
360    row.push(
361        View::new(Style {
362            size: Size {
363                // Compacto: el chevron toma la caja entera y queda centrado en
364                // ella. Normal: celda fija contra el canto derecho.
365                width: if compacto { percent(1.0_f32) } else { length(16.0_f32) },
366                height: auto(),
367            },
368            flex_shrink: 0.0,
369            align_items: Some(AlignItems::CENTER),
370            justify_content: Some(JustifyContent::CENTER),
371            ..Default::default()
372        })
373        .text_aligned(chevron.to_string(), 11.0, palette.fg_muted, Alignment::Center),
374    );
375
376    let (bg, border) = if open {
377        (palette.bg_trigger_hover, palette.border_focus)
378    } else {
379        (palette.bg_trigger, palette.border)
380    };
381
382    // a11y: el disparador es un combobox → lo anunciamos como botón con el
383    // valor elegido (o el placeholder) y su estado abierto/cerrado.
384    let aria = selected
385        .map(|it| it.label.clone())
386        .unwrap_or_else(|| placeholder.to_string());
387
388    // Borde de 1px (rect padre coloreado) + relleno interno con la fila.
389    View::new(Style {
390        size: Size { width: size_width, height: length(TRIGGER_H + 2.0) },
391        padding: Rect {
392            left: length(1.0_f32),
393            right: length(1.0_f32),
394            top: length(1.0_f32),
395            bottom: length(1.0_f32),
396        },
397        ..Default::default()
398    })
399    .fill(border)
400    .radius(7.0)
401    .role(llimphi_compositor::Role::Button)
402    .aria_label(aria)
403    .aria_pressed(open)
404    .on_click_screen(move |x, y, w, h| Some(on_toggle(x, y, w, h)))
405    .children(vec![View::new(Style {
406        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
407        flex_direction: FlexDirection::Row,
408        align_items: Some(AlignItems::CENTER),
409        padding: Rect {
410            left: length(if compacto { 0.0 } else { 10.0 }),
411            right: length(if compacto { 0.0 } else { 8.0 }),
412            top: length(0.0_f32),
413            bottom: length(0.0_f32),
414        },
415        gap: Size {
416            width: length(if compacto { 0.0 } else { 6.0 }),
417            height: length(0.0_f32),
418        },
419        ..Default::default()
420    })
421    .fill(bg)
422    .radius(6.0)
423    .hover_fill(palette.bg_trigger_hover)
424    .children(row)])
425}
426
427// ─────────────────────────────────────────────────────────────────────────
428// Menú flotante (estado abierto) — para App::view_overlay
429// ─────────────────────────────────────────────────────────────────────────
430
431/// Especificación del menú desplegado. Todo lo prestado (`phase`, `visible`,
432/// `selected`, `query`, `palette`) vive en el `Model` de la app; el menú no
433/// clona la lista. Los callbacks son `Arc<dyn Fn>` como en `context-menu`.
434pub struct SelectMenuSpec<'a, Msg: Clone + 'static> {
435    /// Esquina superior-izquierda deseada (típico: bajo el disparador).
436    pub anchor: (f32, f32),
437    pub viewport: (f32, f32),
438    pub width: f32,
439    pub phase: SelectPhase<'a>,
440    /// Índices **originales** visibles tras el filtro (ver [`filter`]). En
441    /// `Loading`/`Error` se ignora.
442    pub visible: &'a [usize],
443    /// Posición dentro de `visible` resaltada por teclado/hover.
444    /// `usize::MAX` = ninguna.
445    pub active: usize,
446    /// Índices originales actualmente elegidos (uno para single, varios para
447    /// multi). Pintan check ✓.
448    pub selected: &'a [usize],
449    /// Texto de la caja de búsqueda (si `searchable`).
450    pub query: &'a str,
451    pub searchable: bool,
452    /// Texto cuando `Ready` pero `visible` quedó vacío (filtro sin match).
453    pub empty_text: &'a str,
454    /// Progreso de aparición 0..1 (fade + leve slide). `1.0` = fijo.
455    pub appear: f32,
456    /// Click en un ítem: índice **original**.
457    pub on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync>,
458    /// Hover sobre un ítem: posición dentro de `visible` (para sincronizar
459    /// `active` con el mouse). Opcional.
460    pub on_hover: Option<Arc<dyn Fn(usize) -> Msg + Send + Sync>>,
461    /// Click-fuera (scrim) o Esc.
462    pub on_dismiss: Msg,
463    /// Acción de la fila de error. Si `None`, el error no es accionable.
464    pub on_retry: Option<Msg>,
465    pub palette: &'a SelectPalette,
466}
467
468/// Compone el menú desplegado como `View<Msg>` para [`App::view_overlay`].
469/// Incluye scrim de dismiss, caja de búsqueda (si corresponde) y la fase
470/// async resuelta a filas.
471pub fn select_menu_view<Msg: Clone + 'static>(spec: SelectMenuSpec<'_, Msg>) -> View<Msg> {
472    let appear = spec.appear.clamp(0.0, 1.0);
473    let slide = (1.0 - appear) * APPEAR_SLIDE;
474    let (anchor, viewport, width, palette) = (spec.anchor, spec.viewport, spec.width, spec.palette);
475    let on_dismiss = spec.on_dismiss.clone();
476    let (children, panel_h) = menu_filas(&spec);
477
478    // Clamp al viewport.
479    let margin = 4.0;
480    let x = anchor
481        .0
482        .min((viewport.0 - width - margin).max(margin))
483        .max(margin);
484    let y = anchor
485        .1
486        .min((viewport.1 - panel_h - margin).max(margin))
487        .max(margin);
488
489    let panel = panel_container(Some((x, y + slide)), width, panel_h, children, palette);
490
491    // Scrim full-screen: cualquier click fuera dismissa.
492    View::new(Style {
493        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
494        ..Default::default()
495    })
496    .fill(palette.scrim)
497    .alpha(appear)
498    .on_click(on_dismiss)
499    .children(vec![panel])
500}
501
502/// **El mismo menú, pero EN EL FLUJO**: empuja el contenido de abajo en vez de
503/// flotar sobre él.
504///
505/// Es para quien no tiene overlay donde colgarlo. El caso que lo pidió: el
506/// panel de video del rail de pata vive en una surface de layer-shell con
507/// tamaño propio y con el clip del scroll de su cajón; un menú flotante ahí se
508/// recorta contra ese borde y no se ve.
509///
510/// **Por qué no alcanzaba [`select_menu_view`]**: devuelve un scrim de
511/// `100% × 100%` con el panel `Position::Absolute` adentro. Puesto en el flujo,
512/// ese scrim toma el alto de su contenido, y un hijo absoluto **no aporta
513/// tamaño** — así que la fila que lo hospeda mide ~0 y el panel queda del lado
514/// ciego del recorte. El estado decía «abierto» y en pantalla no pasaba nada:
515/// es el reporte del operador *«los combobox … no se están desplegando»*
516/// (5-oct), que ya había vuelto una vez diagnosticado como repintado.
517///
518/// Diferencias con la variante flotante, todas por la misma razón:
519/// - **sin scrim**: no hay «afuera» que clickear — el click afuera en el flujo
520///   es el click en otra cosa, y cerrar es volver a apretar el disparador
521///   (`on_dismiss` queda sin usar acá);
522/// - **sin anchor ni clamp**: el lugar lo decide el flujo, no coordenadas de
523///   ventana (`anchor` y `viewport` se ignoran);
524/// - el alto del panel es **propio y declarado**, así que lo que lo hospede
525///   crece con él.
526pub fn select_menu_inline_view<Msg: Clone + 'static>(spec: SelectMenuSpec<'_, Msg>) -> View<Msg> {
527    let appear = spec.appear.clamp(0.0, 1.0);
528    let (width, palette) = (spec.width, spec.palette);
529    let (children, panel_h) = menu_filas(&spec);
530    panel_container(None, width, panel_h, children, palette).alpha(appear)
531}
532
533/// Las filas del menú (búsqueda + fase resuelta) y el alto que piden.
534///
535/// Lo comparten las dos variantes: una sola lista de filas para que el menú del
536/// flujo y el flotante no se separen con el tiempo.
537fn menu_filas<Msg: Clone + 'static>(spec: &SelectMenuSpec<'_, Msg>) -> (Vec<View<Msg>>, f32) {
538    let palette = spec.palette;
539    let mut children: Vec<View<Msg>> = Vec::new();
540    let mut body_h = 0.0_f32;
541
542    if spec.searchable {
543        children.push(search_box_view(spec.query, palette));
544        body_h += SEARCH_H + 4.0;
545    }
546
547    match spec.phase {
548        SelectPhase::Loading => {
549            children.push(status_row_view("\u{27F3}", "Cargando…", palette.fg_muted, palette));
550            body_h += STATUS_H;
551        }
552        SelectPhase::Error(msg) => {
553            children.push(error_row_view(msg, spec.on_retry.clone(), palette));
554            body_h += STATUS_H;
555        }
556        SelectPhase::Ready(items) => {
557            if spec.visible.is_empty() {
558                children.push(status_row_view(
559                    "\u{2205}",
560                    spec.empty_text,
561                    palette.fg_muted,
562                    palette,
563                ));
564                body_h += STATUS_H;
565            } else {
566                let is_selected = |orig: usize| spec.selected.contains(&orig);
567                for (pos, &orig) in spec.visible.iter().enumerate() {
568                    if let Some(item) = items.get(orig) {
569                        body_h += item.row_height();
570                        children.push(item_row_view(
571                            pos,
572                            orig,
573                            item,
574                            pos == spec.active,
575                            is_selected(orig),
576                            &spec.on_pick,
577                            spec.on_hover.as_ref(),
578                            palette,
579                        ));
580                    }
581                }
582            }
583        }
584    }
585
586    (children, 2.0 + 2.0 * PANEL_PAD + body_h)
587}
588
589/// Contenedor visual: panel redondeado con borde hairline (nodo exterior
590/// del color de borde + interior con el gradiente del PanelStyle) y padding
591/// para que las píldoras queden inset.
592///
593/// `at` decide de qué lado de la frontera vive: `Some((x, y))` lo saca del flujo
594/// y lo ancla ahí (el menú flotante sobre su scrim), `None` lo deja **en el
595/// flujo**, donde su alto declarado empuja lo que venga abajo
596/// ([`select_menu_inline_view`]). Un panel absoluto en el flujo se pinta fuera
597/// de los límites de su padre y el contenido siguiente lo tapa: medido, de las
598/// cuatro filas del menú de sonido el dedo alcanzaba dos.
599fn panel_container<Msg: Clone + 'static>(
600    at: Option<(f32, f32)>,
601    width: f32,
602    panel_h: f32,
603    children: Vec<View<Msg>>,
604    palette: &SelectPalette,
605) -> View<Msg> {
606    View::new(Style {
607        position: match at {
608            Some(_) => Position::Absolute,
609            None => Position::Relative,
610        },
611        inset: match at {
612            Some((x, y)) => Rect {
613                left: length(x),
614                top: length(y),
615                right: auto(),
616                bottom: auto(),
617            },
618            None => Rect { left: auto(), top: auto(), right: auto(), bottom: auto() },
619        },
620        flex_shrink: 0.0,
621        size: Size { width: length(width), height: length(panel_h) },
622        padding: Rect {
623            left: length(1.0_f32),
624            right: length(1.0_f32),
625            top: length(1.0_f32),
626            bottom: length(1.0_f32),
627        },
628        ..Default::default()
629    })
630    .fill(palette.border)
631    .radius(palette.radius)
632    .children(vec![View::new(Style {
633        flex_direction: FlexDirection::Column,
634        flex_grow: 1.0,
635        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
636        padding: Rect {
637            left: length(PANEL_PAD),
638            right: length(PANEL_PAD),
639            top: length(PANEL_PAD),
640            bottom: length(PANEL_PAD),
641        },
642        gap: Size { width: length(0.0_f32), height: length(2.0_f32) },
643        ..Default::default()
644    })
645    .radius((palette.radius - 1.0).max(0.0))
646    .paint_with(panel_signature_painter(palette.panel))
647    .children(children)])
648}
649
650/// Caja de búsqueda: render-only (el tecleo lo rutea la app por `on_key`).
651fn search_box_view<Msg: Clone + 'static>(query: &str, palette: &SelectPalette) -> View<Msg> {
652    let is_empty = query.is_empty();
653    let (text, color) = if is_empty {
654        ("Buscar…".to_string(), palette.fg_placeholder)
655    } else {
656        (query.to_string(), palette.fg_text)
657    };
658    View::new(Style {
659        size: Size { width: percent(1.0_f32), height: length(SEARCH_H) },
660        flex_direction: FlexDirection::Row,
661        align_items: Some(AlignItems::CENTER),
662        padding: Rect {
663            left: length(8.0_f32),
664            right: length(8.0_f32),
665            top: length(0.0_f32),
666            bottom: length(0.0_f32),
667        },
668        gap: Size { width: length(6.0_f32), height: length(0.0_f32) },
669        flex_shrink: 0.0,
670        ..Default::default()
671    })
672    .fill(palette.bg_trigger)
673    .radius(ITEM_RADIUS as f64)
674    .children(vec![
675        View::new(Style {
676            size: Size { width: length(16.0_f32), height: auto() },
677            flex_shrink: 0.0,
678            align_items: Some(AlignItems::CENTER),
679            justify_content: Some(JustifyContent::CENTER),
680            ..Default::default()
681        })
682        .text_aligned("\u{2315}".to_string(), 13.0, palette.fg_muted, Alignment::Center),
683        View::new(Style {
684            size: Size { width: auto(), height: auto() },
685            flex_grow: 1.0,
686            ..Default::default()
687        })
688        .text_aligned(text, 12.5, color, Alignment::Start),
689    ])
690}
691
692/// Fila de estado (Loading / vacío): glifo + texto centrado-izquierda.
693fn status_row_view<Msg: Clone + 'static>(
694    glyph: &str,
695    text: &str,
696    color: Color,
697    palette: &SelectPalette,
698) -> View<Msg> {
699    View::new(Style {
700        size: Size { width: percent(1.0_f32), height: length(STATUS_H) },
701        flex_direction: FlexDirection::Row,
702        align_items: Some(AlignItems::CENTER),
703        padding: Rect {
704            left: length(ITEM_PAD_LEFT),
705            right: length(ITEM_PAD_RIGHT),
706            top: length(0.0_f32),
707            bottom: length(0.0_f32),
708        },
709        gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
710        ..Default::default()
711    })
712    .children(vec![
713        View::new(Style {
714            size: Size { width: length(ICON_W), height: auto() },
715            flex_shrink: 0.0,
716            align_items: Some(AlignItems::CENTER),
717            justify_content: Some(JustifyContent::CENTER),
718            ..Default::default()
719        })
720        .text_aligned(glyph.to_string(), 14.0, color, Alignment::Center),
721        View::new(Style {
722            size: Size { width: auto(), height: auto() },
723            flex_grow: 1.0,
724            ..Default::default()
725        })
726        .text_aligned(text.to_string(), 12.5, color, Alignment::Start),
727    ])
728    .fill(palette.bg_panel)
729    .radius(ITEM_RADIUS as f64)
730}
731
732/// Fila de error: mensaje destructivo + "Reintentar" clickeable (si hay
733/// `on_retry`). El click consume el evento (no dismissa el menú).
734fn error_row_view<Msg: Clone + 'static>(
735    msg: &str,
736    on_retry: Option<Msg>,
737    palette: &SelectPalette,
738) -> View<Msg> {
739    let mut children: Vec<View<Msg>> = vec![
740        View::new(Style {
741            size: Size { width: length(ICON_W), height: auto() },
742            flex_shrink: 0.0,
743            align_items: Some(AlignItems::CENTER),
744            justify_content: Some(JustifyContent::CENTER),
745            ..Default::default()
746        })
747        .text_aligned("\u{26A0}".to_string(), 14.0, palette.fg_destructive, Alignment::Center),
748        View::new(Style {
749            size: Size { width: auto(), height: auto() },
750            flex_grow: 1.0,
751            ..Default::default()
752        })
753        .text_aligned(msg.to_string(), 12.5, palette.fg_destructive, Alignment::Start),
754    ];
755
756    if let Some(retry) = on_retry {
757        children.push(
758            View::new(Style {
759                size: Size { width: length(74.0_f32), height: length(24.0_f32) },
760                flex_shrink: 0.0,
761                align_items: Some(AlignItems::CENTER),
762                justify_content: Some(JustifyContent::CENTER),
763                ..Default::default()
764            })
765            .fill(palette.bg_active)
766            .radius(ITEM_RADIUS as f64)
767            .hover_fill(palette.bg_hover)
768            // El hit-test interno gana sobre el scrim: el click no dismissa.
769            .on_click(retry)
770            .text_aligned("Reintentar".to_string(), 11.5, palette.fg_text, Alignment::Center),
771        );
772    }
773
774    View::new(Style {
775        size: Size { width: percent(1.0_f32), height: length(STATUS_H) },
776        flex_direction: FlexDirection::Row,
777        align_items: Some(AlignItems::CENTER),
778        padding: Rect {
779            left: length(ITEM_PAD_LEFT),
780            right: length(ITEM_PAD_RIGHT),
781            top: length(0.0_f32),
782            bottom: length(0.0_f32),
783        },
784        gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
785        ..Default::default()
786    })
787    .fill(palette.bg_panel)
788    .radius(ITEM_RADIUS as f64)
789    .children(children)
790}
791
792/// El gutter izquierdo de un ítem/disparador: la **imagen** si la hay (gana), si
793/// no el **glifo** con `glyph_color`, o nada. Compartido por el disparador y las
794/// filas del menú, para que imágenes y glifos se rindan idénticos en ambos.
795fn gutter_view<Msg: Clone + 'static>(item: &SelectItem, glyph_color: Color) -> Option<View<Msg>> {
796    if let Some(img) = &item.image {
797        return Some(
798            View::new(Style {
799                size: Size { width: length(ICON_W), height: length(ICON_W) },
800                flex_shrink: 0.0,
801                ..Default::default()
802            })
803            .radius(4.0)
804            .image(img.clone()),
805        );
806    }
807    if let Some(icon) = &item.icon {
808        return Some(
809            View::new(Style {
810                size: Size { width: length(ICON_W), height: auto() },
811                flex_shrink: 0.0,
812                align_items: Some(AlignItems::CENTER),
813                justify_content: Some(JustifyContent::CENTER),
814                ..Default::default()
815            })
816            .text_aligned(icon.clone(), 13.0, glyph_color, Alignment::Center),
817        );
818    }
819    None
820}
821
822/// Una fila de ítem. `is_active` = resaltado por teclado/hover; `is_checked`
823/// = está en `selected`.
824#[allow(clippy::too_many_arguments)]
825fn item_row_view<Msg: Clone + 'static>(
826    pos: usize,
827    orig: usize,
828    item: &SelectItem,
829    is_active: bool,
830    is_checked: bool,
831    on_pick: &Arc<dyn Fn(usize) -> Msg + Send + Sync>,
832    on_hover: Option<&Arc<dyn Fn(usize) -> Msg + Send + Sync>>,
833    palette: &SelectPalette,
834) -> View<Msg> {
835    let fg = if !item.enabled {
836        palette.fg_muted
837    } else if is_active {
838        palette.fg_active
839    } else {
840        palette.fg_text
841    };
842    let icon_fg = if !item.enabled {
843        palette.fg_muted
844    } else if is_active {
845        palette.accent
846    } else {
847        palette.fg_muted
848    };
849
850    // Indicador accent vertical en la fila activa (reserva ancho siempre).
851    let indicator = {
852        let v = View::new(Style {
853            size: Size { width: length(INDICATOR_W), height: percent(0.55_f32) },
854            flex_shrink: 0.0,
855            ..Default::default()
856        });
857        if is_active && item.enabled {
858            v.fill(palette.accent).radius(2.0)
859        } else {
860            v
861        }
862    };
863
864    // Check de selección (✓ accent cuando elegido; gutter reservado siempre
865    // para que multi-select no haga saltar el texto).
866    let check = View::new(Style {
867        size: Size { width: length(CHECK_W), height: auto() },
868        flex_shrink: 0.0,
869        align_items: Some(AlignItems::CENTER),
870        justify_content: Some(JustifyContent::CENTER),
871        ..Default::default()
872    })
873    .text_aligned(
874        if is_checked { "\u{2713}".to_string() } else { String::new() },
875        13.0,
876        palette.accent,
877        Alignment::Center,
878    );
879
880    let mut row_children: Vec<View<Msg>> = vec![indicator, check];
881
882    if let Some(g) = gutter_view(item, icon_fg) {
883        row_children.push(g);
884    }
885
886    // Columna label + sublabel.
887    let mut text_col: Vec<View<Msg>> = vec![View::new(Style {
888        size: Size { width: percent(1.0_f32), height: auto() },
889        ..Default::default()
890    })
891    .text_aligned(item.label.clone(), 12.5, fg, Alignment::Start)];
892    if let Some(sub) = &item.sublabel {
893        text_col.push(
894            View::new(Style {
895                size: Size { width: percent(1.0_f32), height: auto() },
896                ..Default::default()
897            })
898            .text_aligned(sub.clone(), 11.0, palette.fg_muted, Alignment::Start),
899        );
900    }
901    row_children.push(
902        View::new(Style {
903            size: Size { width: auto(), height: auto() },
904            flex_grow: 1.0,
905            flex_direction: FlexDirection::Column,
906            justify_content: Some(JustifyContent::CENTER),
907            gap: Size { width: length(0.0_f32), height: length(2.0_f32) },
908            ..Default::default()
909        })
910        .children(text_col),
911    );
912
913    if let Some(badge) = &item.badge {
914        row_children.push(badge.view());
915    }
916
917    let mut row = View::new(Style {
918        size: Size { width: percent(1.0_f32), height: length(item.row_height()) },
919        flex_direction: FlexDirection::Row,
920        align_items: Some(AlignItems::CENTER),
921        padding: Rect {
922            left: length(ITEM_PAD_LEFT),
923            right: length(ITEM_PAD_RIGHT),
924            top: length(0.0_f32),
925            bottom: length(0.0_f32),
926        },
927        gap: Size { width: length(4.0_f32), height: length(0.0_f32) },
928        ..Default::default()
929    })
930    .radius(ITEM_RADIUS as f64)
931    .children(row_children);
932
933    if is_active && item.enabled {
934        row = row.fill(palette.bg_active);
935    }
936
937    // a11y: cada fila es una opción; el lector anuncia label + si está elegida.
938    row = row
939        .role(llimphi_compositor::Role::Button)
940        .aria_label(item.label.clone())
941        .aria_pressed(is_checked);
942
943    if item.enabled {
944        row = row.hover_fill(palette.bg_hover);
945        let on_pick = on_pick.clone();
946        // Consumir el click (no propagar al scrim que dismissaría).
947        row = row.on_click_at(move |_, _, _, _| Some(on_pick(orig)));
948        if let Some(on_hover) = on_hover {
949            let on_hover = on_hover.clone();
950            row = row.on_pointer_enter(on_hover(pos));
951        }
952    }
953    row
954}
955
956// ─────────────────────────────────────────────────────────────────────────
957// Helpers de filtro + navegación (puros, lado app)
958// ─────────────────────────────────────────────────────────────────────────
959
960/// Filtra ítems por `query` (subsecuencia case-insensitive sobre
961/// label+sublabel), devolviendo los índices **originales** ordenados por
962/// score descendente (mejor match primero). `query` vacío → todos en orden.
963///
964/// El scoring premia matches contiguos y al inicio de palabra — suficiente
965/// para un combobox; no pretende ser fzf.
966pub fn filter(items: &[SelectItem], query: &str) -> Vec<usize> {
967    let q = query.trim().to_lowercase();
968    if q.is_empty() {
969        return (0..items.len()).collect();
970    }
971    let mut scored: Vec<(usize, i32)> = Vec::new();
972    for (i, item) in items.iter().enumerate() {
973        let mut hay = item.label.to_lowercase();
974        if let Some(sub) = &item.sublabel {
975            hay.push(' ');
976            hay.push_str(&sub.to_lowercase());
977        }
978        if let Some(score) = subseq_score(&hay, &q) {
979            scored.push((i, score));
980        }
981    }
982    // Orden estable por score desc, conservando orden original ante empate.
983    scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
984    scored.into_iter().map(|(i, _)| i).collect()
985}
986
987/// Score de subsecuencia: `None` si `needle` no es subsecuencia de `hay`.
988/// Bonus por chars contiguos y por arrancar tras separador (inicio de
989/// palabra).
990fn subseq_score(hay: &str, needle: &str) -> Option<i32> {
991    let hay: Vec<char> = hay.chars().collect();
992    let needle: Vec<char> = needle.chars().collect();
993    let mut hi = 0;
994    let mut score = 0;
995    let mut prev_match = false;
996    let mut prev_char: Option<char> = None;
997    for &nc in &needle {
998        let mut found = false;
999        while hi < hay.len() {
1000            let hc = hay[hi];
1001            let at_boundary = prev_char.map(|c| c == ' ' || c == '-' || c == '_' || c == '/').unwrap_or(true);
1002            if hc == nc {
1003                score += 1;
1004                if prev_match {
1005                    score += 2; // contiguo
1006                }
1007                if at_boundary {
1008                    score += 3; // inicio de palabra
1009                }
1010                prev_match = true;
1011                prev_char = Some(hc);
1012                hi += 1;
1013                found = true;
1014                break;
1015            } else {
1016                prev_match = false;
1017                prev_char = Some(hc);
1018                hi += 1;
1019            }
1020        }
1021        if !found {
1022            return None;
1023        }
1024    }
1025    Some(score)
1026}
1027
1028/// Mueve el resaltado: dada la posición actual en `visible` + dirección
1029/// (`+1`/`-1`), la siguiente posición **habilitada** (envuelve). Devuelve
1030/// `usize::MAX` si no hay ninguna elegible. `current == usize::MAX` arranca
1031/// por el extremo según la dirección.
1032pub fn step_active(items: &[SelectItem], visible: &[usize], current: usize, direction: i32) -> usize {
1033    if visible.is_empty() {
1034        return usize::MAX;
1035    }
1036    let n = visible.len() as i32;
1037    let start = if current == usize::MAX {
1038        if direction >= 0 {
1039            -1
1040        } else {
1041            n
1042        }
1043    } else {
1044        current as i32
1045    };
1046    let mut i = start;
1047    for _ in 0..n {
1048        i += direction;
1049        if i < 0 {
1050            i = n - 1;
1051        } else if i >= n {
1052            i = 0;
1053        }
1054        if let Some(item) = items.get(visible[i as usize]) {
1055            if item.enabled {
1056                return i as usize;
1057            }
1058        }
1059    }
1060    usize::MAX
1061}
1062
1063/// Traduce una posición en `visible` al índice **original** del ítem — lo
1064/// que la app pasa a `on_pick`/guarda como elegido al pulsar Enter.
1065pub fn resolve(visible: &[usize], pos: usize) -> Option<usize> {
1066    visible.get(pos).copied()
1067}
1068
1069/// Extremo habilitado para Home/End: la primera (`to_end = false`) o última
1070/// (`true`) posición **habilitada** de `visible`. `usize::MAX` si no hay
1071/// ninguna elegible. Mismo criterio que `context-menu::edge_active`.
1072pub fn edge_active(items: &[SelectItem], visible: &[usize], to_end: bool) -> usize {
1073    let n = visible.len();
1074    if n == 0 {
1075        return usize::MAX;
1076    }
1077    let iter: Box<dyn Iterator<Item = usize>> = if to_end {
1078        Box::new((0..n).rev())
1079    } else {
1080        Box::new(0..n)
1081    };
1082    for pos in iter {
1083        if let Some(item) = items.get(visible[pos]) {
1084            if item.enabled {
1085                return pos;
1086            }
1087        }
1088    }
1089    usize::MAX
1090}
1091
1092/// Type-ahead: dada una tecla imprimible `ch`, salta a la siguiente posición
1093/// **habilitada** de `visible` cuyo label empieza por esa letra (case-insensitive),
1094/// **ciclando** desde la posición actual. Devuelve la nueva posición o
1095/// `usize::MAX` si ninguna coincide. Permite navegar un `<select>` tipeando la
1096/// inicial, sin caja de búsqueda. `current == usize::MAX` arranca desde el inicio.
1097pub fn type_ahead(items: &[SelectItem], visible: &[usize], current: usize, ch: char) -> usize {
1098    let n = visible.len();
1099    if n == 0 {
1100        return usize::MAX;
1101    }
1102    let target = ch.to_lowercase().next().unwrap_or(ch);
1103    let start = if current == usize::MAX { 0 } else { (current + 1) % n };
1104    for k in 0..n {
1105        let pos = (start + k) % n;
1106        if let Some(item) = items.get(visible[pos]) {
1107            if !item.enabled {
1108                continue;
1109            }
1110            let first = item.label.chars().next().and_then(|c| c.to_lowercase().next());
1111            if first == Some(target) {
1112                return pos;
1113            }
1114        }
1115    }
1116    usize::MAX
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121    use super::*;
1122
1123    fn items() -> Vec<SelectItem> {
1124        vec![
1125            SelectItem::new("Pendiente"),
1126            SelectItem::new("En curso").disabled(),
1127            SelectItem::new("Hecho"),
1128            SelectItem::new("Archivado").with_sublabel("guardado en frío"),
1129        ]
1130    }
1131
1132    #[test]
1133    fn filter_vacio_devuelve_todos_en_orden() {
1134        let it = items();
1135        assert_eq!(filter(&it, ""), vec![0, 1, 2, 3]);
1136        assert_eq!(filter(&it, "   "), vec![0, 1, 2, 3]);
1137    }
1138
1139    #[test]
1140    fn filter_subsecuencia_case_insensitive() {
1141        let it = items();
1142        // "hec" sólo es subsecuencia de "Hecho" (ojo: "he" también matchea
1143        // "arcHivado…En frío", por eso elegimos algo discriminante).
1144        assert_eq!(filter(&it, "hec"), vec![2]);
1145        // "EN" matchea "Pendiente" y "En curso" (subsecuencia, case-insensitive).
1146        let r = filter(&it, "en");
1147        assert!(r.contains(&0) && r.contains(&1));
1148    }
1149
1150    #[test]
1151    fn filter_busca_en_sublabel() {
1152        let it = items();
1153        // "frío" sólo aparece en el sublabel de "Archivado".
1154        assert_eq!(filter(&it, "frío"), vec![3]);
1155    }
1156
1157    #[test]
1158    fn filter_prefijo_puntua_mas_que_disperso() {
1159        let it = vec![
1160            SelectItem::new("abandono"), // 'a' disperso al inicio
1161            SelectItem::new("banana"),   // "ban" contiguo al inicio
1162        ];
1163        // Para "ban", banana (contiguo, inicio de palabra) debe ir primero.
1164        assert_eq!(filter(&it, "ban")[0], 1);
1165    }
1166
1167    #[test]
1168    fn step_active_salta_deshabilitados_y_envuelve() {
1169        let it = items();
1170        let vis = filter(&it, ""); // [0,1,2,3], pos 1 = "En curso" disabled
1171        assert_eq!(step_active(&it, &vis, 0, 1), 2); // salta el disabled
1172        assert_eq!(step_active(&it, &vis, 0, -1), 3); // envuelve, salta disabled
1173        assert_eq!(step_active(&it, &vis, usize::MAX, 1), 0); // arranca al inicio
1174    }
1175
1176    #[test]
1177    fn step_active_lista_vacia() {
1178        let it = items();
1179        assert_eq!(step_active(&it, &[], usize::MAX, 1), usize::MAX);
1180    }
1181
1182    #[test]
1183    fn resolve_mapea_posicion_a_indice_original() {
1184        let vis = vec![3, 0, 2];
1185        assert_eq!(resolve(&vis, 0), Some(3));
1186        assert_eq!(resolve(&vis, 2), Some(2));
1187        assert_eq!(resolve(&vis, 9), None);
1188    }
1189
1190    #[test]
1191    fn edge_active_salta_bordes_deshabilitados() {
1192        let it = items(); // pos 1 = "En curso" disabled
1193        let vis = filter(&it, "");
1194        assert_eq!(edge_active(&it, &vis, false), 0); // primero habilitado
1195        assert_eq!(edge_active(&it, &vis, true), 3); // último habilitado
1196        // Con el primero deshabilitado, Home cae en el 2º.
1197        let it2 = vec![
1198            SelectItem::new("uno").disabled(),
1199            SelectItem::new("dos"),
1200        ];
1201        let vis2 = filter(&it2, "");
1202        assert_eq!(edge_active(&it2, &vis2, false), 1);
1203    }
1204
1205    #[test]
1206    fn type_ahead_salta_por_inicial_y_cicla() {
1207        let it = vec![
1208            SelectItem::new("Argentina"),
1209            SelectItem::new("Brasil"),
1210            SelectItem::new("Bolivia"),
1211            SelectItem::new("Chile"),
1212        ];
1213        let vis = filter(&it, "");
1214        // Desde el inicio, 'b' → primer país con B (pos 1).
1215        assert_eq!(type_ahead(&it, &vis, usize::MAX, 'b'), 1);
1216        // Estando en 1, otra 'b' cicla al siguiente con B (pos 2).
1217        assert_eq!(type_ahead(&it, &vis, 1, 'b'), 2);
1218        // Desde 2, 'b' vuelve a 1 (ciclo).
1219        assert_eq!(type_ahead(&it, &vis, 2, 'b'), 1);
1220        // Mayúscula funciona igual (case-insensitive).
1221        assert_eq!(type_ahead(&it, &vis, usize::MAX, 'C'), 3);
1222        // Sin match → MAX.
1223        assert_eq!(type_ahead(&it, &vis, usize::MAX, 'z'), usize::MAX);
1224    }
1225
1226    #[test]
1227    fn type_ahead_saltea_deshabilitados() {
1228        let it = vec![
1229            SelectItem::new("Beta").disabled(),
1230            SelectItem::new("Bravo"),
1231        ];
1232        let vis = filter(&it, "");
1233        assert_eq!(type_ahead(&it, &vis, usize::MAX, 'b'), 1); // salta el disabled
1234    }
1235
1236    #[test]
1237    fn badge_constructores() {
1238        let c = SelectBadge::count(12, BadgeKind::Info);
1239        assert_eq!(c.count, Some(12));
1240        let l = SelectBadge::label("beta", BadgeKind::Warning);
1241        assert_eq!(l.label.as_deref(), Some("beta"));
1242        let d = SelectBadge::dot(BadgeKind::Success);
1243        assert!(d.label.is_none() && d.count.is_none());
1244    }
1245}