tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
widgets/dock-rail/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
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 { id: 0, active: true }, DockRailItem { id: 1, active: 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
38use std::sync::Arc;
39
40use llimphi_ui::llimphi_layout::taffy::{
41 prelude::{auto, length, percent, FlexDirection, Position, Size, Style},
42 AlignItems, JustifyContent, Rect,
43};
44use llimphi_ui::llimphi_raster::peniko::Color;
45use llimphi_ui::{DragPhase, PaintRect, View};
46use llimphi_theme::Theme;
47
48pub use llimphi_widget_badge::BadgeKind;
49use llimphi_widget_badge::{count_badge_view, dot_badge_view};
50
51/// Alto de cada diente (px).
52const TOOTH_H: f32 = 42.0;
53/// Alto de la barra de acento (px) — un poco menor que el diente para
54/// dejar aire arriba y abajo.
55const BAR_H: f32 = 40.0;
56/// Tamaño del icono que se le pide al caller (px).
57const ICON_PX: f32 = 20.0;
58
59/// Paleta del rail.
60#[derive(Debug, Clone, Copy)]
61pub struct DockRailPalette {
62 /// Fondo de la franja del rail.
63 pub bg_rail: Color,
64 /// Fondo del diente activo.
65 pub bg_active: Color,
66 /// Fondo al hover (diente) y al sobrevolar un drop válido (rail).
67 pub bg_hover: Color,
68 /// Color del acento: barra del diente activo + su icono.
69 pub accent: Color,
70 /// Color del icono de un diente inactivo.
71 pub icon_inactive: Color,
72 /// Sigma del **cristal esmerilado** del diente activo (px, `0.0` = sin
73 /// glass, el default). Cuando es `> 0`, la franja del rail deja de pintar
74 /// su fondo (se vuelve **transparente**, así asoma lo que haya detrás) y
75 /// **sólo la pestaña activa** se pinta como vidrio: `backdrop_blur(glass)`
76 /// + un tinte translúcido de `bg_active`. Es un atributo del *look* —lo
77 /// enciende el theme (p. ej. «mirada» con `glass_blur > 0`)— no un toggle
78 /// del widget; los dientes inactivos quedan icono sobre el vacío.
79 pub glass: f32,
80}
81
82impl DockRailPalette {
83 pub fn from_theme(t: &Theme) -> Self {
84 Self {
85 bg_rail: t.bg_panel_alt,
86 bg_active: t.bg_selected,
87 bg_hover: t.bg_row_hover,
88 accent: t.accent,
89 icon_inactive: t.fg_muted,
90 // Sin glass por defecto: el look opaco de siempre. El host que corre
91 // bajo un theme con cristal (mirada `glass_blur > 0`) lo sube.
92 glass: 0.0,
93 }
94 }
95}
96
97/// Un diente del rail: su id opaco y si está activo.
98#[derive(Debug, Clone, Copy)]
99pub struct DockRailItem {
100 pub id: u64,
101 pub active: bool,
102}
103
104/// Distintivo ("bubble number") que se superpone en la esquina externa de un
105/// diente — la esquina hacia la que el diente *sobresale*. Reusa el widget
106/// [`llimphi_widget_badge`]: un chip con número (`Count`) o un punto de estado
107/// (`Dot`). Es `Copy` para encajar en cierres `Fn(u64) -> Option<DockBadge>`
108/// sin clonar; usa [`dock_rail_view_badged`] para pintarlos.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum DockBadge {
111 /// Chip ovalado con un número adentro (`>= 100` se muestra como "99+").
112 Count(u32, BadgeKind),
113 /// Punto sólido sin número — "hay algo" / estado de conexión.
114 Dot(BadgeKind),
115}
116
117impl DockBadge {
118 fn view<Msg: Clone + 'static>(self) -> View<Msg> {
119 match self {
120 DockBadge::Count(n, kind) => count_badge_view(n, kind),
121 DockBadge::Dot(kind) => dot_badge_view(kind),
122 }
123 }
124}
125
126/// A qué borde se pega el rail — decide hacia dónde **sobresalen** los dientes.
127/// `InnerLeft` (default) es para un sidebar a la **izquierda**: la barra de acento
128/// queda a la izquierda y el diente abre hacia la derecha (hacia el centro).
129/// `InnerRight` espeja todo para un sidebar a la **derecha**: barra a la derecha,
130/// el diente abre hacia la izquierda (también hacia el centro). El uso canónico de
131/// cosmos usa el default; las apps con dientes a ambos lados pasan el lado.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
133pub enum DockRailSide {
134 /// Sidebar izquierdo: dientes sobresalen hacia la derecha.
135 #[default]
136 InnerLeft,
137 /// Sidebar derecho: dientes sobresalen hacia la izquierda (espejado).
138 InnerRight,
139}
140
141/// Construye el rail de dientes.
142///
143/// - `items`: en el orden en que se quieren mostrar (el widget no
144/// reordena).
145/// - `width`: ancho de la franja del rail (px).
146/// - `make_icon(id, size, color)`: dibuja el icono del item con el color
147/// ya resuelto (acento si activo, atenuado si no).
148/// - `on_activate(id)`: `Msg` al clickear (en el press).
149/// - `on_drop(payload)`: `Msg` opcional cuando se suelta un diente
150/// (cualquier `id`) sobre este rail.
151pub fn dock_rail_view<Msg, FIcon, FAct, FDrop>(
152 items: &[DockRailItem],
153 width: f32,
154 palette: &DockRailPalette,
155 make_icon: FIcon,
156 on_activate: FAct,
157 on_drop: FDrop,
158) -> View<Msg>
159where
160 Msg: Clone + Send + Sync + 'static,
161 FIcon: Fn(u64, f32, Color) -> View<Msg>,
162 FAct: Fn(u64) -> Msg,
163 FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
164{
165 dock_rail_view_side(
166 items,
167 width,
168 DockRailSide::InnerLeft,
169 palette,
170 make_icon,
171 on_activate,
172 on_drop,
173 )
174}
175
176/// Como [`dock_rail_view`] pero eligiendo el [`DockRailSide`]: en `InnerRight` el
177/// diente se **espeja** (barra de acento a la derecha, esquinas redondeadas a la
178/// izquierda) para un sidebar a la derecha. `dock_rail_view` es el atajo a
179/// `InnerLeft`.
180pub fn dock_rail_view_side<Msg, FIcon, FAct, FDrop>(
181 items: &[DockRailItem],
182 width: f32,
183 side: DockRailSide,
184 palette: &DockRailPalette,
185 make_icon: FIcon,
186 on_activate: FAct,
187 on_drop: FDrop,
188) -> View<Msg>
189where
190 Msg: Clone + Send + Sync + 'static,
191 FIcon: Fn(u64, f32, Color) -> View<Msg>,
192 FAct: Fn(u64) -> Msg,
193 FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
194{
195 dock_rail_view_core(
196 items,
197 width,
198 side,
199 palette,
200 make_icon,
201 |_| None,
202 |_| false,
203 |_| None,
204 on_activate,
205 on_drop,
206 None,
207 )
208}
209
210/// Como [`dock_rail_view_side`] pero con **distintivos** ("bubble numbers"):
211/// `make_badge(id)` devuelve un [`DockBadge`] opcional que se superpone en la
212/// esquina externa del diente (la esquina hacia la que sobresale). El badge no
213/// captura clics — clickear sobre él sigue activando el diente. Igual que
214/// `make_icon`, el badge se deriva del `id`, así que basta variar el modelo y
215/// re-pintar para que el contador cambie en vivo.
216#[allow(clippy::too_many_arguments)]
217pub fn dock_rail_view_badged<Msg, FIcon, FBadge, FAct, FDrop>(
218 items: &[DockRailItem],
219 width: f32,
220 side: DockRailSide,
221 palette: &DockRailPalette,
222 make_icon: FIcon,
223 make_badge: FBadge,
224 on_activate: FAct,
225 on_drop: FDrop,
226) -> View<Msg>
227where
228 Msg: Clone + Send + Sync + 'static,
229 FIcon: Fn(u64, f32, Color) -> View<Msg>,
230 FBadge: Fn(u64) -> Option<DockBadge>,
231 FAct: Fn(u64) -> Msg,
232 FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
233{
234 dock_rail_view_core(
235 items,
236 width,
237 side,
238 palette,
239 make_icon,
240 make_badge,
241 |_| false,
242 |_| None,
243 on_activate,
244 on_drop,
245 None,
246 )
247}
248
249/// Como [`dock_rail_view_side`] pero con **separadores de grupo**: `sep_before(id)`
250/// devuelve `true` para el primer diente de un grupo nuevo, y el rail pinta una
251/// línea tenue *encima* suyo (nunca antes del primero) para agrupar sin resaltar.
252#[allow(clippy::too_many_arguments)]
253pub fn dock_rail_view_grouped<Msg, FIcon, FSep, FAct, FDrop>(
254 items: &[DockRailItem],
255 width: f32,
256 side: DockRailSide,
257 palette: &DockRailPalette,
258 make_icon: FIcon,
259 sep_before: FSep,
260 on_activate: FAct,
261 on_drop: FDrop,
262) -> View<Msg>
263where
264 Msg: Clone + Send + Sync + 'static,
265 FIcon: Fn(u64, f32, Color) -> View<Msg>,
266 FSep: Fn(u64) -> bool,
267 FAct: Fn(u64) -> Msg,
268 FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
269{
270 dock_rail_view_core(
271 items,
272 width,
273 side,
274 palette,
275 make_icon,
276 |_| None,
277 sep_before,
278 |_| None,
279 on_activate,
280 on_drop,
281 None,
282 )
283}
284
285/// Como [`dock_rail_view_grouped`] pero además con **grupos colapsables**: un
286/// diente puede ser el **cabezal** de un grupo que se pliega/despliega. `chevron(id)`
287/// devuelve `Some(expanded)` para un diente-cabezal —`true` si su grupo está
288/// desplegado (sus miembros a la vista), `false` si está plegado (escondidos)— y
289/// `None` para un diente normal. El rail le dibuja una **flechita** (chevron) en la
290/// esquina interna-inferior: ▾ desplegado, ▸ plegado. La flechita es sólo indicador
291/// —no captura clics—; **esconder los miembros y alternar el estado los decide el
292/// host**: al activar el cabezal alterna su `collapsed` y omite de `items` los dientes
293/// de un grupo plegado (conservando el `id` de cada diente visible). Así el widget
294/// aporta el «desplegable» sin dueño de estado nuevo.
295#[allow(clippy::too_many_arguments)]
296pub fn dock_rail_view_collapsible<Msg, FIcon, FSep, FChev, FAct, FDrop>(
297 items: &[DockRailItem],
298 width: f32,
299 side: DockRailSide,
300 palette: &DockRailPalette,
301 make_icon: FIcon,
302 sep_before: FSep,
303 chevron: FChev,
304 on_activate: FAct,
305 on_drop: FDrop,
306) -> View<Msg>
307where
308 Msg: Clone + Send + Sync + 'static,
309 FIcon: Fn(u64, f32, Color) -> View<Msg>,
310 FSep: Fn(u64) -> bool,
311 FChev: Fn(u64) -> Option<bool>,
312 FAct: Fn(u64) -> Msg,
313 FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
314{
315 dock_rail_view_core(
316 items,
317 width,
318 side,
319 palette,
320 make_icon,
321 |_| None,
322 sep_before,
323 chevron,
324 on_activate,
325 on_drop,
326 None,
327 )
328}
329
330/// Callback de **reorden por arrastre**: `(payload, target)` — el `id` del diente
331/// arrastrado y el `id` del diente sobre el que se soltó. La app traduce ambos ids a
332/// posiciones y reordena. `None` = no hacer nada (p. ej. si el payload no pertenece a
333/// este rail). Se comparte por-diente vía `Arc` para no re-crear la lógica N veces.
334pub type ReorderFn<Msg> = Arc<dyn Fn(u64, u64) -> Option<Msg> + Send + Sync>;
335
336/// Como [`dock_rail_view_badged`] pero además **reordenable por arrastre**: cada diente
337/// es también un *drop target*, así soltar un diente sobre otro emite
338/// `on_reorder(payload_arrastrado, id_destino)`. El `on_drop` del rail entero sigue
339/// activo para soltar sobre el hueco (mover de un rail a otro); el drop sobre un diente
340/// concreto tiene prioridad (es el target más interno) y da la POSICIÓN destino que el
341/// `on_drop` a nivel-rail no podía expresar. Es el rail que usa el rag-sidebar para dar
342/// reorden de dientes (sesiones, escritorios, playlists…).
343#[allow(clippy::too_many_arguments)]
344pub fn dock_rail_view_full<Msg, FIcon, FBadge, FSep, FAct, FDrop>(
345 items: &[DockRailItem],
346 width: f32,
347 side: DockRailSide,
348 palette: &DockRailPalette,
349 make_icon: FIcon,
350 make_badge: FBadge,
351 sep_before: FSep,
352 on_activate: FAct,
353 on_drop: FDrop,
354 on_reorder: Option<ReorderFn<Msg>>,
355) -> View<Msg>
356where
357 Msg: Clone + Send + Sync + 'static,
358 FIcon: Fn(u64, f32, Color) -> View<Msg>,
359 FBadge: Fn(u64) -> Option<DockBadge>,
360 FSep: Fn(u64) -> bool,
361 FAct: Fn(u64) -> Msg,
362 FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
363{
364 dock_rail_view_core(
365 items,
366 width,
367 side,
368 palette,
369 make_icon,
370 make_badge,
371 sep_before,
372 |_| None,
373 on_activate,
374 on_drop,
375 on_reorder,
376 )
377}
378
379/// La **flechita** (chevron) de un diente-cabezal de grupo colapsable, dibujada
380/// como vector (no glifo de fuente, así es determinista en toda máquina — evita el
381/// notdef en hardware sin la fuente). Apunta ▾ (abajo) cuando el grupo está
382/// desplegado y ▸ (derecha) cuando está plegado, del `color` ya resuelto del diente
383/// (acento si activo). Se superpone en la esquina interna-inferior y no captura
384/// clics; el host alterna el estado al activar el cabezal.
385fn chevron_view<Msg: Clone + 'static>(expanded: bool, color: Color, mirror: bool) -> View<Msg> {
386 // Esquina INTERNA-inferior: el borde interno es la izquierda en InnerLeft y la
387 // derecha en InnerRight (espejado), del mismo lado que la barra de acento.
388 let inset = if mirror {
389 Rect { top: auto(), bottom: length(2.0_f32), left: auto(), right: length(3.0_f32) }
390 } else {
391 Rect { top: auto(), bottom: length(2.0_f32), left: length(3.0_f32), right: auto() }
392 };
393 View::new(Style {
394 position: Position::Absolute,
395 inset,
396 size: Size { width: length(11.0_f32), height: length(11.0_f32) },
397 ..Default::default()
398 })
399 .paint_with(move |scene, _ts, rect| paint_chevron(scene, rect, expanded, color))
400}
401
402/// Pinta un triángulo relleno (la flechita) centrado en `rect`: apunta abajo si
403/// `expanded`, a la derecha si no.
404fn paint_chevron(scene: &mut llimphi_ui::llimphi_raster::vello::Scene, rect: PaintRect, expanded: bool, color: Color) {
405 use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath};
406 use llimphi_ui::llimphi_raster::peniko::Fill;
407 if rect.w <= 0.0 || rect.h <= 0.0 {
408 return;
409 }
410 let cx = (rect.x + rect.w * 0.5) as f64;
411 let cy = (rect.y + rect.h * 0.5) as f64;
412 let s = 3.4_f64; // media-extensión del triángulo
413 let mut p = BezPath::new();
414 if expanded {
415 // ▾ apunta hacia abajo (grupo desplegado).
416 p.move_to((cx - s, cy - s * 0.55));
417 p.line_to((cx + s, cy - s * 0.55));
418 p.line_to((cx, cy + s * 0.85));
419 } else {
420 // ▸ apunta a la derecha (grupo plegado).
421 p.move_to((cx - s * 0.55, cy - s));
422 p.line_to((cx + s * 0.85, cy));
423 p.line_to((cx - s * 0.55, cy + s));
424 }
425 p.close_path();
426 scene.fill(Fill::NonZero, Affine::IDENTITY, &color, None, &p);
427}
428
429/// Una línea separadora tenue entre grupos de dientes del rail.
430fn rail_separator_view<Msg: Clone + 'static>(width: f32, palette: &DockRailPalette) -> View<Msg> {
431 let line = View::new(Style {
432 size: Size { width: percent(1.0_f32), height: length(1.0_f32) },
433 ..Default::default()
434 })
435 .fill(palette.icon_inactive.multiply_alpha(0.30))
436 .radius(0.5);
437 View::new(Style {
438 size: Size { width: length(width), height: length(11.0_f32) },
439 flex_shrink: 0.0,
440 align_items: Some(AlignItems::Center),
441 justify_content: Some(JustifyContent::Center),
442 padding: Rect {
443 left: length(9.0_f32),
444 right: length(9.0_f32),
445 top: length(5.0_f32),
446 bottom: length(5.0_f32),
447 },
448 ..Default::default()
449 })
450 .children(vec![line])
451}
452
453#[allow(clippy::too_many_arguments)]
454fn dock_rail_view_core<Msg, FIcon, FBadge, FSep, FChev, FAct, FDrop>(
455 items: &[DockRailItem],
456 width: f32,
457 side: DockRailSide,
458 palette: &DockRailPalette,
459 make_icon: FIcon,
460 make_badge: FBadge,
461 sep_before: FSep,
462 chevron: FChev,
463 on_activate: FAct,
464 on_drop: FDrop,
465 on_reorder: Option<ReorderFn<Msg>>,
466) -> View<Msg>
467where
468 Msg: Clone + Send + Sync + 'static,
469 FIcon: Fn(u64, f32, Color) -> View<Msg>,
470 FBadge: Fn(u64) -> Option<DockBadge>,
471 FSep: Fn(u64) -> bool,
472 FChev: Fn(u64) -> Option<bool>,
473 FAct: Fn(u64) -> Msg,
474 FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
475{
476 let mirror = side == DockRailSide::InnerRight;
477 let mut teeth: Vec<View<Msg>> = Vec::with_capacity(items.len());
478 for item in items {
479 // Separador de grupo ENCIMA de este diente (nunca antes del primero).
480 if sep_before(item.id) && !teeth.is_empty() {
481 teeth.push(rail_separator_view(width, palette));
482 }
483 let fg = if item.active {
484 palette.accent
485 } else {
486 palette.icon_inactive
487 };
488 // Barra de acento, pegada al borde interno.
489 let accent_bar = {
490 let b = View::new(Style {
491 size: Size {
492 width: length(3.0_f32),
493 height: length(BAR_H),
494 },
495 flex_shrink: 0.0,
496 ..Default::default()
497 });
498 if item.active {
499 b.fill(palette.accent).radius(2.0)
500 } else {
501 b
502 }
503 };
504 let icon_box = View::new(Style {
505 flex_grow: 1.0,
506 size: Size {
507 width: percent(0.0_f32),
508 height: length(TOOTH_H),
509 },
510 align_items: Some(AlignItems::Center),
511 justify_content: Some(JustifyContent::Center),
512 ..Default::default()
513 })
514 .children(vec![make_icon(item.id, ICON_PX, fg)]);
515
516 let id = item.id;
517 let msg = on_activate(id);
518 // En un sidebar derecho (mirror) la barra de acento va a la DERECHA y el
519 // icono a la izquierda → el diente sobresale hacia el centro (a la izq.).
520 let mut tooth_children = if mirror {
521 vec![icon_box, accent_bar]
522 } else {
523 vec![accent_bar, icon_box]
524 };
525 // Distintivo opcional, superpuesto en la esquina externa superior — la
526 // esquina hacia la que el diente sobresale (derecha en InnerLeft, izquierda
527 // en InnerRight). Va `Absolute` para flotar sobre el icono sin empujarlo.
528 if let Some(badge) = make_badge(id) {
529 let inset = if mirror {
530 Rect {
531 top: length(2.0_f32),
532 left: length(2.0_f32),
533 right: auto(),
534 bottom: auto(),
535 }
536 } else {
537 Rect {
538 top: length(2.0_f32),
539 right: length(2.0_f32),
540 left: auto(),
541 bottom: auto(),
542 }
543 };
544 tooth_children.push(
545 View::new(Style {
546 position: Position::Absolute,
547 inset,
548 ..Default::default()
549 })
550 .children(vec![badge.view::<Msg>()]),
551 );
552 }
553 // Flechita de grupo colapsable (sólo dientes-cabezal): ▾ desplegado, ▸
554 // plegado. Va en la esquina interna-inferior y NO captura clics —el toggle
555 // lo decide el host al activar el cabezal—. `fg` la tiñe (acento si activo).
556 if let Some(expanded) = chevron(id) {
557 tooth_children.push(chevron_view(expanded, fg, mirror));
558 }
559 let mut tooth = View::new(Style {
560 flex_direction: FlexDirection::Row,
561 size: Size {
562 width: percent(1.0_f32),
563 height: length(TOOTH_H),
564 },
565 flex_shrink: 0.0,
566 align_items: Some(AlignItems::Center),
567 ..Default::default()
568 })
569 .hover_fill(palette.bg_hover)
570 // Click en el press activa; arrastrar mueve de rail (payload=id).
571 .on_click_at(move |_, _, _, _| Some(msg.clone()))
572 .draggable_at(|phase, _, _, _, _| match phase {
573 DragPhase::Move | DragPhase::End => None,
574 })
575 .drag_payload(id)
576 .children(tooth_children);
577 if item.active {
578 // Pestaña activa: además del fill, redondea el lado que sobresale hacia
579 // el contenido. La barra de acento marca el borde interno; el diente abre
580 // hacia el lado opuesto (derecha en InnerLeft, izquierda en InnerRight) →
581 // look de pestaña que sale del rail, no un rectángulo plano.
582 let corners = if mirror {
583 (8.0, 0.0, 0.0, 8.0)
584 } else {
585 (0.0, 8.0, 8.0, 0.0)
586 };
587 tooth = tooth.radius_corners(corners.0, corners.1, corners.2, corners.3);
588 if palette.glass > 0.0 {
589 // Cristal esmerilado SÓLO en la pestaña activa: tinte translúcido
590 // de `bg_active` para que el fondo asome, + `backdrop_blur` que lo
591 // borronea. Con la franja transparente (ver abajo), el diente activo
592 // es el único vidrio del rail; los inactivos quedan icono al aire.
593 tooth = tooth
594 .fill(palette.bg_active.multiply_alpha(0.55))
595 .backdrop_blur(palette.glass);
596 } else {
597 tooth = tooth.fill(palette.bg_active);
598 }
599 }
600 // Reorden por arrastre: el diente es TAMBIÉN drop target. Soltar el payload
601 // arrastrado sobre ÉL da la posición destino (lo que el `on_drop` a nivel-rail
602 // no podía). El drop sobre un diente concreto es el target más interno → tiene
603 // prioridad sobre el del rail; soltar en el hueco de abajo sigue yendo al rail.
604 if let Some(reorder) = &on_reorder {
605 let r = reorder.clone();
606 let tid = id;
607 tooth = tooth
608 .on_drop(move |payload| (r)(payload, tid))
609 .drop_hover_fill(palette.bg_hover);
610 }
611 teeth.push(tooth);
612 }
613
614 // La franja: sólo del alto de los dientes (el hueco de abajo lo deja
615 // libre para que el área central lo aproveche si el rail flota como
616 // overlay). Es además el drop target del lado. Con glass, el fondo de la
617 // franja se vuelve transparente — «el resto de la barra» deja ver lo que
618 // haya detrás y sólo los dientes (el activo) portan el vidrio.
619 let rail_bg = if palette.glass > 0.0 {
620 palette.bg_rail.multiply_alpha(0.0)
621 } else {
622 palette.bg_rail
623 };
624 View::new(Style {
625 flex_direction: FlexDirection::Column,
626 size: Size {
627 width: length(width),
628 height: auto(),
629 },
630 flex_shrink: 0.0,
631 ..Default::default()
632 })
633 .fill(rail_bg)
634 .radius(5.0)
635 .on_drop(on_drop)
636 .drop_hover_fill(palette.bg_hover)
637 .children(teeth)
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643
644 fn palette() -> DockRailPalette {
645 DockRailPalette {
646 bg_rail: Color::BLACK,
647 bg_active: Color::BLACK,
648 bg_hover: Color::BLACK,
649 accent: Color::WHITE,
650 icon_inactive: Color::WHITE,
651 glass: 0.0,
652 }
653 }
654
655 fn empty_icon(_id: u64, _size: f32, _color: Color) -> View<i32> {
656 View::new(Style::default())
657 }
658
659 /// Un diente con badge gana exactamente un hijo extra (el overlay absoluto)
660 /// frente a uno sin badge — certifica que el distintivo se inserta en el árbol.
661 #[test]
662 fn badge_agrega_hijo_overlay() {
663 let items = [
664 DockRailItem { id: 0, active: false }, // con badge
665 DockRailItem { id: 1, active: false }, // sin badge
666 ];
667 let rail = dock_rail_view_badged(
668 &items,
669 44.0,
670 DockRailSide::InnerLeft,
671 &palette(),
672 empty_icon,
673 |id| (id == 0).then_some(DockBadge::Count(5, BadgeKind::Error)),
674 |_id| 0i32,
675 |_p| None,
676 );
677 // rail.children = dientes; cada diente lleva [accent_bar, icon_box] (+ badge).
678 let teeth = &rail.children;
679 assert_eq!(teeth.len(), 2);
680 assert_eq!(teeth[0].children.len(), 3, "diente con badge: +1 hijo");
681 assert_eq!(teeth[1].children.len(), 2, "diente sin badge: solo barra+icono");
682 }
683
684 /// Sin `make_badge` (las funciones públicas viejas), ningún diente lleva badge.
685 #[test]
686 fn sin_badge_dos_hijos() {
687 let items = [DockRailItem { id: 0, active: true }];
688 let rail = dock_rail_view(&items, 44.0, &palette(), empty_icon, |_id| 0i32, |_p| None);
689 assert_eq!(rail.children[0].children.len(), 2);
690 }
691
692 /// Con `on_reorder`, cada diente es TAMBIÉN drop target (`on_drop`), así soltar
693 /// uno sobre otro da la posición destino. Sin él, los dientes no reciben drop (sólo
694 /// el rail entero). Certifica el reorden por-diente sin depender del runtime de drag.
695 #[test]
696 fn reorder_hace_cada_diente_drop_target() {
697 let items = [
698 DockRailItem { id: 10, active: false },
699 DockRailItem { id: 20, active: false },
700 ];
701 // Sin reorder: ningún diente es drop target.
702 let sin = dock_rail_view(&items, 44.0, &palette(), empty_icon, |_| 0i32, |_| None);
703 assert!(sin.children[0].on_drop.is_none(), "sin reorder: el diente no recibe drop");
704 // Con reorder: cada diente recibe drop y emite (payload, target) → aquí lo
705 // codificamos como payload*100 + target para verificar que llega el par correcto.
706 let reorder: ReorderFn<i32> = Arc::new(|payload, target| Some((payload * 100 + target) as i32));
707 let con = dock_rail_view_full(
708 &items,
709 44.0,
710 DockRailSide::InnerLeft,
711 &palette(),
712 empty_icon,
713 |_| None,
714 |_| false,
715 |_| 0i32,
716 |_| None,
717 Some(reorder),
718 );
719 // Soltar el diente 10 sobre el diente 20 (índice 1) → 10*100 + 20 = 1020.
720 let drop_en_20 = con.children[1].on_drop.as_ref().expect("diente es drop target");
721 assert_eq!(drop_en_20(10), Some(1020), "el drop da (payload=10, target=20)");
722 }
723
724 /// Con `glass > 0`: la franja del rail se pinta transparente (alpha 0) y
725 /// **sólo la pestaña activa** lleva `backdrop_blur`; las inactivas no. Sin
726 /// glass, la franja mantiene su fondo opaco y nadie borronea el fondo.
727 #[test]
728 fn glass_transparenta_franja_y_borronea_solo_el_activo() {
729 let items = [
730 DockRailItem { id: 0, active: true }, // activo → vidrio
731 DockRailItem { id: 1, active: false }, // inactivo → sin blur
732 ];
733 // Sin glass: franja opaca, ningún diente borronea.
734 let mut pal = palette();
735 let sin = dock_rail_view(&items, 44.0, &pal, empty_icon, |_| 0i32, |_| None);
736 assert_eq!(sin.fill.map(|c| c.components[3]), Some(1.0), "sin glass: franja opaca");
737 assert!(sin.children[0].backdrop_blur.is_none(), "sin glass: el activo no borronea");
738
739 // Con glass: franja transparente + blur SÓLO en el activo.
740 pal.glass = 6.0;
741 let con = dock_rail_view(&items, 44.0, &pal, empty_icon, |_| 0i32, |_| None);
742 assert_eq!(con.fill.map(|c| c.components[3]), Some(0.0), "con glass: franja transparente");
743 assert_eq!(con.children[0].backdrop_blur, Some(6.0), "con glass: el activo es vidrio");
744 assert!(con.children[1].backdrop_blur.is_none(), "con glass: el inactivo NO borronea");
745 }
746
747 /// Un diente-cabezal (chevron `Some`) gana un hijo overlay extra (la flechita)
748 /// frente a un diente normal (chevron `None`); el cabezal no es drop target ni
749 /// captura clics distintos —sigue activando el diente—.
750 #[test]
751 fn chevron_agrega_flechita_solo_en_el_cabezal() {
752 let items = [
753 DockRailItem { id: 0, active: false }, // cabezal (chevron)
754 DockRailItem { id: 1, active: false }, // diente normal
755 ];
756 let rail = dock_rail_view_collapsible(
757 &items,
758 44.0,
759 DockRailSide::InnerLeft,
760 &palette(),
761 empty_icon,
762 |_| false,
763 |id| (id == 0).then_some(true), // el 0 es cabezal desplegado
764 |_id| 0i32,
765 |_p| None,
766 );
767 assert_eq!(rail.children.len(), 2);
768 assert_eq!(rail.children[0].children.len(), 3, "cabezal: barra+icono+flechita");
769 assert_eq!(rail.children[1].children.len(), 2, "diente normal: barra+icono");
770 }
771
772 /// En `InnerRight` el badge se ancla a la izquierda (esquina externa espejada).
773 #[test]
774 fn badge_espeja_inset_en_inner_right() {
775 use llimphi_ui::llimphi_layout::taffy::style_helpers::TaffyAuto;
776 let items = [DockRailItem { id: 0, active: false }];
777 let rail = dock_rail_view_badged(
778 &items,
779 44.0,
780 DockRailSide::InnerRight,
781 &palette(),
782 empty_icon,
783 |_id| Some(DockBadge::Dot(BadgeKind::Success)),
784 |_id| 0i32,
785 |_p| None,
786 );
787 let badge_overlay = rail.children[0].children.last().expect("overlay presente");
788 // Espejo: left fijo, right auto.
789 assert_ne!(badge_overlay.style.inset.left, TaffyAuto::AUTO);
790 assert_eq!(badge_overlay.style.inset.right, TaffyAuto::AUTO);
791 }
792}