tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1//! `llimphi-widget-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}