gioser

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 widgets/dock-rail/src/lib.rs Rust · 792 siq'ikuna · 29.5 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! `llimphi-widget-dock-rail` — rail vertical de **dientes** para
2//! sidebars acoplables.
3//!
4//! Cada diente es una pestaña vertical: una **barra de acento** de 3px
5//! pegada al borde interno (encendida cuando el item está activo) + un
6//! **icono** centrado. Los dientes apilan en una columna redondeada que
7//! se pinta como una franja flotante al borde del centro — el patrón del
8//! dock de cosmos, ahora reutilizable.
9//!
10//! Render-only y agnóstico del `Msg`: el item se identifica por un `u64`
11//! opaco. El clic (en el *press*, para no pelear con el arrastre) activa
12//! vía `on_activate(id)`; el diente es **arrastrable** con su `id` como
13//! payload, y el rail entero es **drop target** (`on_drop(payload)`) —
14//! así una app puede mover un diente de un sidebar a otro soltándolo
15//! sobre el rail opuesto. El icono lo dibuja el caller (`make_icon`), que
16//! recibe el color ya resuelto según el estado activo/inactivo.
17//!
18//! Opcionalmente un diente lleva un **distintivo** ("bubble number") en su
19//! esquina externa: [`dock_rail_view_badged`] toma `make_badge(id)` que
20//! devuelve un [`DockBadge`] (chip con número o punto de estado, reusando
21//! [`llimphi_widget_badge`]). Como se deriva del `id`, basta cambiar el modelo
22//! y re-pintar para que el contador cambie en vivo.
23//!
24//! ```ignore
25//! let items = [DockRailItem { 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}