tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ main widgets/dock-rail/src/lib.rs Rust · 1815 lines · 75.4 KB History Who touched what raw

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

1//! `llimphi-widget-dock-rail` — rail vertical de **dientes** para
2//! sidebars acoplables.
3//!
4//! Cada diente es una pestaña vertical: una **barra de acento** de 3px
5//! pegada al borde interno (encendida cuando el item está activo) + un
6//! **icono** centrado. Los dientes apilan en una columna redondeada que
7//! se pinta como una franja flotante al borde del centro — el patrón del
8//! dock de cosmos, ahora reutilizable.
9//!
10//! Render-only y agnóstico del `Msg`: el item se identifica por un `u64`
11//! opaco. El clic (en el *press*, para no pelear con el arrastre) activa
12//! vía `on_activate(id)`; el diente es **arrastrable** con su `id` como
13//! payload, y el rail entero es **drop target** (`on_drop(payload)`) —
14//! así una app puede mover un diente de un sidebar a otro soltándolo
15//! sobre el rail opuesto. El icono lo dibuja el caller (`make_icon`), que
16//! recibe el color ya resuelto según el estado activo/inactivo.
17//!
18//! Opcionalmente un diente lleva un **distintivo** ("bubble number") en su
19//! esquina externa: [`dock_rail_view_badged`] toma `make_badge(id)` que
20//! devuelve un [`DockBadge`] (chip con número o punto de estado, reusando
21//! [`llimphi_widget_badge`]). Como se deriva del `id`, basta cambiar el modelo
22//! y re-pintar para que el contador cambie en vivo.
23//!
24//! ```ignore
25//! let items = [DockRailItem::new(0, true), DockRailItem::new(1, false)];
26//! dock_rail_view(
27//!     &items,
28//!     44.0,
29//!     &DockRailPalette::from_theme(&theme),
30//!     |id, size, color| my_icon_view(id, size, color),
31//!     |id| Msg::DockActivate(side, id),
32//!     move |payload| Some(Msg::DockDrop(side, payload)),
33//! )
34//! ```
35
36#![forbid(unsafe_code)]
37
38
39// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
40// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
41// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
42#![no_std]
43
44extern crate alloc;
45
46use alloc::string::String;
47use alloc::vec::Vec;
48use alloc::vec;
49use alloc::sync::Arc;
50
51use llimphi_compositor::llimphi_layout::taffy::{
52    prelude::{auto, length, percent, FlexDirection, Position, Size, Style},
53    AlignItems, JustifyContent, Rect,
54};
55use llimphi_compositor::peniko::Color;
56use llimphi_compositor::{DragPhase, PaintRect, View};
57use llimphi_theme::Theme;
58
59pub use llimphi_widget_badge::BadgeKind;
60use llimphi_widget_badge::{count_badge_view, dot_badge_view};
61
62/// Alto de cada diente (px).
63const TOOTH_H: f32 = 42.0;
64/// Alto de la barra de acento (px) — un poco menor que el diente para
65/// dejar aire arriba y abajo.
66const BAR_H: f32 = 40.0;
67/// Tamaño del icono que se le pide al caller (px).
68const ICON_PX: f32 = 20.0;
69/// Ancho del medidor de carga (px) — el mismo de la barra de acento: son dos
70/// barras del mismo diente y con distinto grosor se leerían como dos objetos.
71const CARGA_W: f32 = 3.0;
72
73/// **Cuánta voz tiene un semiactivo**, contra el activo. Medio y un poco menos:
74/// tiene que leerse como «el mismo color, más callado», no como un segundo
75/// acento — con dos intensidades parecidas el rail vuelve a tener dos activos.
76const SEMI_ALFA: f32 = 0.42;
77
78/// Mezcla lineal de dos colores (`t = 0` → `a`, `t = 1` → `b`). El alfa se toma
79/// del primero: acá los dos vienen opacos y lo que se mezcla es el tono.
80fn mezclar(a: Color, b: Color, t: f32) -> Color {
81    let [ar, ag, ab, aa] = a.components;
82    let [br, bg, bb, _] = b.components;
83    let m = |x: f32, y: f32| x + (y - x) * t;
84    Color::new([m(ar, br), m(ag, bg), m(ab, bb), aa])
85}
86
87/// **La barra del borde de un diente**: el acento del activo **y** el medidor de
88/// carga, en la misma columna del canto anclado.
89///
90/// Son una sola barra desde el 2026-08-28, por pedido del operador: *«los dientes
91/// de arriba, que tienen una barra del lado izquierdo, sobre esa barra es que yo
92/// decía poner el medidor de cpu vertical, del lado izquierdo del diente»*. Antes
93/// el medidor vivía en el canto contrario, y ése era el malentendido: la barra que
94/// él nombraba es la del canto anclado, la que ya existía.
95///
96/// Se distinguen **por geometría, no por lugar** —que es lo que había fallado la
97/// primera vez, cuando se pintaba uno encima del otro sin nada que los separara—:
98/// el acento llena la columna **entera** (o no está), y la carga es un relleno
99/// **parcial desde abajo** en su propio color, sobre una pista tenue que dice
100/// hasta dónde podría llegar. Activo y cargado a la vez se lee sin ambigüedad: la
101/// columna de acento con su parte de abajo ardiendo.
102///
103/// Siempre ocupa el mismo lugar, tenga o no dato: si apareciera y desapareciera,
104/// el ícono de todos los dientes se correría tres píxeles cada vez que algo
105/// empieza o termina de gastar.
106fn barra_del_borde<Msg: Clone + 'static>(
107    active: bool,
108    semi: bool,
109    carga: Option<(f32, Color)>,
110    accent: Color,
111) -> View<Msg> {
112    let mut v = View::new(Style {
113        size: Size {
114            width: length(CARGA_W),
115            height: length(BAR_H),
116        },
117        flex_shrink: 0.0,
118        ..Default::default()
119    });
120    if active {
121        v = v.fill(accent).radius(2.0);
122    } else if semi {
123        // **A media voz, en la misma columna.** El semiactivo no se distingue por
124        // ocupar otro lugar —eso lo volvería un tercer dibujo que hay que
125        // aprender— sino por la intensidad de la única marca que ya significa
126        // «acá estás». Ver [`DockRailItem::semi`].
127        v = v.fill(accent.multiply_alpha(SEMI_ALFA)).radius(2.0);
128    }
129    let Some((frac, color)) = carga else {
130        return v;
131    };
132    v.paint_with(move |scene, _ts, rect| {
133        use llimphi_compositor::peniko::kurbo::{Affine, Rect as KRect, RoundedRect};
134        use llimphi_compositor::peniko::Fill;
135        if rect.w <= 0.0 || rect.h <= 0.0 {
136            return;
137        }
138        let columna = |desde_abajo: f32| {
139            let alto = (rect.h * desde_abajo).clamp(0.0, rect.h);
140            RoundedRect::from_rect(
141                KRect::new(
142                    rect.x as f64,
143                    (rect.y + rect.h - alto) as f64,
144                    (rect.x + rect.w) as f64,
145                    (rect.y + rect.h) as f64,
146                ),
147                (rect.w / 2.0) as f64,
148            )
149        };
150        scene.fill(
151            Fill::NonZero,
152            Affine::IDENTITY,
153            color.multiply_alpha(0.16),
154            None,
155            &columna(1.0),
156        );
157        // Piso de dos píxeles: por debajo, el relleno no se distingue de la pista
158        // y lo que estaría diciendo es que no hay nada corriendo.
159        let alto = (rect.h * frac.clamp(0.0, 1.0)).max(2.0) / rect.h;
160        scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &columna(alto));
161    })
162}
163
164/// Paleta del rail.
165#[derive(Debug, Clone, Copy)]
166pub struct DockRailPalette {
167    /// Fondo de la franja del rail.
168    pub bg_rail: Color,
169    /// Fondo del diente activo.
170    pub bg_active: Color,
171    /// Fondo al hover (diente) y al sobrevolar un drop válido (rail).
172    pub bg_hover: Color,
173    /// Color del acento: barra del diente activo + su icono.
174    pub accent: Color,
175    /// Color del icono de un diente inactivo.
176    pub icon_inactive: Color,
177    /// Sigma del **cristal esmerilado** del diente activo (px, `0.0` = sin
178    /// glass, el default). Cuando es `> 0`, la franja del rail deja de pintar
179    /// su fondo (se vuelve **transparente**, así asoma lo que haya detrás) y
180    /// **sólo la pestaña activa** se pinta como vidrio: `backdrop_blur(glass)`
181    /// + un tinte translúcido de `bg_active`. Es un atributo del *look* —lo
182    /// enciende el theme (p. ej. «mirada» con `glass_blur > 0`)— no un toggle
183    /// del widget; los dientes inactivos quedan icono sobre el vacío.
184    pub glass: f32,
185    /// **Brillo que recorre el diente activo**: el reloj monotónico (segundos)
186    /// desde el que se calcula la fase del barrido. `None` (default) = sin
187    /// brillo, el look de siempre.
188    ///
189    /// Es un **barrido periódico**, no una animación continua: una pasada de
190    /// [`BRILLO_BARRIDO_S`] cada [`BRILLO_CICLO_S`], y entre pasada y pasada la
191    /// escena no cambia. Esa forma no es estética sino de costo: en un rail
192    /// hospedado como layer surface, cada cuadro obliga al cliente a repintar y
193    /// al compositor a recomponer, y una animación permanente se paga en CPU
194    /// todo el tiempo que el escritorio esté encendido. El host que quiera
195    /// dejar de repintar entre pasadas pregunta con [`brillo_en_curso`].
196    ///
197    /// Sólo el **activo** lo lleva: los demás dientes se quedan quietos. Es la
198    /// misma regla que ya sigue [`Self::glass`] — lo que distingue al activo es
199    /// que está vivo, no que sea de otro color.
200    pub brillo_t: Option<f64>,
201    /// **Relleno del diente INACTIVO.** `None` (default) = los inactivos no pintan
202    /// fondo y sólo se ven por su ícono, que es el look de siempre: sobre una franja
203    /// sólida el rail ya les da cuerpo.
204    ///
205    /// Existe para el caso contrario: cuando la franja **no** pinta fondo (rail de
206    /// vidrio, [`Self::glass`] o un host que la transparenta), un diente sin relleno
207    /// queda como un ícono flotando en el aire sobre el escritorio. Con este color el
208    /// diente conserva **su color, su redondeado y su forma** aunque la barra sea
209    /// transparente — lo traslúcido es el fondo de la barra, no las piezas que van
210    /// encima.
211    pub pill_inactivo: Option<Color>,
212    /// **El fondo del diente activo como degradé** (cinco paradas, para que la
213    /// paleta siga siendo `Copy`), de izquierda a derecha, en vez de
214    /// `bg_active` liso. `None` = liso. [`Self::from_theme`] lo toma del
215    /// acento-gradiente del proceso (`llimphi_theme::relleno_activo`, mezclado
216    /// a la mitad sobre `bg_selected` para que lo de encima se lea): cualquier
217    /// app con rail lo tiene sin cablearlo (2026-10-12, *«no veo gradiente»*).
218    pub bg_active_gradiente: Option<[Color; 5]>,
219}
220
221/// Cada cuánto pasa el brillo por el diente activo (s).
222pub const BRILLO_CICLO_S: f64 = 6.0;
223/// Cuánto dura una pasada (s). El resto del ciclo el diente está quieto.
224pub const BRILLO_BARRIDO_S: f64 = 0.9;
225
226/// `true` si en el instante `t` hay un barrido en curso — o sea, si repintar
227/// cambiaría algo. El host lo usa para no mantener vivo el bucle de cuadros
228/// durante los ~5 s en que el brillo no está pasando.
229pub fn brillo_en_curso(t: f64) -> bool {
230    t.rem_euclid(BRILLO_CICLO_S) < BRILLO_BARRIDO_S
231}
232
233/// Pinta una pasada de brillo dentro de `rect`: una banda diagonal de luz que
234/// entra por arriba-izquierda y sale por abajo-derecha.
235///
236/// La banda se mueve **moviendo los extremos del degradado**, no las paradas:
237/// un gradiente lineal de `[transparente, blanco, transparente]` sobre un
238/// segmento corto que se desliza. Fuera del segmento el degradado se aferra a su
239/// color final —transparente—, así que no hace falta calcular offsets de parada
240/// para que el resto quede limpio.
241fn paint_brillo(scene: &mut llimphi_compositor::Lienzo, rect: PaintRect, t: f64, corners: (f64, f64, f64, f64)) {
242    use llimphi_compositor::peniko::kurbo::{Affine, Point, RoundedRect, RoundedRectRadii};
243    use llimphi_compositor::peniko::{Extend, Fill, Gradient};
244    if rect.w <= 0.0 || rect.h <= 0.0 {
245        return;
246    }
247    let fase = t.rem_euclid(BRILLO_CICLO_S) / BRILLO_BARRIDO_S;
248    if fase >= 1.0 {
249        return; // entre pasada y pasada no se pinta nada
250    }
251    let (x0, y0) = (rect.x as f64, rect.y as f64);
252    let (x1, y1) = ((rect.x + rect.w) as f64, (rect.y + rect.h) as f64);
253    // Envolvente: entra y sale suave, para que no aparezca ni desaparezca de golpe
254    // en los bordes del diente. sin(πx) vale 0 en los dos extremos.
255    let env = (fase * core::f64::consts::PI).sin().clamp(0.0, 1.0);
256    let alfa = (env * 70.0) as u8;
257    if alfa == 0 {
258        return;
259    }
260    // Diagonal del diente, recorrida con margen a los dos lados para que la banda
261    // arranque afuera y termine afuera.
262    let ancho = ((x1 - x0) + (y1 - y0)) * 0.55; // ancho de la banda
263    let dx = (x1 - x0) + ancho * 2.0;
264    let dy = (y1 - y0) + ancho * 2.0;
265    let cx = x0 - ancho + dx * fase;
266    let cy = y0 - ancho + dy * fase;
267    // Dirección de la diagonal, normalizada, escalada a media banda.
268    let largo = (dx * dx + dy * dy).sqrt().max(1.0);
269    let (ux, uy) = (dx / largo, dy / largo);
270    let h = ancho * 0.5;
271    let claro = Color::from_rgba8(255, 255, 255, alfa);
272    let nada = Color::from_rgba8(255, 255, 255, 0);
273    // `Pad` **explícito** y no el default: de esto depende que fuera de la banda el
274    // degradado se aferre al transparente. Con `Repeat` la banda se azulejaría por
275    // todo el diente y el brillo dejaría de ser un brillo. Hoy `Pad` es el default
276    // de peniko; escribirlo cuesta nada y no queda colgado de eso.
277    let g = Gradient::new_linear(
278        Point::new(cx - ux * h, cy - uy * h),
279        Point::new(cx + ux * h, cy + uy * h),
280    )
281    .with_extend(Extend::Pad)
282    .with_stops([nada, claro, nada].as_slice());
283    let rr = RoundedRect::from_rect(
284        llimphi_compositor::peniko::kurbo::Rect::new(x0, y0, x1, y1),
285        RoundedRectRadii::new(corners.0, corners.1, corners.2, corners.3),
286    );
287    scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
288}
289
290impl DockRailPalette {
291    pub fn from_theme(t: &Theme) -> Self {
292        Self {
293            bg_rail: t.bg_panel_alt,
294            bg_active: t.bg_selected,
295            bg_hover: t.bg_row_hover,
296            accent: t.accent,
297            icon_inactive: t.fg_muted,
298            brillo_t: None,
299            // Sin pill en los inactivos: sobre una franja sólida el rail ya les da
300            // cuerpo. Lo sube el host que transparenta la franja.
301            pill_inactivo: None,
302            // Sin glass por defecto: el look opaco de siempre. El host que corre
303            // bajo un theme con cristal (mirada `glass_blur > 0`) lo sube.
304            glass: 0.0,
305            // El acento-gradiente del proceso, si lo hay (`llimphi-theme`).
306            bg_active_gradiente: llimphi_theme::relleno_activo(t.bg_selected),
307        }
308    }
309}
310
311/// Reordena `orden` moviendo el grupo `arrastrado` al lugar de `destino`.
312///
313/// Es la cuenta que el host repite siempre que implementa «arrastrar para
314/// reposicionar», así que vive acá y no en cada host. Devuelve el orden nuevo;
315/// si alguno de los dos no está en la lista, o son el mismo, devuelve `orden`
316/// intacto — soltar un grupo sobre sí mismo no es un cambio.
317///
318/// La semántica es **mover, no intercambiar**: el grupo arrastrado se saca de
319/// donde estaba y se **inserta** en la posición del destino, corriendo al resto.
320/// Intercambiar es lo fácil de programar y lo que nadie espera de un arrastre —
321/// arrastrar el último grupo al principio con un swap le deja el primero al
322/// final, que no es lo que uno pidió.
323pub fn mover_grupo(orden: &[u64], arrastrado: u64, destino: u64) -> Vec<u64> {
324    let mut v = orden.to_vec();
325    if arrastrado == destino {
326        return v;
327    }
328    let (Some(desde), Some(hasta)) = (
329        v.iter().position(|g| *g == arrastrado),
330        v.iter().position(|g| *g == destino),
331    ) else {
332        return v;
333    };
334    let g = v.remove(desde);
335    v.insert(hasta, g);
336    v
337}
338
339/// Un diente del rail: su id opaco, si está activo, y cómo se llama.
340///
341/// El `label` **no se pinta** — el diente muestra el ícono que arma el caller.
342/// Es el nombre accesible: sin él un lector de pantalla anuncia el diente sin
343/// poder decir qué panel representa, que era el agujero de accesibilidad del
344/// widget (el ícono lo pinta un closure opaco y no hay texto de dónde sacarlo).
345#[derive(Debug, Clone)]
346pub struct DockRailItem {
347    pub id: u64,
348    pub active: bool,
349    /// Nombre del panel que este diente representa. `None` = sin nombre
350    /// accesible (el diente queda mudo para las ayudas técnicas).
351    pub label: Option<String>,
352    /// **El grupo al que pertenece el diente.** `None` (default) = el diente viaja
353    /// solo, como siempre.
354    ///
355    /// Cuando está puesto, el diente deja de arrastrarse a sí mismo y arrastra a
356    /// **su grupo entero**: el `drag_payload` y el destino que recibe el
357    /// `on_reorder` pasan a ser ids de GRUPO, no de diente. Así los escritorios
358    /// viajan juntos, los de una app viajan juntos, y un botón suelto es
359    /// simplemente un grupo de uno.
360    ///
361    /// El widget **no** agrupa por su cuenta ni reordena nada: el orden se lo da el
362    /// host en el orden de `items`, y el `on_reorder` sólo le informa «este grupo se
363    /// soltó sobre este otro». Quién persiste esa posición es asunto del host — el
364    /// rail no tiene dónde guardar nada.
365    pub grupo: Option<u64>,
366    /// **Cuánto está gastando lo que cuelga de este diente**, `(fracción 0..=1,
367    /// color)`, o `None` si no hay nada que medir.
368    ///
369    /// Se pinta como una **segunda barra**, del alto del diente, en el borde
370    /// **opuesto** al de la barra de acento: ésa dice *cuál* está activo y ésta
371    /// dice *cuánto cuesta*, y son dos preguntas distintas. Compartiendo la misma
372    /// barra no se podían leer —el diente activo la pinta entera de acento y se
373    /// come el medidor justo en el que estás trabajando, que suele ser el que más
374    /// gasta—; el reporte del operador fue exactamente ése: *«el medidor vertical
375    /// decía encima de la barra que el diente tiene en el borde izquierdo, no sé
376    /// si el amarillo cambia o queda claro o transparentoso o igual»*.
377    ///
378    /// **El widget no decide ni cuánto ni de qué color**: los dos números llegan
379    /// resueltos por el host, que es quien sabe qué está midiendo. Acá sólo se
380    /// dibuja.
381    pub carga: Option<(f32, Color)>,
382    /// **A medio activar**: lo que este diente representa está ahí, pero tapado.
383    ///
384    /// El caso que lo pidió: el escritorio que está **detrás del cajón**. No es
385    /// el activo —lo que estás mirando es el cajón— y tampoco es uno más de los
386    /// apagados: es al que volvés cuando lo guardás. Con dos estados había que
387    /// elegir entre mentir («activo» sobre algo que no se ve) o esconder («igual
388    /// que los otros» sobre el único que está debajo), y las dos veces el rail
389    /// deja de contestar «¿dónde estoy?».
390    ///
391    /// Se pinta **con la misma geometría del activo, a media voz**: la columna
392    /// del canto lleva el acento translúcido en vez de sólido y el ícono va a
393    /// mitad de camino entre el apagado y el acento. No se le da forma de
394    /// pastilla: la pastilla es del activo, y dos pastillas encendidas serían
395    /// otra vez dos activos.
396    ///
397    /// Ignorado si [`Self::active`] — un diente no puede estar a medio activar
398    /// y activo a la vez.
399    pub semi: bool,
400}
401
402impl DockRailItem {
403    /// Diente sin nombre accesible. Preferí [`Self::nombrado`] — un diente sin
404    /// label es invisible para un lector de pantalla.
405    pub fn new(id: u64, active: bool) -> Self {
406        Self { id, active, label: None, grupo: None, carga: None, semi: false }
407    }
408
409    /// Diente con su nombre accesible. Es la forma recomendada.
410    pub fn nombrado(id: u64, active: bool, label: impl Into<String>) -> Self {
411        Self { id, active, label: Some(label.into()), grupo: None, carga: None, semi: false }
412    }
413
414    /// Agrega/reemplaza el nombre accesible.
415    pub fn with_label(mut self, label: impl Into<String>) -> Self {
416        self.label = Some(label.into());
417        self
418    }
419
420    /// Lo deja **a medio activar** (ver [`Self::semi`]). Encadenable.
421    pub fn semiactivo(mut self, on: bool) -> Self {
422        self.semi = on;
423        self
424    }
425
426    /// Mete el diente en un grupo: a partir de acá arrastrarlo mueve al grupo
427    /// entero. Ver [`Self::grupo`].
428    pub fn en_grupo(mut self, grupo: u64) -> Self {
429        self.grupo = Some(grupo);
430        self
431    }
432
433    /// Lo que este diente **arrastra**: su grupo si lo tiene, o él mismo.
434    pub fn arrastra(&self) -> u64 {
435        self.grupo.unwrap_or(self.id)
436    }
437}
438
439/// Distintivo ("bubble number") que se superpone en la esquina externa de un
440/// diente — la esquina hacia la que el diente *sobresale*. Reusa el widget
441/// [`llimphi_widget_badge`]: un chip con número (`Count`) o un punto de estado
442/// (`Dot`). Es `Copy` para encajar en cierres `Fn(u64) -> Option<DockBadge>`
443/// sin clonar; usa [`dock_rail_view_badged`] para pintarlos.
444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445pub enum DockBadge {
446    /// Chip ovalado con un número adentro (`>= 100` se muestra como "99+").
447    Count(u32, BadgeKind),
448    /// Punto sólido sin número — "hay algo" / estado de conexión.
449    Dot(BadgeKind),
450}
451
452impl DockBadge {
453    fn view<Msg: Clone + 'static>(self) -> View<Msg> {
454        match self {
455            DockBadge::Count(n, kind) => count_badge_view(n, kind),
456            DockBadge::Dot(kind) => dot_badge_view(kind),
457        }
458    }
459}
460
461/// A qué borde se pega el rail — decide hacia dónde **sobresalen** los dientes.
462/// `InnerLeft` (default) es para un sidebar a la **izquierda**: la barra de acento
463/// queda a la izquierda y el diente abre hacia la derecha (hacia el centro).
464/// `InnerRight` espeja todo para un sidebar a la **derecha**: barra a la derecha,
465/// el diente abre hacia la izquierda (también hacia el centro). El uso canónico de
466/// cosmos usa el default; las apps con dientes a ambos lados pasan el lado.
467#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
468pub enum DockRailSide {
469    /// Sidebar izquierdo: dientes sobresalen hacia la derecha.
470    #[default]
471    InnerLeft,
472    /// Sidebar derecho: dientes sobresalen hacia la izquierda (espejado).
473    InnerRight,
474}
475
476/// Construye el rail de dientes.
477///
478/// - `items`: en el orden en que se quieren mostrar (el widget no
479///   reordena).
480/// - `width`: ancho de la franja del rail (px).
481/// - `make_icon(id, size, color)`: dibuja el icono del item con el color
482///   ya resuelto (acento si activo, atenuado si no).
483/// - `on_activate(id)`: `Msg` al clickear (en el press).
484/// - `on_drop(payload)`: `Msg` opcional cuando se suelta un diente
485///   (cualquier `id`) sobre este rail.
486pub fn dock_rail_view<Msg, FIcon, FAct, FDrop>(
487    items: &[DockRailItem],
488    width: f32,
489    palette: &DockRailPalette,
490    make_icon: FIcon,
491    on_activate: FAct,
492    on_drop: FDrop,
493) -> View<Msg>
494where
495    Msg: Clone + Send + Sync + 'static,
496    FIcon: Fn(u64, f32, Color) -> View<Msg>,
497    FAct: Fn(u64) -> Msg,
498    FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
499{
500    dock_rail_view_side(
501        items,
502        width,
503        DockRailSide::InnerLeft,
504        palette,
505        make_icon,
506        on_activate,
507        on_drop,
508    )
509}
510
511/// Como [`dock_rail_view`] pero eligiendo el [`DockRailSide`]: en `InnerRight` el
512/// diente se **espeja** (barra de acento a la derecha, esquinas redondeadas a la
513/// izquierda) para un sidebar a la derecha. `dock_rail_view` es el atajo a
514/// `InnerLeft`.
515pub fn dock_rail_view_side<Msg, FIcon, FAct, FDrop>(
516    items: &[DockRailItem],
517    width: f32,
518    side: DockRailSide,
519    palette: &DockRailPalette,
520    make_icon: FIcon,
521    on_activate: FAct,
522    on_drop: FDrop,
523) -> View<Msg>
524where
525    Msg: Clone + Send + Sync + 'static,
526    FIcon: Fn(u64, f32, Color) -> View<Msg>,
527    FAct: Fn(u64) -> Msg,
528    FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
529{
530    dock_rail_view_core(
531        items,
532        width,
533        side,
534        palette,
535        make_icon,
536        |_| None,
537        |_| false,
538        |_| None,
539        on_activate,
540        |_| None,
541        on_drop,
542        None,
543    )
544}
545
546/// Como [`dock_rail_view_side`] pero con **distintivos** ("bubble numbers"):
547/// `make_badge(id)` devuelve un [`DockBadge`] opcional que se superpone en la
548/// esquina externa del diente (la esquina hacia la que sobresale). El badge no
549/// captura clics — clickear sobre él sigue activando el diente. Igual que
550/// `make_icon`, el badge se deriva del `id`, así que basta variar el modelo y
551/// re-pintar para que el contador cambie en vivo.
552#[allow(clippy::too_many_arguments)]
553pub fn dock_rail_view_badged<Msg, FIcon, FBadge, FAct, FDrop>(
554    items: &[DockRailItem],
555    width: f32,
556    side: DockRailSide,
557    palette: &DockRailPalette,
558    make_icon: FIcon,
559    make_badge: FBadge,
560    on_activate: FAct,
561    on_drop: FDrop,
562) -> View<Msg>
563where
564    Msg: Clone + Send + Sync + 'static,
565    FIcon: Fn(u64, f32, Color) -> View<Msg>,
566    FBadge: Fn(u64) -> Option<DockBadge>,
567    FAct: Fn(u64) -> Msg,
568    FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
569{
570    dock_rail_view_core(
571        items,
572        width,
573        side,
574        palette,
575        make_icon,
576        make_badge,
577        |_| false,
578        |_| None,
579        on_activate,
580        |_| None,
581        on_drop,
582        None,
583    )
584}
585
586/// Como [`dock_rail_view_side`] pero con **separadores de grupo**: `sep_before(id)`
587/// devuelve `true` para el primer diente de un grupo nuevo, y el rail pinta una
588/// línea tenue *encima* suyo (nunca antes del primero) para agrupar sin resaltar.
589#[allow(clippy::too_many_arguments)]
590pub fn dock_rail_view_grouped<Msg, FIcon, FSep, FAct, FDrop>(
591    items: &[DockRailItem],
592    width: f32,
593    side: DockRailSide,
594    palette: &DockRailPalette,
595    make_icon: FIcon,
596    sep_before: FSep,
597    on_activate: FAct,
598    on_drop: FDrop,
599) -> View<Msg>
600where
601    Msg: Clone + Send + Sync + 'static,
602    FIcon: Fn(u64, f32, Color) -> View<Msg>,
603    FSep: Fn(u64) -> bool,
604    FAct: Fn(u64) -> Msg,
605    FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
606{
607    dock_rail_view_core(
608        items,
609        width,
610        side,
611        palette,
612        make_icon,
613        |_| None,
614        sep_before,
615        |_| None,
616        on_activate,
617        |_| None,
618        on_drop,
619        None,
620    )
621}
622
623/// Como [`dock_rail_view_grouped`] pero además con **grupos colapsables**: un
624/// diente puede ser el **cabezal** de un grupo que se pliega/despliega. `chevron(id)`
625/// devuelve `Some(expanded)` para un diente-cabezal —`true` si su grupo está
626/// desplegado (sus miembros a la vista), `false` si está plegado (escondidos)— y
627/// `None` para un diente normal. El rail le dibuja una **flechita** (chevron) en la
628/// esquina interna-inferior: ▾ desplegado, ▸ plegado. La flechita es sólo indicador
629/// —no captura clics—; **esconder los miembros y alternar el estado los decide el
630/// host**: al activar el cabezal alterna su `collapsed` y omite de `items` los dientes
631/// de un grupo plegado (conservando el `id` de cada diente visible). Así el widget
632/// aporta el «desplegable» sin dueño de estado nuevo.
633#[allow(clippy::too_many_arguments)]
634pub fn dock_rail_view_collapsible<Msg, FIcon, FSep, FChev, FAct, FDrop>(
635    items: &[DockRailItem],
636    width: f32,
637    side: DockRailSide,
638    palette: &DockRailPalette,
639    make_icon: FIcon,
640    sep_before: FSep,
641    chevron: FChev,
642    on_activate: FAct,
643    on_drop: FDrop,
644) -> View<Msg>
645where
646    Msg: Clone + Send + Sync + 'static,
647    FIcon: Fn(u64, f32, Color) -> View<Msg>,
648    FSep: Fn(u64) -> bool,
649    FChev: Fn(u64) -> Option<bool>,
650    FAct: Fn(u64) -> Msg,
651    FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
652{
653    dock_rail_view_core(
654        items,
655        width,
656        side,
657        palette,
658        make_icon,
659        |_| None,
660        sep_before,
661        chevron,
662        on_activate,
663        |_| None,
664        on_drop,
665        None,
666    )
667}
668
669/// Callback de **reorden por arrastre**: `(payload, target)` — el `id` del diente
670/// arrastrado y el `id` del diente sobre el que se soltó. La app traduce ambos ids a
671/// posiciones y reordena. `None` = no hacer nada (p. ej. si el payload no pertenece a
672/// este rail). Se comparte por-diente vía `Arc` para no re-crear la lógica N veces.
673pub type ReorderFn<Msg> = Arc<dyn Fn(u64, u64) -> Option<Msg> + Send + Sync>;
674
675/// **Lo que un diente hace con un arrastre AJENO** — uno que no viene de otro
676/// diente: una ventana de la lista del panel, un archivo, una pestaña.
677///
678/// Va aparte de [`ReorderFn`] porque son dos preguntas distintas y la de
679/// reordenar no puede contestar ésta: su destino es el **grupo** del diente (los
680/// escritorios viajan juntos), y quien suelta una ventana sobre el escritorio 3
681/// necesita el **3**, no «el grupo escritorios». Ver
682/// [`DockRailItem::grupo`].
683///
684/// No vive en [`DockRailItem`] —que es `Clone + Debug` y no es genérico sobre el
685/// `Msg`— sino en el rail: una función que recibe el id del diente.
686pub struct DienteRecibe<Msg> {
687    /// `(payload arrastrado, id del diente)` → qué emitir al soltarlo ahí.
688    /// Devolver `None` deja que el drop caiga al reorden, si lo hay.
689    pub al_soltar: Arc<dyn Fn(u64, u64) -> Option<Msg> + Send + Sync>,
690    /// **El resorte**: `(id del diente)` → qué emitir cuando el arrastre se
691    /// queda posado encima sin soltar, típicamente *desplegá este diente* para
692    /// poder apuntar adentro. `None` = el diente no se abre solo.
693    pub al_posarse: Option<Arc<dyn Fn(u64) -> Option<Msg> + Send + Sync>>,
694}
695
696/// Gesto secundario **posicionado**: `(id, x, y)` — el diente y dónde quedó el
697/// puntero, en coordenadas de pantalla (las que entrega
698/// `View::on_right_click_screen`).
699///
700/// Existe porque el secundario a secas (`on_secondary`, un `Msg` pelado) sólo
701/// alcanza para *hacer* algo —y lo que hacía era **cerrar**, sin preguntar. Un
702/// diente que representa trabajo del operador (una sesión, un escritorio, un
703/// proyecto) necesita ofrecer sus verbos en un menú, y un menú se ancla donde
704/// está el puntero: sin las coordenadas hay que inventarlas del alto del rail,
705/// que es exactamente el tipo de cuenta que se rompe al cambiar el tema.
706pub type SecondaryAtFn<Msg> = Arc<dyn Fn(u64, f32, f32) -> Option<Msg> + Send + Sync>;
707
708/// Callback de **doble-click** sobre un diente: recibe su `id` y devuelve el
709/// `Msg` a emitir, o `None` si ese diente no ofrece el gesto.
710///
711/// El doble-click de un diente es **exclusivo**, no aditivo: el runtime retiene
712/// el click simple hasta que vence la ventana del doble y lo descarta si el
713/// segundo llega (`View::double_tap_exclusive`). Sin eso, un doble-click
714/// desplegaba y replegaba el panel de paso — y con el sidebar en FIJO eso suelta
715/// y vuelve a tomar la franja reservada del escritorio, que se ve.
716pub type DobleFn<Msg> = Arc<dyn Fn(u64) -> Option<Msg> + Send + Sync>;
717
718/// Callback de **hover** sobre un diente: `(id, entra)` → el `Msg` a emitir, o
719/// `None` si ese diente no le da importancia al puntero.
720///
721/// Existe porque un diente puede querer ofrecer un gesto **sólo mientras lo
722/// mirás** — el caso que lo pidió es el empausador del diente de sonido: con
723/// música sonando, el puntero encima revela un botón de pausa chico en la
724/// esquina del ícono, y al irse desaparece (pedido textual del operador,
725/// 2026-09-03: *«si está play, el hover agregue un icono de pausa pequeño»*).
726///
727/// **Va en el diente, no en el ícono**, y eso no es un detalle de gusto: el
728/// hit-test de hover del runtime sólo encuentra nodos con `hover_fill`
729/// (`hit_test_hover`), y el único que lo declara es el diente. Un
730/// `on_pointer_enter` colgado del ícono no se dispara nunca, y ponerle
731/// `hover_fill` al ícono para que se dispare le roba el resaltado al diente —
732/// que es peor que no tener el gesto.
733pub type HoverFn<Msg> = Arc<dyn Fn(u64, bool) -> Option<Msg> + Send + Sync>;
734
735/// Como [`dock_rail_view_badged`] pero además **reordenable por arrastre**: cada diente
736/// es también un *drop target*, así soltar un diente sobre otro emite
737/// `on_reorder(payload_arrastrado, id_destino)`. El `on_drop` del rail entero sigue
738/// activo para soltar sobre el hueco (mover de un rail a otro); el drop sobre un diente
739/// concreto tiene prioridad (es el target más interno) y da la POSICIÓN destino que el
740/// `on_drop` a nivel-rail no podía expresar. Es el rail que usa el rag-sidebar para dar
741/// reorden de dientes (sesiones, escritorios, playlists…).
742///
743/// `on_secondary(id)` es el **gesto secundario** del diente (clic derecho/medio):
744/// `Some(msg)` lo cablea, `None` lo deja inerte. Es el mismo trato que ya tenían los
745/// subdientes (`RagSubTooth::on_close`) y existe por lo mismo: un diente que sólo sabe
746/// activarse no se puede **sacar** del rail, así que la única forma de deshacerse de él
747/// era ir a buscar el botón adentro del panel que ese diente abre.
748#[allow(clippy::too_many_arguments)]
749pub fn dock_rail_view_full<Msg, FIcon, FBadge, FSep, FAct, FSec, FDrop>(
750    items: &[DockRailItem],
751    width: f32,
752    side: DockRailSide,
753    palette: &DockRailPalette,
754    make_icon: FIcon,
755    make_badge: FBadge,
756    sep_before: FSep,
757    on_activate: FAct,
758    on_secondary: FSec,
759    on_drop: FDrop,
760    on_reorder: Option<ReorderFn<Msg>>,
761) -> View<Msg>
762where
763    Msg: Clone + Send + Sync + 'static,
764    FIcon: Fn(u64, f32, Color) -> View<Msg>,
765    FBadge: Fn(u64) -> Option<DockBadge>,
766    FSep: Fn(u64) -> bool,
767    FAct: Fn(u64) -> Msg,
768    FSec: Fn(u64) -> Option<Msg>,
769    FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
770{
771    dock_rail_view_core(
772        items,
773        width,
774        side,
775        palette,
776        make_icon,
777        make_badge,
778        sep_before,
779        |_| None,
780        on_activate,
781        on_secondary,
782        on_drop,
783        on_reorder,
784    )
785}
786
787/// Como [`dock_rail_view_full`] pero con el **gesto secundario posicionado**
788/// ([`SecondaryAtFn`]): el clic derecho entrega además dónde quedó el puntero,
789/// que es lo que hace falta para anclar un **menú** en vez de ejecutar de una.
790///
791/// Los dos conviven: `on_secondary` sigue gobernando el clic **medio** (el
792/// atajo de cerrar) y el derecho pasa a ser el menú del que declare
793/// `on_secondary_at`. `None` deja el rail exactamente como antes.
794#[allow(clippy::too_many_arguments)]
795pub fn dock_rail_view_full_at<Msg, FIcon, FBadge, FSep, FAct, FSec, FDrop>(
796    items: &[DockRailItem],
797    width: f32,
798    side: DockRailSide,
799    palette: &DockRailPalette,
800    make_icon: FIcon,
801    make_badge: FBadge,
802    sep_before: FSep,
803    on_activate: FAct,
804    on_secondary: FSec,
805    on_secondary_at: Option<SecondaryAtFn<Msg>>,
806    on_drop: FDrop,
807    on_reorder: Option<ReorderFn<Msg>>,
808) -> View<Msg>
809where
810    Msg: Clone + Send + Sync + 'static,
811    FIcon: Fn(u64, f32, Color) -> View<Msg>,
812    FBadge: Fn(u64) -> Option<DockBadge>,
813    FSep: Fn(u64) -> bool,
814    FAct: Fn(u64) -> Msg,
815    FSec: Fn(u64) -> Option<Msg>,
816    FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
817{
818    dock_rail_view_core_at(
819        items,
820        width,
821        side,
822        palette,
823        make_icon,
824        make_badge,
825        sep_before,
826        |_| None,
827        on_activate,
828        on_secondary,
829        on_secondary_at,
830        None,
831        None,
832        on_drop,
833        on_reorder,
834        None,
835    )
836}
837
838/// Como [`dock_rail_view_full_at`] pero con el **doble-click** del diente
839/// ([`DobleFn`]): dos clicks rápidos sobre el mismo diente emiten su `Msg`, y el
840/// click simple queda arbitrado contra él (no se dispara de paso).
841///
842/// Lo estrena el rag-sidebar: un click despliega el panel del diente, dos fijan
843/// el **espacio** (eje Flota/Fijo) sin tocar lo que el panel esté mostrando.
844#[allow(clippy::too_many_arguments)]
845pub fn dock_rail_view_full_at_doble<Msg, FIcon, FBadge, FSep, FAct, FSec, FDrop>(
846    items: &[DockRailItem],
847    width: f32,
848    side: DockRailSide,
849    palette: &DockRailPalette,
850    make_icon: FIcon,
851    make_badge: FBadge,
852    sep_before: FSep,
853    on_activate: FAct,
854    on_secondary: FSec,
855    on_secondary_at: Option<SecondaryAtFn<Msg>>,
856    on_doble: Option<DobleFn<Msg>>,
857    on_drop: FDrop,
858    on_reorder: Option<ReorderFn<Msg>>,
859) -> View<Msg>
860where
861    Msg: Clone + Send + Sync + 'static,
862    FIcon: Fn(u64, f32, Color) -> View<Msg>,
863    FBadge: Fn(u64) -> Option<DockBadge>,
864    FSep: Fn(u64) -> bool,
865    FAct: Fn(u64) -> Msg,
866    FSec: Fn(u64) -> Option<Msg>,
867    FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
868{
869    dock_rail_view_core_at(
870        items,
871        width,
872        side,
873        palette,
874        make_icon,
875        make_badge,
876        sep_before,
877        |_| None,
878        on_activate,
879        on_secondary,
880        on_secondary_at,
881        on_doble,
882        None,
883        on_drop,
884        on_reorder,
885        None,
886    )
887}
888
889/// Igual que [`dock_rail_view_full_at_doble`] pero con **hover por diente**
890/// ([`HoverFn`]): el diente avisa cuando el puntero entra y cuando sale.
891///
892/// Es una entrada aparte y no un argumento más de la de arriba porque la de
893/// arriba la llaman varios rails que no quieren saber nada del puntero, y
894/// romperles la firma para que pasen un `None` es cambiarle el código a quien no
895/// pidió nada.
896///
897/// `recibe` es qué hace un diente con un arrastre que **no** es otro diente (ver
898/// [`DienteRecibe`]); `None` = los dientes sólo reciben reordenamientos.
899#[allow(clippy::too_many_arguments)]
900pub fn dock_rail_view_full_at_doble_hover<Msg, FIcon, FBadge, FSep, FAct, FSec, FDrop>(
901    items: &[DockRailItem],
902    width: f32,
903    side: DockRailSide,
904    palette: &DockRailPalette,
905    make_icon: FIcon,
906    make_badge: FBadge,
907    sep_before: FSep,
908    on_activate: FAct,
909    on_secondary: FSec,
910    on_secondary_at: Option<SecondaryAtFn<Msg>>,
911    on_doble: Option<DobleFn<Msg>>,
912    on_hover: Option<HoverFn<Msg>>,
913    on_drop: FDrop,
914    on_reorder: Option<ReorderFn<Msg>>,
915    recibe: Option<DienteRecibe<Msg>>,
916) -> View<Msg>
917where
918    Msg: Clone + Send + Sync + 'static,
919    FIcon: Fn(u64, f32, Color) -> View<Msg>,
920    FBadge: Fn(u64) -> Option<DockBadge>,
921    FSep: Fn(u64) -> bool,
922    FAct: Fn(u64) -> Msg,
923    FSec: Fn(u64) -> Option<Msg>,
924    FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
925{
926    dock_rail_view_core_at(
927        items,
928        width,
929        side,
930        palette,
931        make_icon,
932        make_badge,
933        sep_before,
934        |_| None,
935        on_activate,
936        on_secondary,
937        on_secondary_at,
938        on_doble,
939        on_hover,
940        on_drop,
941        on_reorder,
942        recibe,
943    )
944}
945
946/// La **flechita** (chevron) de un diente-cabezal de grupo colapsable, dibujada
947/// como vector (no glifo de fuente, así es determinista en toda máquina — evita el
948/// notdef en hardware sin la fuente). Apunta ▾ (abajo) cuando el grupo está
949/// desplegado y ▸ (derecha) cuando está plegado, del `color` ya resuelto del diente
950/// (acento si activo). Se superpone en la esquina interna-inferior y no captura
951/// clics; el host alterna el estado al activar el cabezal.
952fn chevron_view<Msg: Clone + 'static>(expanded: bool, color: Color, mirror: bool) -> View<Msg> {
953    // Esquina INTERNA-inferior: el borde interno es la izquierda en InnerLeft y la
954    // derecha en InnerRight (espejado), del mismo lado que la barra de acento.
955    let inset = if mirror {
956        Rect { top: auto(), bottom: length(2.0_f32), left: auto(), right: length(3.0_f32) }
957    } else {
958        Rect { top: auto(), bottom: length(2.0_f32), left: length(3.0_f32), right: auto() }
959    };
960    View::new(Style {
961        position: Position::Absolute,
962        inset,
963        size: Size { width: length(11.0_f32), height: length(11.0_f32) },
964        ..Default::default()
965    })
966    .paint_with(move |scene, _ts, rect| paint_chevron(scene, rect, expanded, color))
967}
968
969/// Pinta un triángulo relleno (la flechita) centrado en `rect`: apunta abajo si
970/// `expanded`, a la derecha si no.
971fn paint_chevron(scene: &mut llimphi_compositor::Lienzo, rect: PaintRect, expanded: bool, color: Color) {
972    use llimphi_compositor::peniko::kurbo::{Affine, BezPath};
973    use llimphi_compositor::peniko::Fill;
974    if rect.w <= 0.0 || rect.h <= 0.0 {
975        return;
976    }
977    let cx = (rect.x + rect.w * 0.5) as f64;
978    let cy = (rect.y + rect.h * 0.5) as f64;
979    let s = 3.4_f64; // media-extensión del triángulo
980    let mut p = BezPath::new();
981    if expanded {
982        // ▾ apunta hacia abajo (grupo desplegado).
983        p.move_to((cx - s, cy - s * 0.55));
984        p.line_to((cx + s, cy - s * 0.55));
985        p.line_to((cx, cy + s * 0.85));
986    } else {
987        // ▸ apunta a la derecha (grupo plegado).
988        p.move_to((cx - s * 0.55, cy - s));
989        p.line_to((cx + s * 0.85, cy));
990        p.line_to((cx - s * 0.55, cy + s));
991    }
992    p.close_path();
993    scene.fill(Fill::NonZero, Affine::IDENTITY, &color, None, &p);
994}
995
996/// Una línea separadora tenue entre grupos de dientes del rail.
997fn rail_separator_view<Msg: Clone + 'static>(width: f32, palette: &DockRailPalette) -> View<Msg> {
998    let line = View::new(Style {
999        size: Size { width: percent(1.0_f32), height: length(1.0_f32) },
1000        ..Default::default()
1001    })
1002    .fill(palette.icon_inactive.multiply_alpha(0.30))
1003    .radius(0.5);
1004    View::new(Style {
1005        size: Size { width: length(width), height: length(11.0_f32) },
1006        flex_shrink: 0.0,
1007        align_items: Some(AlignItems::CENTER),
1008        justify_content: Some(JustifyContent::CENTER),
1009        padding: Rect {
1010            left: length(9.0_f32),
1011            right: length(9.0_f32),
1012            top: length(5.0_f32),
1013            bottom: length(5.0_f32),
1014        },
1015        ..Default::default()
1016    })
1017    .children(vec![line])
1018}
1019
1020#[allow(clippy::too_many_arguments)]
1021#[allow(clippy::too_many_arguments)]
1022fn dock_rail_view_core<Msg, FIcon, FBadge, FSep, FChev, FAct, FSec, FDrop>(
1023    items: &[DockRailItem],
1024    width: f32,
1025    side: DockRailSide,
1026    palette: &DockRailPalette,
1027    make_icon: FIcon,
1028    make_badge: FBadge,
1029    sep_before: FSep,
1030    chevron: FChev,
1031    on_activate: FAct,
1032    on_secondary: FSec,
1033    on_drop: FDrop,
1034    on_reorder: Option<ReorderFn<Msg>>,
1035) -> View<Msg>
1036where
1037    Msg: Clone + Send + Sync + 'static,
1038    FIcon: Fn(u64, f32, Color) -> View<Msg>,
1039    FBadge: Fn(u64) -> Option<DockBadge>,
1040    FSep: Fn(u64) -> bool,
1041    FChev: Fn(u64) -> Option<bool>,
1042    FAct: Fn(u64) -> Msg,
1043    FSec: Fn(u64) -> Option<Msg>,
1044    FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
1045{
1046    dock_rail_view_core_at(
1047        items,
1048        width,
1049        side,
1050        palette,
1051        make_icon,
1052        make_badge,
1053        sep_before,
1054        chevron,
1055        on_activate,
1056        on_secondary,
1057        None,
1058        None,
1059        None,
1060        on_drop,
1061        on_reorder,
1062        None,
1063    )
1064}
1065
1066/// El núcleo, con el **gesto secundario posicionado** que usa el que necesita
1067/// abrir un menú donde está el puntero (ver [`SecondaryAtFn`]).
1068#[allow(clippy::too_many_arguments)]
1069fn dock_rail_view_core_at<Msg, FIcon, FBadge, FSep, FChev, FAct, FSec, FDrop>(
1070    items: &[DockRailItem],
1071    width: f32,
1072    side: DockRailSide,
1073    palette: &DockRailPalette,
1074    make_icon: FIcon,
1075    make_badge: FBadge,
1076    sep_before: FSep,
1077    chevron: FChev,
1078    on_activate: FAct,
1079    on_secondary: FSec,
1080    on_secondary_at: Option<SecondaryAtFn<Msg>>,
1081    on_doble: Option<DobleFn<Msg>>,
1082    on_hover: Option<HoverFn<Msg>>,
1083    on_drop: FDrop,
1084    on_reorder: Option<ReorderFn<Msg>>,
1085    recibe: Option<DienteRecibe<Msg>>,
1086) -> View<Msg>
1087where
1088    Msg: Clone + Send + Sync + 'static,
1089    FIcon: Fn(u64, f32, Color) -> View<Msg>,
1090    FBadge: Fn(u64) -> Option<DockBadge>,
1091    FSep: Fn(u64) -> bool,
1092    FChev: Fn(u64) -> Option<bool>,
1093    FAct: Fn(u64) -> Msg,
1094    FSec: Fn(u64) -> Option<Msg>,
1095    FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
1096{
1097    let mirror = side == DockRailSide::InnerRight;
1098    let mut teeth: Vec<View<Msg>> = Vec::with_capacity(items.len());
1099    for item in items {
1100        // Separador de grupo ENCIMA de este diente (nunca antes del primero).
1101        if sep_before(item.id) && !teeth.is_empty() {
1102            teeth.push(rail_separator_view(width, palette));
1103        }
1104        let fg = if item.active {
1105            palette.accent
1106        } else if item.semi {
1107            // A mitad de camino entre apagado y acento: el ícono acompaña a la
1108            // columna del canto en vez de contradecirla.
1109            mezclar(palette.icon_inactive, palette.accent, SEMI_ALFA)
1110        } else {
1111            palette.icon_inactive
1112        };
1113        // **La barra del canto anclado: acento y medidor de carga, juntos.** Ver
1114        // [`barra_del_borde`] — el operador la nombró como «la barra del lado
1115        // izquierdo» y ahí es donde quiere el medidor vertical.
1116        let accent_bar = barra_del_borde(item.active, item.semi, item.carga, palette.accent);
1117        let icon_box = View::new(Style {
1118            flex_grow: 1.0,
1119            size: Size {
1120                width: percent(0.0_f32),
1121                height: length(TOOTH_H),
1122            },
1123            align_items: Some(AlignItems::CENTER),
1124            justify_content: Some(JustifyContent::CENTER),
1125            ..Default::default()
1126        })
1127        .children(vec![make_icon(item.id, ICON_PX, fg)]);
1128
1129        let id = item.id;
1130        let msg = on_activate(id);
1131        // **El hueco del canto contrario.** Ya no lleva medidor —se mudó a la
1132        // barra del canto anclado, ver [`barra_del_borde`]—, pero sigue midiendo
1133        // lo mismo: su único trabajo es que el ícono quede centrado en el diente.
1134        // Sacarlo correría los íconos de todos los rails tres píxeles.
1135        let carga_bar: View<Msg> = View::new(Style {
1136            size: Size {
1137                width: length(CARGA_W),
1138                height: length(BAR_H),
1139            },
1140            flex_shrink: 0.0,
1141            ..Default::default()
1142        });
1143        // En un sidebar derecho (mirror) la barra de acento va a la DERECHA y el
1144        // icono a la izquierda → el diente sobresale hacia el centro (a la izq.).
1145        // El medidor viaja siempre del lado contrario, así que se espeja con ella.
1146        let mut tooth_children = if mirror {
1147            vec![carga_bar, icon_box, accent_bar]
1148        } else {
1149            vec![accent_bar, icon_box, carga_bar]
1150        };
1151        // Distintivo opcional, superpuesto en la esquina externa superior — la
1152        // esquina hacia la que el diente sobresale (derecha en InnerLeft, izquierda
1153        // en InnerRight). Va `Absolute` para flotar sobre el icono sin empujarlo.
1154        if let Some(badge) = make_badge(id) {
1155            let inset = if mirror {
1156                Rect {
1157                    top: length(2.0_f32),
1158                    left: length(2.0_f32),
1159                    right: auto(),
1160                    bottom: auto(),
1161                }
1162            } else {
1163                Rect {
1164                    top: length(2.0_f32),
1165                    right: length(2.0_f32),
1166                    left: auto(),
1167                    bottom: auto(),
1168                }
1169            };
1170            tooth_children.push(
1171                View::new(Style {
1172                    position: Position::Absolute,
1173                    inset,
1174                    ..Default::default()
1175                })
1176                .children(vec![badge.view::<Msg>()]),
1177            );
1178        }
1179        // Flechita de grupo colapsable (sólo dientes-cabezal): ▾ desplegado, ▸
1180        // plegado. Va en la esquina interna-inferior y NO captura clics —el toggle
1181        // lo decide el host al activar el cabezal—. `fg` la tiñe (acento si activo).
1182        if let Some(expanded) = chevron(id) {
1183            tooth_children.push(chevron_view(expanded, fg, mirror));
1184        }
1185        let mut tooth = View::new(Style {
1186            flex_direction: FlexDirection::Row,
1187            size: Size {
1188                width: percent(1.0_f32),
1189                height: length(TOOTH_H),
1190            },
1191            flex_shrink: 0.0,
1192            align_items: Some(AlignItems::CENTER),
1193            ..Default::default()
1194        })
1195        .hover_fill(palette.bg_hover)
1196        // Un diente REPRESENTA un panel: semánticamente es una pestaña, y su
1197        // estado activo es lo que el lector necesita para ubicarse. El nombre
1198        // sale de `item.label` porque el ícono lo pinta un closure opaco y no
1199        // hay texto en el árbol de dónde derivarlo.
1200        .role(llimphi_compositor::Role::Tab)
1201        .aria_pressed(item.active)
1202        // Click al SOLTAR activa; arrastrar mueve el grupo (payload=grupo). Que
1203        // active al soltar y no en el press lo decide el runtime por el payload
1204        // (ver `handle_left_press`): un diente es un drag de transferencia, así
1205        // que un toque activa al levantar el botón y un arrastre no activa nada.
1206        .on_click_at(move |_, _, _, _| Some(msg.clone()));
1207        // Gesto **secundario** (derecho/medio), si el host lo declara: el mismo trato
1208        // que los subdientes. Sin esto un diente sólo sabe activarse, y el único modo
1209        // de sacarlo del rail es entrar al panel que él mismo abre a buscar el botón.
1210        if let Some(sec) = on_secondary(id) {
1211            tooth = tooth.on_right_click(sec.clone()).on_middle_click(sec);
1212        }
1213        // **Y el secundario POSICIONADO gana sobre el pelado**, si el host lo
1214        // declara: es el que abre un menú donde está el puntero. Los dos pueden
1215        // convivir en el mismo rail —un diente que sólo se cierra al lado de uno
1216        // que ofrece sus verbos— y por eso se cablea después: el derecho queda en
1217        // el menú y el medio sigue siendo el atajo de cerrar del que lo tenga.
1218        if let Some(f) = on_secondary_at.clone() {
1219            tooth = tooth.on_right_click_screen(move |x, y, _, _| (f)(id, x, y));
1220        }
1221        // Doble-click del diente, si el host lo declara. **Exclusivo**: el
1222        // runtime retiene el click simple hasta que vence la ventana del doble y
1223        // lo descarta si el segundo llega, así el doble no despliega y repliega
1224        // el panel de paso (ver [`DobleFn`]).
1225        if let Some(doble) = on_doble.as_ref().and_then(|f| (f)(id)) {
1226            tooth = tooth.on_double_tap(doble).double_tap_exclusive();
1227        }
1228        // **Hover del diente** (ver [`HoverFn`]): entra y sale. Va sobre el nodo
1229        // del diente, que es el único con `hover_fill` y por lo tanto el único
1230        // que el hit-test de hover del runtime encuentra.
1231        if let Some(f) = on_hover.clone() {
1232            if let Some(entra) = (f)(id, true) {
1233                tooth = tooth.on_pointer_enter(entra);
1234            }
1235            if let Some(sale) = (f)(id, false) {
1236                tooth = tooth.on_pointer_leave(sale);
1237            }
1238        }
1239        if let Some(nombre) = &item.label {
1240            tooth = tooth.aria_label(nombre.clone());
1241        }
1242        // Lo que viaja al arrastrar es **el grupo** del diente (o el diente solo, si
1243        // no tiene grupo). De ahí que el payload no sea `id`: agarrar cualquier
1244        // diente de los escritorios mueve los escritorios, no ese escritorio.
1245        let arrastra = item.arrastra();
1246        tooth = tooth
1247            .draggable_at(|phase, _, _, _, _| match phase {
1248                DragPhase::Move | DragPhase::End => None,
1249            })
1250            .drag_payload(arrastra)
1251            .children(tooth_children);
1252        // La forma del diente: redondea el lado que sobresale hacia el contenido. La
1253        // barra de acento marca el borde interno; el diente abre hacia el lado opuesto
1254        // (derecha en InnerLeft, izquierda en InnerRight) → look de pestaña que sale
1255        // del rail, no un rectángulo plano. La comparten el activo y —cuando el host
1256        // pide `pill_inactivo`— los inactivos: la forma es del DIENTE, no del estado.
1257        let corners = if mirror {
1258            (8.0, 0.0, 0.0, 8.0)
1259        } else {
1260            (0.0, 8.0, 8.0, 0.0)
1261        };
1262        if item.active {
1263            tooth = tooth.radius_corners(corners.0, corners.1, corners.2, corners.3);
1264            // Los mismos radios en f64, para que el brillo se recorte con la forma
1265            // real de la pestaña y no rebase por la esquina que sobresale.
1266            let corners_f64 = (
1267                corners.0 as f64,
1268                corners.1 as f64,
1269                corners.2 as f64,
1270                corners.3 as f64,
1271            );
1272            if palette.glass > 0.0 {
1273                // Cristal esmerilado SÓLO en la pestaña activa: tinte translúcido
1274                // de `bg_active` para que el fondo asome, + `backdrop_blur` que lo
1275                // borronea. Con la franja transparente (ver abajo), el diente activo
1276                // es el único vidrio del rail; los inactivos quedan icono al aire.
1277                tooth = tooth
1278                    .fill(palette.bg_active.multiply_alpha(0.55))
1279                    .backdrop_blur(palette.glass);
1280            } else {
1281                tooth = tooth.fill(palette.bg_active);
1282            }
1283            if let Some(paradas) = palette.bg_active_gradiente {
1284                let alfa = if palette.glass > 0.0 { 0.55 } else { 1.0 };
1285                let paradas = paradas.map(|c| c.multiply_alpha(alfa));
1286                use llimphi_compositor::peniko::{kurbo::Point, Gradient};
1287                // Uno por pantalla si el host dijo dónde cae la superficie
1288                // (`llimphi_theme::fijar_tramo_de_pantalla`): el diente muestra
1289                // el tramo que le toca por su posición. Si no, recorre el diente.
1290                tooth = match llimphi_theme::puntas_en_superficie() {
1291                    Some((a, b)) => tooth.fill_gradient_superficie(
1292                        Gradient::new_linear(Point::new(a.0, a.1), Point::new(b.0, b.1))
1293                            .with_stops(paradas.as_slice()),
1294                    ),
1295                    None => tooth.fill_gradient(
1296                        Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 0.0))
1297                            .with_stops(paradas.as_slice()),
1298                    ),
1299                };
1300            }
1301            // El brillo va DESPUÉS del fill (el painter pinta encima) y sólo acá:
1302            // los dientes inactivos se quedan quietos, que es el punto — lo que
1303            // distingue al activo es que está vivo.
1304            if let Some(t) = palette.brillo_t {
1305                tooth = tooth.paint_with(move |scene, _ts, rect| paint_brillo(scene, rect, t, corners_f64));
1306            }
1307        } else if let Some(pill) = palette.pill_inactivo {
1308            // Diente inactivo CON cuerpo propio: mismo relleno y misma forma que el
1309            // activo, sin la barra de acento (esa sigue distinguiendo al activo). Es
1310            // lo que hace que un rail de vidrio se lea como una fila de dientes y no
1311            // como íconos sueltos flotando sobre el escritorio.
1312            tooth = tooth
1313                .fill(pill)
1314                .radius_corners(corners.0, corners.1, corners.2, corners.3);
1315        }
1316        // Reorden por arrastre: el diente es TAMBIÉN drop target. Soltar el payload
1317        // arrastrado sobre ÉL da la posición destino (lo que el `on_drop` a nivel-rail
1318        // no podía). El drop sobre un diente concreto es el target más interno → tiene
1319        // prioridad sobre el del rail; soltar en el hueco de abajo sigue yendo al rail.
1320        if on_reorder.is_some() || recibe.is_some() {
1321            let r = on_reorder.clone();
1322            // El destino también es el GRUPO del diente sobre el que se soltó: los
1323            // dos lados del `on_reorder` hablan del mismo idioma. Sin grupos, sigue
1324            // siendo `(diente arrastrado, diente destino)` como antes.
1325            let destino = arrastra;
1326            // …y lo AJENO va con el id del diente, que es lo que el grupo no
1327            // puede decir (ver [`DienteRecibe`]). Se pregunta primero: un
1328            // arrastre que no es un diente nunca es un reorden.
1329            let ajeno = recibe.as_ref().map(|x| (x.al_soltar.clone(), item.id));
1330            tooth = tooth
1331                .on_drop(move |payload| {
1332                    if let Some((f, id)) = ajeno.as_ref() {
1333                        if let Some(m) = (f)(payload, *id) {
1334                            return Some(m);
1335                        }
1336                    }
1337                    r.as_ref().and_then(|r| (r)(payload, destino))
1338                })
1339                .drop_hover_fill(palette.bg_hover);
1340            // El resorte del diente: posarse encima lo despliega, para poder
1341            // soltar adentro de su panel en vez de sobre el diente a ciegas.
1342            if let Some(m) = recibe
1343                .as_ref()
1344                .and_then(|x| x.al_posarse.as_ref())
1345                .and_then(|f| (f)(item.id))
1346            {
1347                tooth = tooth.on_drop_hover(m);
1348            }
1349        }
1350        teeth.push(tooth);
1351    }
1352
1353    // La franja: sólo del alto de los dientes (el hueco de abajo lo deja
1354    // libre para que el área central lo aproveche si el rail flota como
1355    // overlay). Es además el drop target del lado. Con glass, el fondo de la
1356    // franja se vuelve transparente — «el resto de la barra» deja ver lo que
1357    // haya detrás y sólo los dientes (el activo) portan el vidrio.
1358    let rail_bg = if palette.glass > 0.0 {
1359        palette.bg_rail.multiply_alpha(0.0)
1360    } else {
1361        palette.bg_rail
1362    };
1363    View::new(Style {
1364        flex_direction: FlexDirection::Column,
1365        size: Size {
1366            width: length(width),
1367            height: auto(),
1368        },
1369        flex_shrink: 0.0,
1370        ..Default::default()
1371    })
1372    .fill(rail_bg)
1373    .radius(5.0)
1374    .on_drop(on_drop)
1375    .drop_hover_fill(palette.bg_hover)
1376    .children(teeth)
1377}
1378
1379#[cfg(test)]
1380mod tests {
1381    use super::*;
1382
1383    fn palette() -> DockRailPalette {
1384        DockRailPalette {
1385            bg_rail: Color::BLACK,
1386            bg_active: Color::BLACK,
1387            bg_hover: Color::BLACK,
1388            accent: Color::WHITE,
1389            icon_inactive: Color::WHITE,
1390            glass: 0.0,
1391            brillo_t: None,
1392            pill_inactivo: None,
1393            bg_active_gradiente: None,
1394        }
1395    }
1396
1397
1398    fn empty_icon(_id: u64, _size: f32, _color: Color) -> View<i32> {
1399        View::new(Style::default())
1400    }
1401
1402    /// Un diente con badge gana exactamente un hijo extra (el overlay absoluto)
1403    /// frente a uno sin badge — certifica que el distintivo se inserta en el árbol.
1404    #[test]
1405    fn badge_agrega_hijo_overlay() {
1406        let items = [
1407            DockRailItem::new(0, false), // con badge
1408            DockRailItem::new(1, false), // sin badge
1409        ];
1410        let rail = dock_rail_view_badged(
1411            &items,
1412            44.0,
1413            DockRailSide::InnerLeft,
1414            &palette(),
1415            empty_icon,
1416            |id| (id == 0).then_some(DockBadge::Count(5, BadgeKind::Error)),
1417            |_id| 0i32,
1418            |_p| None,
1419        );
1420        // rail.children = dientes; cada diente lleva [accent_bar, icon_box,
1421        // carga_bar] (+ badge). El medidor de carga cuenta **siempre**, tenga o no
1422        // dato: sin él el ícono se correría tres píxeles cada vez que algo empieza
1423        // o termina de gastar (ver `barra_de_carga`).
1424        let teeth = &rail.children;
1425        assert_eq!(teeth.len(), 2);
1426        assert_eq!(teeth[0].children.len(), 4, "diente con badge: +1 hijo");
1427        assert_eq!(teeth[1].children.len(), 3, "diente sin badge: barra + icono + medidor");
1428    }
1429
1430    /// El **gesto secundario** llega al diente que lo declara, y sólo a ése. Presencia
1431    /// y ausencia en el mismo test: un cableado que se lo pusiera a todos los dientes
1432    /// se ve igual de bien desde la mitad de arriba.
1433    #[test]
1434    fn el_gesto_secundario_va_al_diente_que_lo_declara() {
1435        let items = [DockRailItem::new(10, false), DockRailItem::new(20, false)];
1436        let rail = dock_rail_view_full(
1437            &items,
1438            44.0,
1439            DockRailSide::InnerLeft,
1440            &palette(),
1441            empty_icon,
1442            |_| None,
1443            |_| false,
1444            |_| 0i32,
1445            |id| (id == 20).then_some(7i32),
1446            |_| None,
1447            None,
1448        );
1449        assert!(
1450            llimphi_compositor::raro!(rail.children[0], on_right_click).is_none(),
1451            "el diente que no lo declara queda sin gesto secundario"
1452        );
1453        assert_eq!(
1454            llimphi_compositor::raro!(rail.children[1], on_right_click),
1455            Some(7i32),
1456            "el que lo declara emite su mensaje al clic derecho"
1457        );
1458        assert_eq!(
1459            llimphi_compositor::raro!(rail.children[1], on_middle_click),
1460            Some(7i32),
1461            "y al del medio: el mismo gesto por las dos vías"
1462        );
1463    }
1464
1465    /// **El medidor vive en la barra del canto ANCLADO, la misma del acento** —y
1466    /// se sigue distinguiendo de él.
1467    ///
1468    /// Cambió de lado el 2026-08-28, por pedido del operador: *«los dientes de
1469    /// arriba, que tienen una barra del lado izquierdo, sobre esa barra es que yo
1470    /// decía poner el medidor de cpu vertical»*. La versión anterior lo había
1471    /// mandado al canto contrario justamente para separarlo del acento; lo que se
1472    /// separa ahora es la **geometría** (el acento llena la columna entera, la
1473    /// carga la llena en parte y desde abajo), no el lugar.
1474    ///
1475    /// Lo que se fija: el medidor está en el hijo del canto anclado —el primero
1476    /// en un rail izquierdo, el último en uno espejado— y el hueco del canto
1477    /// contrario **no pinta nada**.
1478    #[test]
1479    fn el_medidor_va_en_la_barra_del_canto_anclado() {
1480        let item = DockRailItem { carga: Some((0.5, Color::WHITE)), ..DockRailItem::new(0, true) };
1481        let izq = dock_rail_view_side(
1482            &[item.clone()],
1483            44.0,
1484            DockRailSide::InnerLeft,
1485            &palette(),
1486            empty_icon,
1487            |_id| 0i32,
1488            |_p| None,
1489        );
1490        let diente = &izq.children[0];
1491        // Sidebar izquierdo: la barra del canto anclado es el PRIMER hijo, y es la
1492        // que lleva el medidor. El ícono va en el medio y es el que crece.
1493        assert_eq!(diente.children[0].style.size.height, length(BAR_H));
1494        assert!(diente.children[0].painter.is_some(), "el medidor con dato pinta, y pinta acá");
1495        assert_eq!(
1496            diente.children[2].style.size.height,
1497            length(BAR_H),
1498            "el hueco del canto contrario sigue reservando su lugar (centra el ícono)"
1499        );
1500        assert!(diente.children[2].painter.is_none(), "…pero no pinta nada");
1501
1502        // Espejado (sidebar derecho), la barra anclada es el ÚLTIMO hijo y el
1503        // medidor viaja con ella.
1504        let der = dock_rail_view_side(
1505            &[item],
1506            44.0,
1507            DockRailSide::InnerRight,
1508            &palette(),
1509            empty_icon,
1510            |_id| 0i32,
1511            |_p| None,
1512        );
1513        let diente = &der.children[0];
1514        assert!(diente.children[2].painter.is_some(), "espejado, el medidor va último");
1515        assert!(diente.children[0].painter.is_none());
1516    }
1517
1518    /// Sin dato, la barra **ocupa su lugar igual** y no pinta carga: si apareciera
1519    /// y desapareciera, el ícono de todos los dientes se correría tres píxeles cada
1520    /// vez que algo empieza o termina de gastar.
1521    #[test]
1522    fn sin_carga_la_barra_reserva_su_lugar_y_no_pinta() {
1523        let items = [DockRailItem::new(0, false)];
1524        let rail = dock_rail_view(&items, 44.0, &palette(), empty_icon, |_id| 0i32, |_p| None);
1525        let barra = &rail.children[0].children[0];
1526        assert_eq!(barra.style.size.height, length(BAR_H));
1527        assert!(barra.painter.is_none(), "sin dato no se pinta nada");
1528        let hueco = &rail.children[0].children[2];
1529        assert_eq!(hueco.style.size.height, length(BAR_H));
1530        assert!(hueco.painter.is_none());
1531    }
1532
1533    /// Sin `make_badge` (las funciones públicas viejas), ningún diente lleva badge:
1534    /// se queda con sus tres hijos fijos (barra de acento, ícono, medidor de carga).
1535    #[test]
1536    fn sin_badge_tres_hijos() {
1537        let items = [DockRailItem::new(0, true)];
1538        let rail = dock_rail_view(&items, 44.0, &palette(), empty_icon, |_id| 0i32, |_p| None);
1539        assert_eq!(rail.children[0].children.len(), 3);
1540    }
1541
1542    /// Con `on_reorder`, cada diente es TAMBIÉN drop target (`on_drop`), así soltar
1543    /// uno sobre otro da la posición destino. Sin él, los dientes no reciben drop (sólo
1544    /// el rail entero). Certifica el reorden por-diente sin depender del runtime de drag.
1545    #[test]
1546    fn reorder_hace_cada_diente_drop_target() {
1547        let items = [
1548            DockRailItem::new(10, false),
1549            DockRailItem::new(20, false),
1550        ];
1551        // Sin reorder: ningún diente es drop target.
1552        let sin = dock_rail_view(&items, 44.0, &palette(), empty_icon, |_| 0i32, |_| None);
1553        assert!(llimphi_compositor::raro!(sin.children[0], on_drop).is_none(), "sin reorder: el diente no recibe drop");
1554        // Con reorder: cada diente recibe drop y emite (payload, target) → aquí lo
1555        // codificamos como payload*100 + target para verificar que llega el par correcto.
1556        let reorder: ReorderFn<i32> = Arc::new(|payload, target| Some((payload * 100 + target) as i32));
1557        let con = dock_rail_view_full(
1558            &items,
1559            44.0,
1560            DockRailSide::InnerLeft,
1561            &palette(),
1562            empty_icon,
1563            |_| None,
1564            |_| false,
1565            |_| 0i32,
1566            |_| None,
1567            |_| None,
1568            Some(reorder),
1569        );
1570        // Soltar el diente 10 sobre el diente 20 (índice 1) → 10*100 + 20 = 1020.
1571        let drop_en_20 = llimphi_compositor::raro!(con.children[1], on_drop).expect("diente es drop target");
1572        assert_eq!(drop_en_20(10), Some(1020), "el drop da (payload=10, target=20)");
1573    }
1574
1575    /// Con `glass > 0`: la franja del rail se pinta transparente (alpha 0) y
1576    /// **sólo la pestaña activa** lleva `backdrop_blur`; las inactivas no. Sin
1577    /// glass, la franja mantiene su fondo opaco y nadie borronea el fondo.
1578    #[test]
1579    fn glass_transparenta_franja_y_borronea_solo_el_activo() {
1580        let items = [
1581            DockRailItem::new(0, true),  // activo → vidrio
1582            DockRailItem::new(1, false), // inactivo → sin blur
1583        ];
1584        // Sin glass: franja opaca, ningún diente borronea.
1585        let mut pal = palette();
1586        let sin = dock_rail_view(&items, 44.0, &pal, empty_icon, |_| 0i32, |_| None);
1587        assert_eq!(sin.fill.map(|c| c.components[3]), Some(1.0), "sin glass: franja opaca");
1588        assert!(llimphi_compositor::raro!(sin.children[0], backdrop_blur).is_none(), "sin glass: el activo no borronea");
1589
1590        // Con glass: franja transparente + blur SÓLO en el activo.
1591        pal.glass = 6.0;
1592        let con = dock_rail_view(&items, 44.0, &pal, empty_icon, |_| 0i32, |_| None);
1593        assert_eq!(con.fill.map(|c| c.components[3]), Some(0.0), "con glass: franja transparente");
1594        assert_eq!(llimphi_compositor::raro!(con.children[0], backdrop_blur), Some(6.0), "con glass: el activo es vidrio");
1595        assert!(llimphi_compositor::raro!(con.children[1], backdrop_blur).is_none(), "con glass: el inactivo NO borronea");
1596    }
1597
1598    /// Sin `pill_inactivo` (el default) los dientes inactivos no pintan fondo; con él,
1599    /// cada inactivo gana el relleno pedido **y la misma forma que el activo**.
1600    ///
1601    /// Es lo que sostiene el modo vidrio: cuando la franja no pinta fondo, un diente
1602    /// sin relleno queda como un ícono flotando en el aire. El test mira el alfa además
1603    /// del color porque el bug que lo motivó no era «no hay pill» sino «hay un pill
1604    /// translúcido», que a ojo se lee igual de mal pero pasa cualquier assert de
1605    /// presencia.
1606    #[test]
1607    fn pill_inactivo_da_cuerpo_a_los_dientes_apagados() {
1608        let items = [DockRailItem::new(0, true), DockRailItem::new(1, false)];
1609        // Default: el inactivo no pinta fondo.
1610        let pal = palette();
1611        let sin = dock_rail_view(&items, 44.0, &pal, empty_icon, |_| 0i32, |_| None);
1612        assert!(sin.children[1].fill.is_none(), "sin pill_inactivo: el inactivo no pinta fondo");
1613
1614        // Con pill: el inactivo lo lleva, opaco, y con las esquinas del diente.
1615        let mut con_pill = palette();
1616        con_pill.pill_inactivo = Some(Color::from_rgba8(20, 30, 40, 255));
1617        let con = dock_rail_view(&items, 44.0, &con_pill, empty_icon, |_| 0i32, |_| None);
1618        let f = con.children[1].fill.expect("el inactivo pinta su pill");
1619        assert_eq!(f.components[3], 1.0, "el pill del inactivo es OPACO, no un fantasma");
1620        assert_eq!(
1621            con.children[1].radius, con.children[0].radius,
1622            "la forma es del diente, no del estado: inactivo y activo redondean igual"
1623        );
1624    }
1625
1626    /// Un diente con grupo arrastra **a su grupo**, no a sí mismo — y el destino que
1627    /// recibe el `on_reorder` también es un grupo. Sin grupo, todo sigue igual.
1628    ///
1629    /// Es la propiedad entera de la feature: agarrar cualquier escritorio tiene que
1630    /// mover los escritorios, no ese escritorio.
1631    #[test]
1632    fn un_diente_agrupado_arrastra_a_su_grupo() {
1633        const WS: u64 = 900;
1634        let items = [
1635            DockRailItem::new(1, false).en_grupo(WS),
1636            DockRailItem::new(2, false).en_grupo(WS),
1637            DockRailItem::new(77, false), // suelto: es su propio grupo
1638        ];
1639        assert_eq!(items[0].arrastra(), WS);
1640        assert_eq!(items[1].arrastra(), WS, "el otro escritorio arrastra lo mismo");
1641        assert_eq!(items[2].arrastra(), 77, "sin grupo, el diente se arrastra a sí mismo");
1642
1643        let reorder: ReorderFn<i32> = Arc::new(|a, d| Some((a * 1000 + d) as i32));
1644        let rail = dock_rail_view_full(
1645            &items,
1646            44.0,
1647            DockRailSide::InnerLeft,
1648            &palette(),
1649            empty_icon,
1650            |_| None,
1651            |_| false,
1652            |_| 0i32,
1653            |_| None,
1654            |_| None,
1655            Some(reorder),
1656        );
1657        // Soltar sobre el SEGUNDO escritorio da el grupo, no el diente: los dos
1658        // lados del `on_reorder` hablan el mismo idioma.
1659        let sobre_ws = llimphi_compositor::raro!(rail.children[1], on_drop).expect("drop target");
1660        assert_eq!(sobre_ws(WS), Some((WS * 1000 + WS) as i32));
1661        // Y soltar el grupo de escritorios sobre el diente suelto da (WS, 77).
1662        let sobre_suelto = llimphi_compositor::raro!(rail.children[2], on_drop).expect("drop target");
1663        assert_eq!(sobre_suelto(WS), Some((WS * 1000 + 77) as i32));
1664    }
1665
1666    /// `mover_grupo` **mueve**, no intercambia.
1667    ///
1668    /// Intercambiar es lo fácil de programar y lo que nadie espera de un arrastre:
1669    /// llevar el último grupo al principio con un swap le deja el primero al final,
1670    /// que no es lo que uno pidió. El test fija la semántica correcta y los bordes
1671    /// (ids desconocidos, soltar sobre sí mismo) que no deben tocar nada.
1672    #[test]
1673    fn mover_grupo_inserta_y_no_intercambia() {
1674        let orden = [10_u64, 20, 30, 40];
1675        // El último al principio: los demás CORREN, no se intercambian.
1676        assert_eq!(mover_grupo(&orden, 40, 10), vec![40, 10, 20, 30]);
1677        // Uno del medio al final.
1678        assert_eq!(mover_grupo(&orden, 20, 40), vec![10, 30, 40, 20]);
1679        // Vecinos.
1680        assert_eq!(mover_grupo(&orden, 10, 20), vec![20, 10, 30, 40]);
1681        // No-ops: sobre sí mismo, o con un id que no está en la lista.
1682        assert_eq!(mover_grupo(&orden, 30, 30), orden.to_vec());
1683        assert_eq!(mover_grupo(&orden, 99, 10), orden.to_vec());
1684        assert_eq!(mover_grupo(&orden, 10, 99), orden.to_vec());
1685        // El orden nunca pierde ni duplica grupos.
1686        let nuevo = mover_grupo(&orden, 40, 10);
1687        let mut ordenado = nuevo.clone();
1688        ordenado.sort_unstable();
1689        assert_eq!(ordenado, orden.to_vec(), "se movió, no se inventó ni se perdió nada");
1690    }
1691
1692    /// Un diente-cabezal (chevron `Some`) gana un hijo overlay extra (la flechita)
1693    /// frente a un diente normal (chevron `None`); el cabezal no es drop target ni
1694    /// captura clics distintos —sigue activando el diente—.
1695    #[test]
1696    fn chevron_agrega_flechita_solo_en_el_cabezal() {
1697        let items = [
1698            DockRailItem::new(0, false), // cabezal (chevron)
1699            DockRailItem::new(1, false), // diente normal
1700        ];
1701        let rail = dock_rail_view_collapsible(
1702            &items,
1703            44.0,
1704            DockRailSide::InnerLeft,
1705            &palette(),
1706            empty_icon,
1707            |_| false,
1708            |id| (id == 0).then_some(true), // el 0 es cabezal desplegado
1709            |_id| 0i32,
1710            |_p| None,
1711        );
1712        assert_eq!(rail.children.len(), 2);
1713        assert_eq!(rail.children[0].children.len(), 4, "cabezal: barra+icono+medidor+flechita");
1714        assert_eq!(rail.children[1].children.len(), 3, "diente normal: barra+icono+medidor");
1715    }
1716
1717    /// En `InnerRight` el badge se ancla a la izquierda (esquina externa espejada).
1718    #[test]
1719    fn badge_espeja_inset_en_inner_right() {
1720        use llimphi_compositor::llimphi_layout::taffy::style_helpers::TaffyAuto;
1721        let items = [DockRailItem::new(0, false)];
1722        let rail = dock_rail_view_badged(
1723            &items,
1724            44.0,
1725            DockRailSide::InnerRight,
1726            &palette(),
1727            empty_icon,
1728            |_id| Some(DockBadge::Dot(BadgeKind::Success)),
1729            |_id| 0i32,
1730            |_p| None,
1731        );
1732        let badge_overlay = rail.children[0].children.last().expect("overlay presente");
1733        // Espejo: left fijo, right auto.
1734        assert_ne!(badge_overlay.style.inset.left, TaffyAuto::AUTO);
1735        assert_eq!(badge_overlay.style.inset.right, TaffyAuto::AUTO);
1736    }
1737
1738    /// El brillo es un **barrido periódico**, no una animación continua: casi
1739    /// todo el ciclo el diente está quieto. Es la propiedad de la que depende que
1740    /// esto sea barato, así que se vigila con un número, no con la intención.
1741    #[test]
1742    fn el_brillo_pasa_poco_y_descansa_mucho() {
1743        let encendido = (0..600)
1744            .filter(|i| brillo_en_curso(*i as f64 * BRILLO_CICLO_S / 100.0))
1745            .count();
1746        let fraccion = encendido as f64 / 600.0;
1747        let esperado = BRILLO_BARRIDO_S / BRILLO_CICLO_S;
1748        assert!(
1749            (fraccion - esperado).abs() < 0.02,
1750            "el brillo debería estar encendido ~{:.0} % del tiempo y está {:.0} %",
1751            esperado * 100.0,
1752            fraccion * 100.0
1753        );
1754        assert!(esperado < 0.2, "más de un quinto del tiempo ya no es «tranquilo»");
1755    }
1756
1757    /// Arranca encendido en `t = 0` y se apaga al terminar la pasada, para
1758    /// cualquier ciclo — no sólo el primero.
1759    #[test]
1760    fn el_barrido_se_apaga_al_terminar_la_pasada() {
1761        for ciclo in 0..4 {
1762            let base = ciclo as f64 * BRILLO_CICLO_S;
1763            assert!(brillo_en_curso(base), "empieza la pasada del ciclo {ciclo}");
1764            assert!(brillo_en_curso(base + BRILLO_BARRIDO_S * 0.5), "mitad de la pasada");
1765            assert!(!brillo_en_curso(base + BRILLO_BARRIDO_S + 0.001), "recién terminada");
1766            assert!(!brillo_en_curso(base + BRILLO_CICLO_S * 0.9), "bien adentro del descanso");
1767        }
1768    }
1769
1770    /// Un reloj negativo no puede dejar el brillo prendido para siempre:
1771    /// `rem_euclid` mantiene la fase en `[0, CICLO)` también con `t < 0`.
1772    #[test]
1773    fn el_barrido_aguanta_un_reloj_negativo() {
1774        assert!(!brillo_en_curso(-BRILLO_CICLO_S * 0.5));
1775        assert!(brillo_en_curso(-BRILLO_CICLO_S));
1776    }
1777
1778    /// Sin `brillo_t` el diente activo no gana ningún painter — el look de
1779    /// siempre para todo consumidor que no lo pida.
1780    #[test]
1781    fn sin_reloj_no_hay_painter_en_el_activo() {
1782        let items = [DockRailItem::new(0, true), DockRailItem::new(1, false)];
1783        let pal = palette();
1784        assert!(
1785            DockRailPalette::from_theme(&Theme::dark()).brillo_t.is_none(),
1786            "el default de la paleta es sin brillo"
1787        );
1788        let v: View<i32> = dock_rail_view(&items, 44.0, &pal, empty_icon, |id| id as i32, |_p| None);
1789        assert!(
1790            v.children.iter().all(|t| t.painter.is_none()),
1791            "ningún diente pinta nada extra sin reloj"
1792        );
1793    }
1794
1795    /// Con reloj, **sólo el activo** lo lleva: los demás se quedan tranquilos.
1796    #[test]
1797    fn con_reloj_solo_brilla_el_activo() {
1798        let items = [
1799            DockRailItem::new(0, false),
1800            DockRailItem::new(1, true),
1801            DockRailItem::new(2, false),
1802        ];
1803        let mut pal = palette();
1804        pal.brillo_t = Some(0.2);
1805        let v: View<i32> = dock_rail_view(&items, 44.0, &pal, empty_icon, |id| id as i32, |_p| None);
1806        let con_painter: Vec<usize> = v
1807            .children
1808            .iter()
1809            .enumerate()
1810            .filter(|(_, t)| t.painter.is_some())
1811            .map(|(i, _)| i)
1812            .collect();
1813        assert_eq!(con_painter, vec![1], "sólo el diente activo (índice 1) brilla");
1814    }
1815}