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-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}