tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Definiciones

⎇ main widgets/rag-sidebar/src/unified.rs Rust · 1384 líneas · 64.6 KB Historia Quién tocó qué crudo

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

1//! La **vista unificada** del rag-sidebar: UN solo widget que es dueño del rail de
2//! dientes (vía `dock-rail`, adentro) **y** del panel + chrome (buscador + control
3//! + popover de las 4 opciones). Cada app monta esto con sus dientes y su
4//! [`RagSidebarState`]; hereda el comportamiento de las 4 opciones sin re-derivarlo.
5//!
6//! Antes había dos cosas: `dock-rail` (los dientes) y `rag-sidebar` (el chrome, sin
7//! rail). Por eso las apps componían a mano y "Rail Adentro/Afuera" no hacía nada.
8//! Ahora el rail vive **dentro** del widget, que decide su posición según el estado.
9//!
10//! El widget sigue siendo render-only y agnóstico del `Msg` del host: emite
11//! [`RagSidebarMsg`] a través de un `map` que el host enruta a
12//! [`RagSidebarState::update`].
13//!
14//! ```ignore
15//! let spec = RagSidebarView {
16//!     teeth: vec![RagTooth::new(0, "Árbol", Arc::new(|s,c| icono(s,c)))],
17//!     body: mi_panel(body_height(h, false)),   // cuerpo del diente abierto
18//!     accessory: None, search_hits: false, t, rail_w: 44.0,
19//!     map: Arc::new(Msg::Sidebar), on_drop: None, on_reorder: None, grow: None,
20//! };
21//! rag_sidebar_view(&model.sidebar, spec, RagSide::Left, panel_w, h, &palette)
22//! ```
23
24use alloc::vec::Vec;
25use alloc::string::String;
26use alloc::vec;
27use alloc::string::ToString;
28use alloc::sync::Arc;
29
30use llimphi_compositor::llimphi_layout::taffy::{
31    prelude::{auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style},
32    Rect as TaffyRect,
33};
34use llimphi_compositor::peniko::Color;
35use llimphi_compositor::{PaintRect, View};
36
37use llimphi_widget_dock_rail::{dock_rail_view_full_at_doble_hover, DockBadge, DockRailItem, DockRailSide, ReorderFn};
38
39use crate::state::{RagSide, RagSidebarMsg, RagSidebarState};
40use crate::{RagSidebarPalette, RagSidebarSpec, RagSwitchGroup};
41
42/// Un **diente** del sidebar unificado: su `id`, el título de su panel y cómo se
43/// dibuja su icono en el rail. El `body` (cuerpo del panel abierto) NO va aquí — lo
44/// aporta el host una sola vez en [`RagSidebarView::body`], para el diente abierto.
45pub struct RagTooth<Msg> {
46    /// Identidad estable del diente (la usa `RagSidebarMsg::Activate`).
47    pub id: u64,
48    /// Título del panel cuando este diente está abierto (lo pinta el cabezal).
49    pub title: String,
50    /// El panel de este diente no lleva cabezal (para paneles de puro lienzo).
51    pub no_header: bool,
52    /// Dibuja el icono del diente: `(size, color)` → `View`. El `color` es el que
53    /// resuelve el rail (acento si activo, atenuado si no); el host puede ignorarlo.
54    pub icon: Arc<dyn Fn(f32, Color) -> View<Msg> + Send + Sync>,
55    /// Distintivo ("bubble number") opcional en la esquina externa del diente.
56    pub badge: Option<DockBadge>,
57    /// Dibuja un **separador** sutil (una fina línea tenue de alto mínimo) *encima*
58    /// de este diente en el rail, para **agrupar** dientes sin resaltarlos. No aplica
59    /// al primer diente (no hay nada que separar arriba). `false` = sin separador.
60    pub separator_before: bool,
61    /// **Fuerza** el resaltado del diente en vez de derivarlo del estado del sidebar.
62    /// `None` (default) = el rail decide por `state.is_selected||is_open` (dientes que
63    /// despliegan el panel del sidebar). `Some(b)` = el host manda: para dientes cuyo
64    /// "activo" NO es el panel abierto sino su propio estado —shuma abierto, un diente
65    /// hospedado activo, la sesión de terminal en foco— que conviven en el mismo rail.
66    pub force_active: Option<bool>,
67    /// **A medio activar** — lo que el diente representa está ahí, pero tapado.
68    ///
69    /// El caso concreto es el escritorio que queda **detrás del cajón**: no es
70    /// el activo (lo que se ve es el cajón) y no es uno más de los apagados (es
71    /// al que volvés al guardarlo). Ver [`llimphi_widget_dock_rail::DockRailItem::semi`],
72    /// que es donde se pinta. Ignorado si el diente sale activo.
73    pub semi: bool,
74    /// **Encía intercalada** justo DESPUÉS de este diente (glifo, tooltip, `Msg`). Es
75    /// el botón que hace *crecer un diente nuevo* PEGADO a su grupo —el «+» de
76    /// escritorios va tras la última celda de escritorio, no al final del rail—. `None`
77    /// (default) = sin encía. Se distingue de [`RagSidebarView::grow`], que va al final.
78    pub grow_after: Option<(String, String, Msg)>,
79    /// **Encía de GRUPO** justo después de este diente, DEBAJO de su
80    /// [`Self::grow_after`]: el botón que hace crecer *otro diente del mismo
81    /// grupo* (una sesión más), no un hijo de éste (una pestaña más).
82    ///
83    /// Son dos botones distintos porque son dos actos distintos, y el rail los
84    /// puede necesitar **a la vez** en el mismo diente —el último diente del
85    /// grupo suele ser también el activo—. Se dibuja **más ancho y en acento**
86    /// (ver [`crate::rag_encia_grupo_view`]) para que no se confunda con el «+»
87    /// chiquito de la rama, que es el error que se busca evitar.
88    pub grow_group_after: Option<(String, String, Msg)>,
89    /// **Subdientes**: dientes MENORES que cuelgan de éste y se pintan indentados
90    /// justo debajo suyo (antes de su encía), como una rama del rail. Es el segundo
91    /// nivel de la jerarquía cuando un diente contiene cosas navegables por sí
92    /// mismas —el caso canónico: el diente-escritorio de `pata` y los *tabs* de ese
93    /// escritorio—. Vacío (default) = diente sin rama. El host decide CUÁNDO
94    /// llenarlos (p. ej. sólo el escritorio activo los despliega): el widget no
95    /// colapsa ni expande por su cuenta.
96    /// **Cuánto gasta lo que cuelga de este diente**, `(fracción 0..=1, color)`.
97    /// Viaja tal cual al medidor del rail (ver [`DockRailItem::carga`]), que lo
98    /// pinta como una columna en el borde opuesto al de la barra de acento.
99    /// `None` = sin medidor, que es el default de todos los dientes.
100    pub carga: Option<(f32, Color)>,
101    pub subteeth: Vec<RagSubTooth<Msg>>,
102    /// **El grupo al que pertenece el diente** (ver
103    /// [`llimphi_widget_dock_rail::DockRailItem::grupo`]). `None` (default) = viaja
104    /// solo.
105    ///
106    /// Es lo que hace que los dientes se muevan **de a grupos**: los escritorios
107    /// juntos, los de una app juntos, y un botón suelto como grupo de uno. Se
108    /// distingue de [`Self::separator_before`], que sólo dibuja la línea: aquél es
109    /// el aspecto, éste es la unidad que viaja. Un rail puede querer una sin la otra
110    /// (grupos sin línea, o una línea decorativa dentro de un grupo).
111    pub grupo: Option<u64>,
112    /// Qué emite el **gesto secundario** (clic derecho/medio) sobre el diente —
113    /// típicamente *cerrar lo que el diente representa*. `None` (default) = el diente
114    /// sólo se activa.
115    ///
116    /// Es el mismo trato que ya tenían los subdientes ([`RagSubTooth::on_close`]) y
117    /// existe por lo mismo: un diente que sólo sabe abrir su panel no se puede sacar
118    /// del rail, así que para deshacerse de él hay que abrirlo primero e ir a buscar el
119    /// botón adentro — justo lo que un rail evita.
120    pub on_close: Option<Msg>,
121    /// Qué emite el clic **derecho** cuando el diente ofrece un **menú** en vez de
122    /// una acción sola: recibe dónde quedó el puntero (coords de pantalla) para
123    /// anclarlo. `None` (default) = manda [`Self::on_close`], si lo hay.
124    ///
125    /// Existe porque un diente que representa trabajo del operador —una sesión,
126    /// un escritorio, un proyecto— no se puede **cerrar de un clic derecho sin
127    /// preguntar**: el gesto es el mismo con el que en todos lados se pide un
128    /// menú, así que el operador pide opciones y pierde lo que tenía. Reporte
129    /// textual (25-ago): *«le di click derecho y se cerró, lo cual me sorprendió,
130    /// no debería, y no sé si es para siempre»*.
131    ///
132    /// El clic **medio** se queda con [`Self::on_close`]: ahí el gesto sí es
133    /// «sacá esto» y no tiene otra lectura.
134    pub on_close_at: Option<Arc<dyn Fn(f32, f32) -> Msg + Send + Sync>>,
135    /// Qué emite el diente cuando el **puntero entra y cuando sale** —
136    /// `(al entrar, al salir)`. `None` (default) = el diente no se entera del
137    /// puntero, que es lo de siempre.
138    ///
139    /// Existe para el gesto que sólo tiene sentido *mientras mirás* el diente:
140    /// el caso que lo pidió es el empausador del diente de sonido de `pata` —con
141    /// música sonando, el puntero encima revela un botón de pausa chico en la
142    /// esquina del ícono—. Ver [`llimphi_widget_dock_rail::HoverFn`], que además
143    /// explica por qué el gancho tiene que colgar del **diente** y no del ícono.
144    pub on_hover: Option<(Msg, Msg)>,
145    /// **Qué hace este diente con un arrastre AJENO** — uno que no es otro
146    /// diente: una ventana de la lista del panel, un archivo. Recibe el payload
147    /// que declaró el nodo arrastrado; `None` = ese arrastre no le sirve y el
148    /// drop cae al reordenamiento del rail, si lo hay.
149    ///
150    /// `None` (default) = el diente sólo recibe reordenamientos, que es como
151    /// funcionó siempre. Ver [`llimphi_widget_dock_rail::DienteRecibe`].
152    pub al_soltar: Option<Arc<dyn Fn(u64) -> Option<Msg> + Send + Sync>>,
153    /// **El resorte**: qué emite cuando un arrastre se queda posado encima sin
154    /// soltar — desplegar este diente, para poder apuntar dentro de su panel.
155    /// Va con [`Self::al_soltar`] y tiene que ser **idempotente** (abrir, no
156    /// alternar). `None` (default) = el diente no se despliega solo.
157    pub al_posarse: Option<Msg>,
158}
159
160/// Un **subdiente**: la unidad del segundo nivel del rail (ver
161/// [`RagTooth::subteeth`]). Más chico que un diente y sin panel propio — su clic es
162/// una acción del host (enfocar un tab, saltar a una sección), no abrir el sidebar.
163pub struct RagSubTooth<Msg> {
164    /// Identidad estable (no colisiona con los ids de diente: el widget no la usa
165    /// para el estado, sólo la expone para que el host se ubique).
166    pub id: u64,
167    /// Texto del tooltip al sobrevolarlo.
168    pub tooltip: String,
169    /// Dibuja su contenido: `(size, color)` → `View`, igual que el icono de un
170    /// diente. El `color` viene resuelto (acento si activo, atenuado si no).
171    pub icon: Arc<dyn Fn(f32, Color) -> View<Msg> + Send + Sync>,
172    /// Resaltado (el tab que estás mirando).
173    pub active: bool,
174    /// Qué emite al clic izquierdo.
175    pub on_click: Msg,
176    /// Qué emite al clic derecho/medio (cerrar). `None` = sin acción.
177    pub on_close: Option<Msg>,
178}
179
180impl<Msg> RagTooth<Msg> {
181    /// Atajo: diente con cabezal, sin badge, sin separador.
182    pub fn new(
183        id: u64,
184        title: impl Into<String>,
185        icon: Arc<dyn Fn(f32, Color) -> View<Msg> + Send + Sync>,
186    ) -> Self {
187        Self {
188            id,
189            title: title.into(),
190            no_header: false,
191            icon,
192            badge: None,
193            separator_before: false,
194            force_active: None,
195            semi: false,
196            grow_after: None,
197            grow_group_after: None,
198            carga: None,
199            subteeth: Vec::new(),
200            grupo: None,
201            on_close: None,
202            on_close_at: None,
203            on_hover: None,
204            al_soltar: None,
205            al_posarse: None,
206        }
207    }
208
209    /// Ver [`Self::al_soltar`]: el diente acepta que le suelten algo encima.
210    pub fn recibe<F>(mut self, f: F) -> Self
211    where
212        F: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
213    {
214        self.al_soltar = Some(Arc::new(f));
215        self
216    }
217
218    /// Ver [`Self::al_posarse`]: el resorte, para desplegarlo sin soltar.
219    pub fn resorte(mut self, msg: Msg) -> Self {
220        self.al_posarse = Some(msg);
221        self
222    }
223
224    /// Cablea el **hover** del diente: `entra` al llegar el puntero, `sale` al
225    /// irse. Encadenable. Ver [`Self::on_hover`].
226    pub fn hover(mut self, entra: Msg, sale: Msg) -> Self {
227        self.on_hover = Some((entra, sale));
228        self
229    }
230
231    /// Cablea el **gesto secundario** (derecho/medio) del diente. Encadenable.
232    pub fn on_close(mut self, msg: Msg) -> Self {
233        self.on_close = Some(msg);
234        self
235    }
236
237    /// Cablea el **menú** del diente: el clic derecho emite `f(x, y)` con las
238    /// coordenadas del puntero, en vez de ejecutar la acción de
239    /// [`Self::on_close`]. Encadenable. Ver [`Self::on_close_at`].
240    pub fn on_close_at(
241        mut self,
242        f: Arc<dyn Fn(f32, f32) -> Msg + Send + Sync>,
243    ) -> Self {
244        self.on_close_at = Some(f);
245        self
246    }
247
248    /// Cuelga **subdientes** de este diente (segundo nivel del rail). Encadenable.
249    pub fn subteeth(mut self, subs: Vec<RagSubTooth<Msg>>) -> Self {
250        self.subteeth = subs;
251        self
252    }
253
254    /// Intercala una **encía** (botón «+») justo después de este diente (dentro de su
255    /// grupo), en vez de al final del rail. Encadenable.
256    pub fn grow_after(mut self, glyph: impl Into<String>, tooltip: impl Into<String>, on_grow: Msg) -> Self {
257        self.grow_after = Some((glyph.into(), tooltip.into(), on_grow));
258        self
259    }
260
261    /// Intercala la **encía de grupo** (el «+» ancho que agrega otro diente del
262    /// mismo grupo) debajo de la encía de rama. Encadenable.
263    pub fn grow_group_after(
264        mut self,
265        glyph: impl Into<String>,
266        tooltip: impl Into<String>,
267        on_grow: Msg,
268    ) -> Self {
269        self.grow_group_after = Some((glyph.into(), tooltip.into(), on_grow));
270        self
271    }
272
273    /// Marca que este diente arranca un **grupo nuevo**: el rail pinta un separador
274    /// tenue justo encima suyo.
275    pub fn separator_before(mut self) -> Self {
276        self.separator_before = true;
277        self
278    }
279
280    /// **Le cuelga el medidor de carga** (ver [`Self::carga`]). Encadenable.
281    pub fn carga(mut self, carga: Option<(f32, Color)>) -> Self {
282        self.carga = carga;
283        self
284    }
285
286    /// Fuerza el resaltado del diente (para dientes cuyo activo lo gobierna el host,
287    /// no el panel abierto del sidebar).
288    pub fn active(mut self, on: bool) -> Self {
289        self.force_active = Some(on);
290        self
291    }
292
293    /// Lo deja **a medio activar** (ver [`Self::semi`]). Encadenable.
294    pub fn semiactivo(mut self, on: bool) -> Self {
295        self.semi = on;
296        self
297    }
298}
299
300/// Todo lo que el host aporta para pintar el sidebar unificado. Genérico sobre
301/// `Msg`; el widget compone rail + chrome + panel y decide la disposición leyendo
302/// el [`RagSidebarState`].
303pub struct RagSidebarView<Msg> {
304    /// Los dientes del rail, en orden.
305    pub teeth: Vec<RagTooth<Msg>>,
306    /// El cuerpo del diente **abierto**, ya dimensionado por el host a
307    /// [`crate::body_height`]. Ignorado si no hay panel visible.
308    pub body: View<Msg>,
309    /// Accesorio derecho del cabezal del panel (p. ej. un toggle Árbol/Grafo).
310    pub accessory: Option<View<Msg>>,
311    /// Hay coincidencias del buscador (tiñe el control de acento).
312    pub search_hits: bool,
313    /// Reloj de animación (~20 Hz) para el halo del control idle.
314    pub t: f64,
315    /// Ancho del rail de dientes, en px.
316    pub rail_w: f32,
317    /// Enruta los `Msg` internos del widget al `Msg` del host.
318    pub map: Arc<dyn Fn(RagSidebarMsg) -> Msg + Send + Sync>,
319    /// Callback opcional al soltar un diente arrastrado sobre el **rail** (hueco o
320    /// rail entero) — para MOVER un diente de un sidebar a otro (payload = id
321    /// arrastrado). Para reordenar DENTRO del rail usa [`Self::on_reorder`].
322    pub on_drop: Option<Arc<dyn Fn(u64) -> Option<Msg> + Send + Sync>>,
323    /// Callback opcional de **reorden por arrastre**: `(payload, target)` = id del
324    /// diente arrastrado y id del diente sobre el que se soltó. Hace que cada diente
325    /// sea drop target y da la POSICIÓN destino; la app traduce ids→índices y reordena.
326    /// `None` = dientes no reordenables. Se distingue de [`Self::on_drop`] (mover de
327    /// rail a rail, sin posición).
328    pub on_reorder: Option<ReorderFn<Msg>>,
329    /// **Encía** opcional al final del rail: `(glifo, tooltip, Msg)`. Es el botón
330    /// que hace *crecer un diente nuevo* (p. ej. "+" para agregar una playlist / un
331    /// escritorio). `None` = sin encía.
332    pub grow: Option<(String, String, Msg)>,
333}
334
335/// Qué opciones del multiselect **ofrece** una app. Una app que resuelve un eje por
336/// su cuenta (p. ej. media tiene su propio autohide estilo mpv) puede **deshabilitar**
337/// ese switch para no ofrecer un control redundante/conflictivo. Todas `true` por
338/// defecto.
339#[derive(Debug, Clone, Copy)]
340pub struct RagOptions {
341    /// "Rail: Adentro/Afuera".
342    pub rail: bool,
343    /// "Espacio: Flota/Fijo".
344    pub espacio: bool,
345    /// "Ocultar: Autoesconde".
346    pub ocultar: bool,
347    /// "Dientes: Un/Dos pasos".
348    pub dientes: bool,
349    /// "Fondo: Sólido/Vidrio" — el rail sin fondo propio. **Apagado por defecto**:
350    /// sólo se ve bien donde el compositor sabe glasear por sub-región (los pills),
351    /// que hoy es `pata` sobre `mirada`. En cualquier otro lado un rail sin fondo es
352    /// texto flotando sobre lo que haya atrás.
353    pub fondo: bool,
354}
355
356impl Default for RagOptions {
357    fn default() -> Self {
358        Self { rail: true, espacio: true, ocultar: true, dientes: true, fondo: false }
359    }
360}
361
362/// Los grupos excluyentes del popover de disposición **habilitados** (`opts`),
363/// armados desde el estado. Una app deshabilita los que resuelve por su cuenta.
364fn dispo_groups<Msg: Clone + Send + Sync + 'static>(
365    state: &RagSidebarState,
366    opts: RagOptions,
367    map: &Arc<dyn Fn(RagSidebarMsg) -> Msg + Send + Sync>,
368) -> Vec<RagSwitchGroup<Msg>> {
369    let mut groups: Vec<RagSwitchGroup<Msg>> = Vec::with_capacity(4);
370    if opts.rail {
371        let m = map.clone();
372        groups.push(RagSwitchGroup::new("Rail", &["Adentro", "Afuera"], state.rail_outside as usize, move |i| {
373            (m)(RagSidebarMsg::SetRailOutside(i == 1))
374        }));
375    }
376    if opts.espacio {
377        let m = map.clone();
378        groups.push(RagSwitchGroup::new("Espacio", &["Flota", "Fijo"], state.docked as usize, move |i| {
379            (m)(RagSidebarMsg::SetDocked(i == 1))
380        }));
381    }
382    if opts.ocultar {
383        let m = map.clone();
384        groups.push(RagSwitchGroup::new("Ocultar", &["Nunca", "Autoesconde"], state.autohide as usize, move |i| {
385            (m)(RagSidebarMsg::SetAutohide(i == 1))
386        }));
387    }
388    // El eje **Dientes** —«Un paso» / «Dos pasos»— vuelve a pintarse (17-ago, segunda
389    // vuelta): se lo había sacado junto con el modo, y sacar el modo era pasarse del
390    // encargo. Ver `RagSidebarState::activate`.
391    if opts.dientes {
392        let m = map.clone();
393        groups.push(RagSwitchGroup::new("Dientes", &["Un paso", "Dos pasos"], state.dos_pasos as usize, move |i| {
394            (m)(RagSidebarMsg::SetDosPasos(i == 1))
395        }));
396    }
397    if opts.fondo {
398        let m = map.clone();
399        groups.push(RagSwitchGroup::new("Fondo", &["Sólido", "Vidrio"], state.transparente as usize, move |i| {
400            (m)(RagSidebarMsg::SetTransparente(i == 1))
401        }));
402    }
403    groups
404}
405
406/// El **panel** del diente abierto: barra buscador+control + cabezal + `body`, SIN el
407/// rail. Llena su contenedor (fill). Público para el modo **DELEGADO** (pata pinta el
408/// rail prestando los dientes; la app pinta solo el panel) — ver la actividad de
409/// delegado+arrastre universal. En modo normal usa [`rag_sidebar_view`] (rail+panel).
410pub fn rag_panel_view<Msg: Clone + Send + Sync + 'static>(
411    state: &RagSidebarState,
412    teeth: &[RagTooth<Msg>],
413    body: View<Msg>,
414    accessory: Option<View<Msg>>,
415    search_hits: bool,
416    t: f64,
417    // **De qué lado cuelga este panel.** Lo necesita el chrome: en un sidebar
418    // derecho el engranaje va a la izquierda del buscador (ver
419    // `RagSidebarSpec::side`). `rag_sidebar_view` le pasa el suyo; un host de dos
420    // superficies —pata— pasa el de su `Surface`.
421    side: RagSide,
422    palette: &RagSidebarPalette,
423    map: Arc<dyn Fn(RagSidebarMsg) -> Msg + Send + Sync>,
424) -> View<Msg> {
425    let drawer = panel_spec(state, teeth, body, accessory, search_hits, t, side, map);
426    crate::rag_drawer_view_fill(drawer, palette)
427}
428
429/// Arma el [`RagSidebarSpec`] del panel desde el estado: título/no_header del diente
430/// abierto, buscador/control desde `state` y los 4 grupos de disposición.
431fn panel_spec<'a, Msg: Clone + Send + Sync + 'static>(
432    state: &'a RagSidebarState,
433    teeth: &[RagTooth<Msg>],
434    body: View<Msg>,
435    accessory: Option<View<Msg>>,
436    search_hits: bool,
437    t: f64,
438    side: RagSide,
439    map: Arc<dyn Fn(RagSidebarMsg) -> Msg + Send + Sync>,
440) -> RagSidebarSpec<'a, Msg> {
441    let open = state.open.and_then(|id| teeth.iter().find(|tt| tt.id == id));
442    let (title, no_header) = open.map(|tt| (tt.title.clone(), tt.no_header)).unwrap_or_default();
443    let m_focus = map.clone();
444    let m_close = map.clone();
445    let m_input = map.clone();
446    RagSidebarSpec {
447        title,
448        no_header,
449        accessory,
450        body,
451        search: state.search.clone(),
452        search_focused: state.search_focused,
453        search_hits,
454        search_input: &state.search_input,
455        on_search_input: Arc::new(move |ev| (m_input)(RagSidebarMsg::SearchInput(ev))),
456        on_search_focus: Arc::new(move |f| (m_focus)(RagSidebarMsg::SearchFocus(f))),
457        on_search_close: (m_close)(RagSidebarMsg::SearchClose),
458        control_open: state.control_open,
459        t,
460        on_control_toggle: (map)(RagSidebarMsg::ControlToggle),
461        // SIN popover inline: el multiselect va como ventana/overlay EXTERNO que el
462        // host pinta con `rag_multiselect_view` (así no lo clipea el panel al achicar).
463        groups: Vec::new(),
464        side,
465    }
466}
467
468/// El **multiselect** de disposición (los 4 grupos excluyentes) como vista
469/// **standalone**, para que el host la pinte como **ventana/overlay externo** cerca
470/// del control cuando `state.control_open` — NO clipeada por el panel. Es la card sin
471/// posicionar; el host la ubica (y le pone un fondo/backdrop si quiere).
472pub fn rag_multiselect_view<Msg: Clone + Send + Sync + 'static>(
473    state: &RagSidebarState,
474    opts: RagOptions,
475    map: Arc<dyn Fn(RagSidebarMsg) -> Msg + Send + Sync>,
476    palette: &RagSidebarPalette,
477) -> View<Msg> {
478    crate::multiswitch_card(dispo_groups(state, opts, &map), palette)
479}
480
481/// El **rail** de dientes (interno): un `dock-rail` con el activo resaltado desde el
482/// estado y el click enrutado a `Activate`.
483fn build_rail<Msg: Clone + Send + Sync + 'static>(
484    state: &RagSidebarState,
485    teeth: &[RagTooth<Msg>],
486    rail_w: f32,
487    side: RagSide,
488    palette: &RagSidebarPalette,
489    map: &Arc<dyn Fn(RagSidebarMsg) -> Msg + Send + Sync>,
490    on_drop: Option<Arc<dyn Fn(u64) -> Option<Msg> + Send + Sync>>,
491    on_reorder: Option<ReorderFn<Msg>>,
492    grow: Option<(String, String, Msg)>,
493) -> View<Msg> {
494    let items: Vec<DockRailItem> = teeth
495        .iter()
496        .map(|t| DockRailItem {
497            id: t.id,
498            // El diente sólo pinta un ícono: el título es su nombre accesible.
499            label: Some(t.title.clone()),
500            // El rail resalta el diente SELECCIONADO (que en dos-pasos puede estar
501            // activo con el panel replegado), o el desplegado — salvo que el diente
502            // FUERCE su activo (shuma/hospedado/terminal, cuyo activo no es el panel).
503            active: t.force_active.unwrap_or_else(|| state.is_selected(t.id) || state.is_open(t.id)),
504            // El grupo viaja tal cual al widget base: arrastrar cualquier diente del
505            // grupo mueve al grupo entero, y el destino del `on_reorder` es también
506            // un grupo. `None` = el diente viaja solo, como siempre.
507            grupo: t.grupo,
508            carga: t.carga,
509            semi: t.semi,
510        })
511        .collect();
512    let icons: Vec<(u64, Arc<dyn Fn(f32, Color) -> View<Msg> + Send + Sync>)> =
513        teeth.iter().map(|t| (t.id, t.icon.clone())).collect();
514    let badges: Vec<(u64, DockBadge)> =
515        teeth.iter().filter_map(|t| t.badge.map(|b| (t.id, b))).collect();
516    let dside = match side {
517        RagSide::Left => DockRailSide::InnerLeft,
518        RagSide::Right => DockRailSide::InnerRight,
519    };
520    let map_act = map.clone();
521    // Gesto secundario por diente: sólo lo tienen los que declaran `on_close`.
522    let cierres: Vec<(u64, Msg)> = teeth
523        .iter()
524        .filter_map(|t| t.on_close.clone().map(|m| (t.id, m)))
525        .collect();
526    // Los que abren **menú** en vez de cerrar de una: el clic derecho les llega
527    // con las coordenadas del puntero (ver `RagTooth::on_close_at`).
528    let menus: Vec<(u64, Arc<dyn Fn(f32, f32) -> Msg + Send + Sync>)> = teeth
529        .iter()
530        .filter_map(|t| t.on_close_at.clone().map(|f| (t.id, f)))
531        .collect();
532    let secundario_at: Option<llimphi_widget_dock_rail::SecondaryAtFn<Msg>> = (!menus.is_empty())
533        .then(|| {
534            Arc::new(move |id: u64, x: f32, y: f32| {
535                menus.iter().find(|(i, _)| *i == id).map(|(_, f)| (f)(x, y))
536            }) as llimphi_widget_dock_rail::SecondaryAtFn<Msg>
537        });
538    // **Doble-click en cualquier diente = alternar el eje "Espacio"**
539    // (Flota ⇄ Fijo). El atajo del switch que vive en el popover de
540    // disposición, para clavar o soltar el panel que estás mirando sin ir a
541    // buscarlo. Es exclusivo: el runtime descarta el click simple del par, así
542    // que el doble no despliega ni repliega el panel de paso.
543    let map_doble = map.clone();
544    let doble: Option<llimphi_widget_dock_rail::DobleFn<Msg>> = Some(Arc::new(move |_id: u64| {
545        Some((map_doble)(RagSidebarMsg::ToggleDocked))
546    }));
547    // **Hover por diente** (ver [`RagTooth::on_hover`]): sólo se arma si alguno lo
548    // declara — un rail donde a nadie le importa el puntero no paga un `Arc` ni
549    // un despacho por cada `enter`/`leave` que el runtime resuelva.
550    let hovers: Vec<(u64, (Msg, Msg))> = teeth
551        .iter()
552        .filter_map(|t| t.on_hover.clone().map(|h| (t.id, h)))
553        .collect();
554    let hover: Option<llimphi_widget_dock_rail::HoverFn<Msg>> = (!hovers.is_empty()).then(|| {
555        Arc::new(move |id: u64, entra: bool| {
556            hovers
557                .iter()
558                .find(|(i, _)| *i == id)
559                .map(|(_, (e, s))| if entra { e.clone() } else { s.clone() })
560        }) as llimphi_widget_dock_rail::HoverFn<Msg>
561    });
562    // **Lo que los dientes reciben de afuera** (ver [`RagTooth::al_soltar`]):
563    // igual que los cierres y los hovers, se junta en una tabla por id y se
564    // entrega como UNA función al rail base — que no puede llevar closures en
565    // su `DockRailItem` (es `Clone + Debug` y no sabe del `Msg`).
566    let soltares: Vec<(u64, Arc<dyn Fn(u64) -> Option<Msg> + Send + Sync>)> = teeth
567        .iter()
568        .filter_map(|t| t.al_soltar.clone().map(|f| (t.id, f)))
569        .collect();
570    let resortes: Vec<(u64, Msg)> = teeth
571        .iter()
572        .filter_map(|t| t.al_posarse.clone().map(|m| (t.id, m)))
573        .collect();
574    let recibe: Option<llimphi_widget_dock_rail::DienteRecibe<Msg>> = (!soltares.is_empty())
575        .then(|| llimphi_widget_dock_rail::DienteRecibe {
576            al_soltar: Arc::new(move |payload: u64, id: u64| {
577                soltares.iter().find(|(i, _)| *i == id).and_then(|(_, f)| (f)(payload))
578            }),
579            al_posarse: (!resortes.is_empty()).then(|| {
580                Arc::new(move |id: u64| {
581                    resortes.iter().find(|(i, _)| *i == id).map(|(_, m)| m.clone())
582                }) as Arc<dyn Fn(u64) -> Option<Msg> + Send + Sync>
583            }),
584        });
585    let mut dientes = dock_rail_view_full_at_doble_hover(
586        &items,
587        rail_w,
588        dside,
589        &palette.rail,
590        move |id, size, color| {
591            icons
592                .iter()
593                .find(|(i, _)| *i == id)
594                .map(|(_, f)| (f)(size, color))
595                .unwrap_or_else(|| View::new(Style::default()))
596        },
597        move |id| badges.iter().find(|(i, _)| *i == id).map(|(_, b)| *b),
598        |_| false,
599        move |id| (map_act)(RagSidebarMsg::Activate(id)),
600        move |id| cierres.iter().find(|(i, _)| *i == id).map(|(_, m)| m.clone()),
601        secundario_at,
602        doble,
603        hover,
604        move |id| on_drop.as_ref().and_then(|f| (f)(id)),
605        on_reorder,
606        recibe,
607    );
608    // Intercala **separadores** de grupo y **encías** (`grow_after`) inline: `dock-rail`
609    // devuelve una columna cuyos hijos son los dientes 1:1 con `teeth` en orden. Se
610    // recorre de atrás hacia adelante para no correr los índices ya insertados. Por cada
611    // diente: una encía justo DESPUÉS (index i+1) si tiene `grow_after` —el «+» pegado a
612    // su grupo, p. ej. en cada hueco de escritorios—, y un separador ANTES (index i) si
613    // pide `separator_before` (nunca el primero: no hay nada arriba que separar).
614    for (i, t) in teeth.iter().enumerate().rev() {
615        // La encía de **grupo** se inserta PRIMERO para que termine ABAJO de
616        // todo: cada `insert(i + 1)` posterior la empuja un lugar más lejos del
617        // diente. El orden final es [diente, rama, encía de rama, encía de
618        // grupo] — «una pestaña más» pegado a las pestañas, «una sesión más» al
619        // pie del grupo.
620        if let Some((glyph, tip, msg)) = &t.grow_group_after {
621            let encia = crate::rag_encia_grupo_view(glyph, tip.clone(), msg.clone(), palette);
622            let centrada = View::new(Style {
623                size: Size { width: length(rail_w), height: auto() },
624                align_items: Some(AlignItems::CENTER),
625                justify_content: Some(JustifyContent::CENTER),
626                flex_shrink: 0.0,
627                ..Default::default()
628            })
629            .children(vec![encia]);
630            dientes.children.insert(i + 1, centrada);
631        }
632        if let Some((glyph, tip, msg)) = &t.grow_after {
633            let encia = crate::rag_encia_view(glyph, tip.clone(), msg.clone(), palette);
634            // Centrada en el ancho del rail: la encía es 30px y la columna del
635            // dock-rail no centra sus hijos sueltos, así que sin este envoltorio caía
636            // a la izquierda («chueca»). Los dientes ya vienen centrados por dock-rail.
637            let centrada = View::new(Style {
638                size: Size { width: length(rail_w), height: auto() },
639                align_items: Some(AlignItems::CENTER),
640                justify_content: Some(JustifyContent::CENTER),
641                flex_shrink: 0.0,
642                ..Default::default()
643            })
644            .children(vec![encia]);
645            dientes.children.insert(i + 1, centrada);
646        }
647        // La **rama** de subdientes va pegada a su diente y ARRIBA de su encía: el
648        // «+» del hueco siguiente pertenece al grupo, no a la rama. Como la encía ya
649        // se insertó en `i + 1`, meter la rama en `i + 1` la deja en el medio.
650        if !t.subteeth.is_empty() {
651            dientes
652                .children
653                .insert(i + 1, rail_subteeth(&t.subteeth, rail_w, palette));
654        }
655        if i >= 1 && t.separator_before {
656            dientes.children.insert(i, rail_separator(rail_w, palette));
657        }
658    }
659    // Encía opcional al final del rail (el botón que crece un diente nuevo).
660    match grow {
661        None => dientes,
662        Some((glyph, tooltip, msg)) => View::new(Style {
663            flex_direction: FlexDirection::Column,
664            size: Size { width: length(rail_w), height: llimphi_compositor::llimphi_layout::taffy::prelude::auto() },
665            align_items: Some(AlignItems::CENTER),
666            gap: Size { width: length(0.0_f32), height: length(4.0_f32) },
667            ..Default::default()
668        })
669        .children(vec![dientes, crate::rag_encia_view(&glyph, tooltip, msg, palette)]),
670    }
671}
672
673/// La **rama de subdientes** de un diente: una columna de celdas más chicas,
674/// indentadas respecto del diente padre, que se pinta justo debajo suyo (ver
675/// [`RagTooth::subteeth`]). Cada celda es un botón: clic izquierdo emite su
676/// `on_click`, derecho/medio su `on_close` si lo declara. El subdiente activo va
677/// con fondo de selección y su icono en acento; los demás, atenuados.
678fn rail_subteeth<Msg: Clone + Send + Sync + 'static>(
679    subs: &[RagSubTooth<Msg>],
680    rail_w: f32,
681    palette: &RagSidebarPalette,
682) -> View<Msg> {
683    // Más chico que un diente y con sangría: la jerarquía se lee por tamaño, no por
684    // rótulos. Piso de 16px para que siga siendo clickeable en un rail angosto.
685    let lado = (rail_w - 18.0).max(16.0);
686    let celdas: Vec<View<Msg>> = subs
687        .iter()
688        .map(|s| {
689            let fg = if s.active { palette.rail.accent } else { palette.rail.icon_inactive };
690            let mut celda = View::new(Style {
691                size: Size { width: length(lado), height: length(lado) },
692                align_items: Some(AlignItems::CENTER),
693                justify_content: Some(JustifyContent::CENTER),
694                flex_shrink: 0.0,
695                ..Default::default()
696            })
697            .radius(5.0)
698            .hover_fill(palette.rail.bg_hover)
699            // Un subdiente es del mismo linaje que su diente: pestaña, con su
700            // activo declarado. El nombre sale del tooltip porque el ícono lo
701            // pinta un closure opaco y no hay texto en el árbol.
702            .role(llimphi_compositor::Role::Tab)
703            .aria_label(s.tooltip.clone())
704            .aria_pressed(s.active)
705            .tooltip(s.tooltip.clone())
706            .on_click(s.on_click.clone())
707            .children(vec![(s.icon)(lado - 6.0, fg)]);
708            if s.active {
709                celda = celda.fill(palette.rail.bg_active).border(1.5, palette.rail.accent);
710            }
711            match &s.on_close {
712                Some(m) => celda
713                    .on_right_click(m.clone())
714                    .on_middle_click(m.clone()),
715                None => celda,
716            }
717        })
718        .collect();
719    View::new(Style {
720        flex_direction: FlexDirection::Column,
721        size: Size { width: length(rail_w), height: auto() },
722        align_items: Some(AlignItems::CENTER),
723        justify_content: Some(JustifyContent::CENTER),
724        gap: Size { width: length(0.0_f32), height: length(3.0_f32) },
725        padding: TaffyRect {
726            left: length(0.0_f32),
727            right: length(0.0_f32),
728            top: length(3.0_f32),
729            bottom: length(3.0_f32),
730        },
731        flex_shrink: 0.0,
732        ..Default::default()
733    })
734    .children(celdas)
735}
736
737/// Un **separador** de grupo dentro del rail: una fina hairline tenue (color
738/// `border`) de **alto mínimo**, con un pequeño margen lateral para que no toque los
739/// bordes. Separa dientes sin robar altura ni resaltar — lo justo para leer un corte
740/// entre grupos. Se intercala en la columna del rail (ver [`build_rail`]).
741fn rail_separator<Msg: Clone + Send + Sync + 'static>(
742    rail_w: f32,
743    palette: &RagSidebarPalette,
744) -> View<Msg> {
745    let linea = View::new(Style {
746        size: Size { width: percent(1.0_f32), height: length(1.0_f32) },
747        flex_shrink: 0.0,
748        ..Default::default()
749    })
750    .fill(palette.border);
751    View::new(Style {
752        flex_direction: FlexDirection::Column,
753        size: Size { width: length(rail_w), height: length(7.0_f32) },
754        align_items: Some(AlignItems::CENTER),
755        justify_content: Some(JustifyContent::CENTER),
756        padding: TaffyRect {
757            left: length(7.0_f32),
758            right: length(7.0_f32),
759            top: length(0.0_f32),
760            bottom: length(0.0_f32),
761        },
762        flex_shrink: 0.0,
763        ..Default::default()
764    })
765    .children(vec![linea])
766}
767
768/// Envuelve el rail en una **franja** de alto completo (fill) con fondo. Ancho fijo
769/// `rail_w`; alto 100% del contenedor.
770fn rail_franja<Msg: Clone + Send + Sync + 'static>(
771    rail: View<Msg>,
772    rail_w: f32,
773    palette: &RagSidebarPalette,
774) -> View<Msg> {
775    View::new(Style {
776        flex_direction: FlexDirection::Column,
777        size: Size { width: length(rail_w), height: percent(1.0_f32) },
778        align_items: Some(AlignItems::CENTER),
779        flex_shrink: 0.0,
780        ..Default::default()
781    })
782    .fill(palette.bg_panel_alt)
783    .children(vec![rail])
784}
785
786/// Como [`rail_franja`], pero con **dientes de footer** anclados al fondo: entre la
787/// columna de arriba y el footer va un espaciador `flex_grow` que empuja el footer al
788/// borde inferior de la franja (alto 100%). Con `footer` vacío es equivalente a
789/// [`rail_franja`]. Es el patrón «diente de sistema abajo del todo».
790#[allow(clippy::too_many_arguments)]
791fn rail_franja_footer<Msg: Clone + Send + Sync + 'static>(
792    columna: View<Msg>,
793    footer: Vec<RagTooth<Msg>>,
794    state: &RagSidebarState,
795    rail_w: f32,
796    side: RagSide,
797    palette: &RagSidebarPalette,
798    map: &Arc<dyn Fn(RagSidebarMsg) -> Msg + Send + Sync>,
799    on_reorder: Option<ReorderFn<Msg>>,
800) -> View<Msg> {
801    if footer.is_empty() {
802        return rail_franja(columna, rail_w, palette);
803    }
804    // El footer recibe el MISMO `on_reorder` que el rail de arriba. Sin esto, un
805    // diente de footer con `grupo` puesto se podía agarrar pero su suelta no emitía
806    // nada: el grupo anclado al fondo era el único inarrastrable del rail, que es
807    // justo lo contrario de lo que un grupo significa (ver `GrupoRail` en pata).
808    let footer_rail = build_rail(state, &footer, rail_w, side, palette, map, None, on_reorder, None);
809    let espaciador = View::new(Style {
810        size: Size { width: length(rail_w), height: length(0.0_f32) },
811        flex_grow: 1.0,
812        flex_shrink: 0.0,
813        ..Default::default()
814    });
815    View::new(Style {
816        flex_direction: FlexDirection::Column,
817        size: Size { width: length(rail_w), height: percent(1.0_f32) },
818        align_items: Some(AlignItems::CENTER),
819        flex_shrink: 0.0,
820        ..Default::default()
821    })
822    .fill(palette.bg_panel_alt)
823    .children(vec![columna, espaciador, footer_rail])
824}
825
826/// El **divisor** arrastrable del borde interno del panel: agranda/achica
827/// `state.panel_w`. En un sidebar izquierdo el borde interno es la derecha (arrastrar
828/// a la derecha agranda → `+dx`); en uno derecho es la izquierda (se invierte).
829/// Ancho del divisor arrastrable, px. Es también el **carril** que el panel le
830/// reserva cuando los dos caerían en el mismo canto (ver `rag_sidebar_view`).
831pub const DIVISOR_W: f32 = 6.0;
832
833fn resize_divider<Msg: Clone + Send + Sync + 'static>(
834    side: RagSide,
835    palette: &RagSidebarPalette,
836    map: Arc<dyn Fn(RagSidebarMsg) -> Msg + Send + Sync>,
837) -> View<Msg> {
838    let (sign, inset) = match side {
839        RagSide::Left => (
840            1.0_f32,
841            TaffyRect { left: auto(), right: length(0.0_f32), top: length(0.0_f32), bottom: length(0.0_f32) },
842        ),
843        RagSide::Right => (
844            -1.0_f32,
845            TaffyRect { left: length(0.0_f32), right: auto(), top: length(0.0_f32), bottom: length(0.0_f32) },
846        ),
847    };
848    View::new(Style {
849        position: Position::Absolute,
850        inset,
851        size: Size { width: length(DIVISOR_W), height: auto() },
852        ..Default::default()
853    })
854    .hover_fill(palette.accent)
855    .draggable(move |phase, dx, _dy| match phase {
856        llimphi_compositor::DragPhase::Move => Some((map)(RagSidebarMsg::Resize(sign * dx))),
857        _ => None,
858    })
859}
860
861/// La **zona caliente** de reaparición: una fina franja al borde que, al recibir el
862/// puntero, revela el sidebar (autohide). Es lo único que se pinta cuando el sidebar
863/// está oculto ([`RagSidebarState::hidden`]).
864fn edge_hotzone<Msg: Clone + Send + Sync + 'static>(
865    palette: &RagSidebarPalette,
866    map: Arc<dyn Fn(RagSidebarMsg) -> Msg + Send + Sync>,
867) -> View<Msg> {
868    // `hover_fill` es OBLIGATORIO: el runtime solo despacha `on_pointer_enter` para
869    // el nodo que `hit_test_hover` elige, y ese hit-test SOLO considera nodos con
870    // `hover_fill`. Sin él, la franja nunca revelaría.
871    View::new(Style {
872        size: Size { width: length(5.0_f32), height: percent(1.0_f32) },
873        flex_shrink: 0.0,
874        ..Default::default()
875    })
876    .fill(palette.border)
877    .hover_fill(palette.accent)
878    .on_pointer_enter((map)(RagSidebarMsg::Reveal(true)))
879}
880
881/// El **botón de línea punteada** al tope del rail **colapsado** ("guardado"): una
882/// franja finísima (alto mínimo, ancho del rail) que, al clickearse, abre el popover
883/// de disposición con el mismo [`RagSidebarMsg::ControlToggle`] que el control gear.
884///
885/// Existe porque con el sidebar guardado el chrome (buscador + control) NO está a la
886/// vista, así que no habría cómo llegar a las opciones del rag. La línea punteada las
887/// devuelve a mano sin robarle altura a los dientes (se tiñe de acento si el popover
888/// está abierto).
889fn control_dotted_button<Msg: Clone + Send + Sync + 'static>(
890    rail_w: f32,
891    control_open: bool,
892    palette: &RagSidebarPalette,
893    map: &Arc<dyn Fn(RagSidebarMsg) -> Msg + Send + Sync>,
894) -> View<Msg> {
895    let color = if control_open { palette.accent } else { palette.fg_muted };
896    let m = map.clone();
897    View::new(Style {
898        size: Size { width: length(rail_w), height: length(11.0_f32) },
899        align_items: Some(AlignItems::CENTER),
900        justify_content: Some(JustifyContent::CENTER),
901        flex_shrink: 0.0,
902        ..Default::default()
903    })
904    .radius(4.0)
905    .hover_fill(palette.bg_button_hover)
906    // No tiene texto ni ícono: lo dibuja `paint_with`. Sin label es un botón que
907    // no se puede nombrar ni apuntar desde afuera.
908    .role(llimphi_compositor::Role::Button)
909    .aria_label("Opciones de disposición")
910    .aria_pressed(control_open)
911    .tooltip("Opciones de disposición".to_string())
912    .on_click((m)(RagSidebarMsg::ControlToggle))
913    .paint_with(move |scene, _ts, rect| paint_dotted_line(scene, rect, color))
914}
915
916/// Una **línea horizontal punteada** centrada en `rect`: puntitos redondos del color
917/// dado, espaciados, con un margen a los costados. La firma del botón de opciones del
918/// rail colapsado (mismo lenguaje que la hebra punteada de una madre *stale*).
919fn paint_dotted_line(scene: &mut llimphi_compositor::Lienzo, rect: PaintRect, color: Color) {
920    use llimphi_compositor::peniko::kurbo::{Affine, RoundedRect};
921    use llimphi_compositor::peniko::Fill;
922    if rect.w <= 0.0 || rect.h <= 0.0 {
923        return;
924    }
925    let cy = (rect.y + rect.h * 0.5) as f64;
926    let dot = 2.0_f64; // diámetro del punto
927    let step = dot + 3.0_f64; // punto + hueco
928    let margin = 6.0_f64;
929    let x0 = rect.x as f64 + margin;
930    let x1 = (rect.x + rect.w) as f64 - margin;
931    let mut x = x0;
932    while x + dot <= x1 {
933        let rr = RoundedRect::new(x, cy - dot / 2.0, x + dot, cy + dot / 2.0, dot / 2.0);
934        scene.fill(Fill::NonZero, Affine::IDENTITY, &color, None, &rr);
935        x += step;
936    }
937}
938
939/// **La** vista del sidebar (una sola forma): rail (dock-rail) + panel + chrome +
940/// las 4 opciones + **divisor de ancho arrastrable**, en un `View`. El **ancho** lo
941/// fija `state.panel_w` (redimensionable con el divisor) más el del rail; el **alto**
942/// lo llena el contenedor del host. El host lo monta donde quiera (pane, columna,
943/// overlay).
944///
945/// - Sin diente abierto → solo el rail (franja `rail_w`, sin divisor).
946/// - AFUERA (`rail_outside`) → el rail va al borde EXTERNO (pegado al borde de la
947///   ventana): `[rail][panel]` en un sidebar izquierdo.
948/// - ADENTRO (`!rail_outside`) → el rail va al borde INTERNO, **entre el panel y el
949///   canvas**: `[panel][rail]` en un sidebar izquierdo.
950pub fn rag_sidebar_view<Msg: Clone + Send + Sync + 'static>(
951    state: &RagSidebarState,
952    spec: RagSidebarView<Msg>,
953    side: RagSide,
954    palette: &RagSidebarPalette,
955) -> View<Msg> {
956    let RagSidebarView { teeth, body, accessory, search_hits, t, rail_w, map, on_drop, on_reorder, grow } = spec;
957
958    // Autohide oculto (sin panel ni reveal): solo la franja caliente de reaparición.
959    if state.hidden() {
960        return edge_hotzone(palette, map);
961    }
962
963    let rail = build_rail(state, &teeth, rail_w, side, palette, &map, on_drop, on_reorder, grow);
964    let panel_w = state.panel_w;
965
966    let inner = if !state.panel_visible() {
967        // Colapsado ("guardado"): solo el rail (ancho = rail_w). Sin panel ni divisor,
968        // y por tanto sin el chrome (buscador + control) que abre las opciones. Para no
969        // perderlas, una fina LÍNEA PUNTEADA al tope del rail las despliega
970        // (`ControlToggle`), robándole el mínimo de altura a los dientes.
971        let boton = control_dotted_button(rail_w, state.control_open, palette, &map);
972        let columna = View::new(Style {
973            flex_direction: FlexDirection::Column,
974            size: Size { width: length(rail_w), height: auto() },
975            align_items: Some(AlignItems::CENTER),
976            gap: Size { width: length(0.0_f32), height: length(4.0_f32) },
977            flex_shrink: 0.0,
978            ..Default::default()
979        })
980        .children(vec![boton, rail]);
981        rail_franja(columna, rail_w, palette)
982    } else {
983        // Panel visible. El rail va como franja a UN lado del panel:
984        //  - AFUERA: borde EXTERNO (pegado al borde de la ventana).
985        //  - ADENTRO: borde INTERNO (ENTRE el panel y el canvas).
986        // El eje `rail_outside` + el lado del sidebar deciden el orden en el Row.
987        let franja = rail_franja(rail, rail_w, palette);
988        // ¿El rail va PRIMERO (extremo del contenedor) en el Row? Sí para un sidebar
989        // izquierdo AFUERA (rail contra el borde izquierdo de ventana) o un sidebar
990        // derecho ADENTRO (rail hacia el centro). Al revés en los otros dos casos.
991        let rail_primero = match side {
992            RagSide::Left => state.rail_outside,
993            RagSide::Right => !state.rail_outside,
994        };
995        // **El divisor y la barra de scroll no comparten carril.**
996        //
997        // La barra de `llimphi-widget-scroll` vive contra el canto DERECHO de su
998        // viewport; el divisor de ancho vive contra el canto INTERNO del sidebar.
999        // En un sidebar IZQUIERDO con el rail afuera son el mismo canto, y como el
1000        // divisor se pinta encima gana el hit-test: ibas a arrastrar la barra y
1001        // arrastrabas el borde. Reporte textual del operador: *«su scrollbar se
1002        // confunde con el borde resizable»*.
1003        //
1004        // El panel le reserva al divisor su ancho como padding, así que la barra
1005        // queda por dentro y cada uno tiene dónde agarrarse. En los otros tres
1006        // casos no se tocan —el divisor cae sobre el rail, o sobre el canto
1007        // izquierdo del panel, donde no hay barra— y no se reserva nada: un carril
1008        // vacío es ancho de contenido tirado.
1009        let carril_divisor = matches!(side, RagSide::Left) && rail_primero;
1010        let panel = View::new(Style {
1011            size: Size { width: length(panel_w), height: percent(1.0_f32) },
1012            flex_shrink: 0.0,
1013            padding: TaffyRect {
1014                left: length(0.0_f32),
1015                right: length(if carril_divisor { DIVISOR_W } else { 0.0 }),
1016                top: length(0.0_f32),
1017                bottom: length(0.0_f32),
1018            },
1019            ..Default::default()
1020        })
1021        .children(vec![rag_panel_view(state, &teeth, body, accessory, search_hits, t, side, palette, map.clone())]);
1022        let mut hijos = if rail_primero { vec![franja, panel] } else { vec![panel, franja] };
1023        hijos.push(resize_divider(side, palette, map.clone()));
1024        View::new(Style {
1025            position: Position::Relative,
1026            flex_direction: FlexDirection::Row,
1027            size: Size { width: length(rail_w + panel_w), height: percent(1.0_f32) },
1028            flex_shrink: 0.0,
1029            ..Default::default()
1030        })
1031        .children(hijos)
1032    };
1033
1034    // Reaparición/guardado: solo cuando el sidebar puede esconderse (autohide) o
1035    // flota (se guarda al desenfocar). Para un Fijo no-autohide no hace falta
1036    // rastrear el puntero (evita churn). `map` sigue vivo (todos sus usos previos
1037    // fueron por préstamo).
1038    if state.autohide || !state.docked {
1039        inner
1040            .on_pointer_enter((map)(RagSidebarMsg::Reveal(true)))
1041            .on_pointer_leave((map)(RagSidebarMsg::Reveal(false)))
1042    } else {
1043        inner
1044    }
1045}
1046
1047/// La **franja-rail** standalone, como pieza pública — para hosts que DEBEN pintar
1048/// el rail en una superficie SEPARADA del panel. Es el caso de **pata en
1049/// layer-shell**: el rail y el drawer son dos surfaces Wayland distintas porque
1050/// crecer UNA sola surface por-diente reconfiguraba el swapchain y crasheaba en Iris
1051/// Xe (ver `pata .../sidebar.rs`). Aun partido en dos surfaces, el host sigue usando
1052/// **sólo código del widget** para el rail (cero rail propio): esta fn produce
1053/// EXACTAMENTE lo que pinta la rama colapsada de [`rag_sidebar_view`] — la franja de
1054/// fondo con el **botón punteado** de opciones encima de los dientes (+ encía +
1055/// separadores de grupo). El panel se pinta aparte con [`rag_panel_view`]; ambos son
1056/// del widget, split sólo en la frontera de surfaces.
1057///
1058/// `dock-rail` queda **encapsulado adentro** del widget: el host nunca lo toca.
1059///
1060/// `footer` son dientes **anclados al fondo** de la franja (separados de los dientes
1061/// de arriba por un espaciador flexible): el lugar canónico para el diente de
1062/// sistema/sesión, que vive abajo del todo estilo VSCode/Slack. Vacío = sin footer.
1063#[allow(clippy::too_many_arguments)]
1064pub fn rag_rail_view<Msg: Clone + Send + Sync + 'static>(
1065    state: &RagSidebarState,
1066    teeth: Vec<RagTooth<Msg>>,
1067    rail_w: f32,
1068    side: RagSide,
1069    palette: &RagSidebarPalette,
1070    map: Arc<dyn Fn(RagSidebarMsg) -> Msg + Send + Sync>,
1071    on_drop: Option<Arc<dyn Fn(u64) -> Option<Msg> + Send + Sync>>,
1072    on_reorder: Option<ReorderFn<Msg>>,
1073    grow: Option<(String, String, Msg)>,
1074    footer: Vec<RagTooth<Msg>>,
1075) -> View<Msg> {
1076    // Autohide oculto: sólo la fina zona caliente de reaparición al borde.
1077    if state.hidden() {
1078        return edge_hotzone(palette, map);
1079    }
1080    // El `on_reorder` se **comparte** entre el rail de arriba y el del footer: es un
1081    // `Arc`, así que clonarlo es un contador, no una copia del callback.
1082    let rail = build_rail(
1083        state,
1084        &teeth,
1085        rail_w,
1086        side,
1087        palette,
1088        &map,
1089        on_drop,
1090        on_reorder.clone(),
1091        grow,
1092    );
1093    // En un host de dos superficies el chrome (buscador + control) vive en la surface
1094    // del PANEL, así que el rail necesita su propia entrada a las opciones — la misma
1095    // línea punteada que la rama colapsada del widget.
1096    let boton = control_dotted_button(rail_w, state.control_open, palette, &map);
1097    let columna = View::new(Style {
1098        flex_direction: FlexDirection::Column,
1099        size: Size { width: length(rail_w), height: auto() },
1100        align_items: Some(AlignItems::CENTER),
1101        gap: Size { width: length(0.0_f32), height: length(4.0_f32) },
1102        flex_shrink: 0.0,
1103        ..Default::default()
1104    })
1105    .children(vec![boton, rail]);
1106    let franja = rail_franja_footer(columna, footer, state, rail_w, side, palette, &map, on_reorder);
1107    // Reaparición/guardado sólo si puede esconderse (autohide) o flota.
1108    if state.autohide || !state.docked {
1109        franja
1110            .on_pointer_enter((map.clone())(RagSidebarMsg::Reveal(true)))
1111            .on_pointer_leave((map)(RagSidebarMsg::Reveal(false)))
1112    } else {
1113        franja
1114    }
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use super::*;
1120    use crate::RagSidebarPalette;
1121    use llimphi_compositor::llimphi_layout::taffy::prelude::{Dimension, LengthPercentage};
1122    use llimphi_theme::Theme;
1123
1124    fn spec() -> RagSidebarView<i32> {
1125        RagSidebarView {
1126            teeth: vec![
1127                RagTooth::new(1, "A", Arc::new(|_, _| View::new(Style::default()))),
1128                RagTooth::new(2, "B", Arc::new(|_, _| View::new(Style::default()))),
1129            ],
1130            body: View::new(Style::default()),
1131            accessory: None,
1132            search_hits: false,
1133            t: 0.0,
1134            rail_w: 44.0,
1135            map: Arc::new(|m| match m {
1136                RagSidebarMsg::ControlToggle => 99,
1137                _ => 0,
1138            }),
1139            on_drop: None,
1140            on_reorder: None,
1141            grow: None,
1142        }
1143    }
1144
1145    /// Colapsado ("guardado"): el rail lleva una columna [línea-punteada, dientes] —
1146    /// el botón que devuelve las opciones cuando el chrome no está a la vista.
1147    #[test]
1148    fn colapsado_trae_boton_punteado_sobre_los_dientes() {
1149        let pal = RagSidebarPalette::from_theme(&Theme::dark());
1150        let st = RagSidebarState { open: None, ..Default::default() };
1151        let v = rag_sidebar_view(&st, spec(), RagSide::Left, &pal);
1152        // rail_franja → [columna]; columna → [boton_punteado, rail].
1153        let columna = &v.children[0];
1154        assert_eq!(columna.children.len(), 2, "línea punteada + rail de dientes");
1155        // El botón emite ControlToggle al clickearse (map → 99).
1156        assert_eq!(columna.children[0].on_click, Some(99), "la línea punteada abre las opciones");
1157    }
1158
1159    /// Un diente marcado con `separator_before` intercala **una** línea tenue justo
1160    /// encima suyo en la columna del rail; el primer diente nunca lleva separador.
1161    #[test]
1162    fn separador_intercala_una_linea_en_el_rail() {
1163        let pal = RagSidebarPalette::from_theme(&Theme::dark());
1164        let st = RagSidebarState::default();
1165        let map: Arc<dyn Fn(RagSidebarMsg) -> i32 + Send + Sync> = Arc::new(|_| 0);
1166        let teeth = vec![
1167            // El primero pide separador: se ignora (nada arriba que separar).
1168            RagTooth::new(1, "A", Arc::new(|_, _| View::new(Style::default()))).separator_before(),
1169            RagTooth::new(2, "B", Arc::new(|_, _| View::new(Style::default()))).separator_before(),
1170            RagTooth::new(3, "C", Arc::new(|_, _| View::new(Style::default()))),
1171        ];
1172        let rail = build_rail(&st, &teeth, 44.0, RagSide::Left, &pal, &map, None, None, None);
1173        // 3 dientes + 1 separador (antes de B) = 4 hijos; el de A no cuenta.
1174        assert_eq!(rail.children.len(), 4, "un solo separador intercalado");
1175        // El separador quedó en el índice 1 (entre A y B): es un contenedor de alto
1176        // mínimo, no un diente clickeable.
1177        assert!(rail.children[1].on_click.is_none(), "el separador no es interactivo");
1178    }
1179
1180    /// Un diente con **subdientes** cuelga su rama JUSTO debajo suyo y ARRIBA de su
1181    /// encía: el «+» del hueco siguiente pertenece al grupo, no a la rama. Los
1182    /// subdientes son clickeables (emiten el `Msg` del host, no un `RagSidebarMsg`).
1183    #[test]
1184    fn los_subdientes_cuelgan_entre_su_diente_y_su_encia() {
1185        let pal = RagSidebarPalette::from_theme(&Theme::dark());
1186        let st = RagSidebarState::default();
1187        let map: Arc<dyn Fn(RagSidebarMsg) -> i32 + Send + Sync> = Arc::new(|_| 0);
1188        let sub = |id: u64, on: i32| RagSubTooth {
1189            id,
1190            tooltip: "tab".to_string(),
1191            icon: Arc::new(|_, _| View::new(Style::default())),
1192            active: false,
1193            on_click: on,
1194            on_close: None,
1195        };
1196        let teeth = vec![
1197            RagTooth::new(1, "A", Arc::new(|_, _| View::new(Style::default())))
1198                .subteeth(vec![sub(10, 77), sub(11, 78)])
1199                .grow_after("+", "nuevo", 5),
1200            RagTooth::new(2, "B", Arc::new(|_, _| View::new(Style::default()))),
1201        ];
1202        let rail = build_rail(&st, &teeth, 44.0, RagSide::Left, &pal, &map, None, None, None);
1203        // [A, rama(A), encía, B].
1204        assert_eq!(rail.children.len(), 4, "diente + rama + encía + diente");
1205        let rama = &rail.children[1];
1206        assert_eq!(rama.children.len(), 2, "un subdiente por tab");
1207        assert_eq!(rama.children[0].on_click, Some(77));
1208        assert_eq!(rama.children[1].on_click, Some(78));
1209    }
1210
1211    /// **Las dos encías conviven en el mismo diente, y en este orden**: la de la
1212    /// rama («una pestaña más») pegada a la rama, y la de grupo («una sesión
1213    /// más») al pie de todo. El caso es el normal, no un borde: el último diente
1214    /// del grupo suele ser también el activo, que es el que despliega su rama.
1215    #[test]
1216    fn las_dos_encias_conviven_y_la_de_grupo_va_al_pie() {
1217        let pal = RagSidebarPalette::from_theme(&Theme::dark());
1218        let st = RagSidebarState::default();
1219        let map: Arc<dyn Fn(RagSidebarMsg) -> i32 + Send + Sync> = Arc::new(|_| 0);
1220        let sub = |id: u64, on: i32| RagSubTooth {
1221            id,
1222            tooltip: "tab".to_string(),
1223            icon: Arc::new(|_, _| View::new(Style::default())),
1224            active: false,
1225            on_click: on,
1226            on_close: None,
1227        };
1228        let teeth = vec![RagTooth::new(1, "A", Arc::new(|_, _| View::new(Style::default())))
1229            .subteeth(vec![sub(10, 77)])
1230            .grow_after("+", "pestaña nueva", 5)
1231            .grow_group_after("+", "sesión nueva", 6)];
1232        let rail = build_rail(&st, &teeth, 44.0, RagSide::Left, &pal, &map, None, None, None);
1233        // [A, rama(A), encía de rama, encía de grupo].
1234        assert_eq!(rail.children.len(), 4, "diente + rama + dos encías");
1235        let clic = |i: usize| rail.children[i].children.first().and_then(|c| c.on_click);
1236        assert_eq!(clic(2), Some(5), "la encía de la rama va justo debajo de la rama");
1237        assert_eq!(clic(3), Some(6), "la de grupo, al pie de todo");
1238    }
1239
1240    /// Un diente sin subdientes no agrega NADA al rail — la rama no existe como
1241    /// contenedor vacío (que robaría alto y separaría los dientes sin motivo).
1242    #[test]
1243    fn sin_subdientes_el_rail_queda_igual() {
1244        let pal = RagSidebarPalette::from_theme(&Theme::dark());
1245        let st = RagSidebarState::default();
1246        let map: Arc<dyn Fn(RagSidebarMsg) -> i32 + Send + Sync> = Arc::new(|_| 0);
1247        let teeth = vec![
1248            RagTooth::new(1, "A", Arc::new(|_, _| View::new(Style::default()))),
1249            RagTooth::new(2, "B", Arc::new(|_, _| View::new(Style::default()))),
1250        ];
1251        let rail = build_rail(&st, &teeth, 44.0, RagSide::Left, &pal, &map, None, None, None);
1252        assert_eq!(rail.children.len(), 2);
1253    }
1254
1255    /// `rag_rail_view` (la mitad-rail standalone, para hosts de dos superficies como
1256    /// pata/Iris Xe) produce la franja con la columna [línea-punteada, dientes] — el
1257    /// mismo rail del widget, sin que el host toque `dock-rail`.
1258    #[test]
1259    fn rail_standalone_trae_boton_y_dientes_del_widget() {
1260        let pal = RagSidebarPalette::from_theme(&Theme::dark());
1261        let st = RagSidebarState { open: None, ..Default::default() };
1262        let map: Arc<dyn Fn(RagSidebarMsg) -> i32 + Send + Sync> =
1263            Arc::new(|m| if matches!(m, RagSidebarMsg::ControlToggle) { 99 } else { 0 });
1264        let teeth = vec![
1265            RagTooth::new(1, "A", Arc::new(|_, _| View::new(Style::default()))),
1266            RagTooth::new(2, "B", Arc::new(|_, _| View::new(Style::default()))),
1267        ];
1268        let v = rag_rail_view(&st, teeth, 44.0, RagSide::Left, &pal, map, None, None, None, Vec::new());
1269        // rail_franja → [columna]; columna → [boton_punteado, rail].
1270        let columna = &v.children[0];
1271        assert_eq!(columna.children.len(), 2, "línea punteada + rail de dientes");
1272        assert_eq!(columna.children[0].on_click, Some(99), "la línea punteada abre las opciones");
1273    }
1274
1275    /// Con `footer` no vacío, la franja lleva [columna, espaciador, footer_rail]: el
1276    /// espaciador `flex_grow` empuja el diente de footer al borde inferior.
1277    #[test]
1278    fn footer_ancla_diente_al_fondo() {
1279        let pal = RagSidebarPalette::from_theme(&Theme::dark());
1280        let st = RagSidebarState { open: None, ..Default::default() };
1281        let map: Arc<dyn Fn(RagSidebarMsg) -> i32 + Send + Sync> = Arc::new(|_| 0);
1282        let teeth = vec![RagTooth::new(1, "A", Arc::new(|_, _| View::new(Style::default())))];
1283        let footer = vec![RagTooth::new(5_000_000, "Sistema", Arc::new(|_, _| View::new(Style::default())))];
1284        let v = rag_rail_view(&st, teeth, 44.0, RagSide::Left, &pal, map, None, None, None, footer);
1285        // La franja con footer es una columna [columna_arriba, espaciador, footer_rail].
1286        assert_eq!(v.children.len(), 3, "columna + espaciador + footer");
1287        // El espaciador crece (flex_grow) para empujar el footer al fondo.
1288        assert!(v.children[1].style.flex_grow > 0.0, "el espaciador empuja el footer abajo");
1289    }
1290
1291    /// Sin ningún `separator_before`, el rail es solo los dientes (sin líneas extra).
1292    #[test]
1293    fn sin_separadores_rail_intacto() {
1294        let pal = RagSidebarPalette::from_theme(&Theme::dark());
1295        let st = RagSidebarState::default();
1296        let map: Arc<dyn Fn(RagSidebarMsg) -> i32 + Send + Sync> = Arc::new(|_| 0);
1297        let teeth = vec![
1298            RagTooth::new(1, "A", Arc::new(|_, _| View::new(Style::default()))),
1299            RagTooth::new(2, "B", Arc::new(|_, _| View::new(Style::default()))),
1300        ];
1301        let rail = build_rail(&st, &teeth, 44.0, RagSide::Left, &pal, &map, None, None, None);
1302        assert_eq!(rail.children.len(), 2, "dos dientes, sin separadores");
1303    }
1304
1305    /// **Doble-click en un diente = alternar el eje "Espacio"**, y el gesto es
1306    /// EXCLUSIVO: el nodo lo declara así para que el runtime descarte el click
1307    /// simple del par y el doble no despliegue/repliegue el panel de paso.
1308    ///
1309    /// El click simple sigue intacto en el mismo nodo — el gesto se agrega, no
1310    /// reemplaza al que ya estaba.
1311    #[test]
1312    fn el_diente_alterna_el_espacio_con_doble_click_y_es_exclusivo() {
1313        let pal = RagSidebarPalette::from_theme(&Theme::dark());
1314        let st = RagSidebarState::default();
1315        // Map identidad: así el test lee el `RagSidebarMsg` que emite el diente.
1316        let map: Arc<dyn Fn(RagSidebarMsg) -> RagSidebarMsg + Send + Sync> = Arc::new(|m| m);
1317        let teeth = vec![RagTooth::new(1, "A", Arc::new(|_, _| View::new(Style::default())))];
1318        let rail = build_rail(&st, &teeth, 44.0, RagSide::Left, &pal, &map, None, None, None);
1319        let diente = &rail.children[0];
1320        let raro = diente.rare.as_ref().expect("el diente lleva props raras");
1321        // Por forma y no por igualdad: `RagSidebarMsg` ya no deriva `PartialEq`
1322        // desde que lleva un evento del campo de texto adentro (un `KeyEvent` no
1323        // se compara). La afirmación es la misma — qué mensaje emite el gesto.
1324        assert!(
1325            matches!(raro.on_double_tap, Some(RagSidebarMsg::ToggleDocked)),
1326            "dos clicks alternan Flota/Fijo"
1327        );
1328        assert!(
1329            raro.double_tap_exclusive.is_some(),
1330            "exclusivo: el runtime retiene y descarta el click simple del par"
1331        );
1332        let click = raro.on_click_at.as_ref().expect("y el click simple sigue estando");
1333        assert!(
1334            matches!(click(1.0, 1.0, 44.0, 44.0), Some(RagSidebarMsg::Activate(1))),
1335            "un click sigue desplegando su panel"
1336        );
1337    }
1338
1339    /// Con un diente abierto NO va el botón: el chrome (buscador + control gear) ya
1340    /// está a la vista en el panel, así que no se duplica.
1341    #[test]
1342    fn desplegado_no_trae_boton_punteado() {
1343        let pal = RagSidebarPalette::from_theme(&Theme::dark());
1344        let st = RagSidebarState { open: Some(1), ..Default::default() };
1345        let v = rag_sidebar_view(&st, spec(), RagSide::Left, &pal);
1346        // Panel visible → Row [franja-rail, panel, divisor]; ningún hijo es la columna
1347        // con el botón punteado (la franja envuelve el rail a secas).
1348        assert!(v.children.len() >= 2, "rail + panel (+ divisor)");
1349    }
1350
1351    /// **El divisor tiene carril propio donde chocaría con la barra de scroll.**
1352    ///
1353    /// Sidebar izquierdo con el rail afuera: el canto derecho del panel es también
1354    /// el canto interno del sidebar, o sea el del divisor — y ahí es donde
1355    /// `llimphi-widget-scroll` pinta su barra. Sin el carril, arrastrar la barra
1356    /// arrastraba el borde.
1357    #[test]
1358    fn el_divisor_tiene_carril_donde_chocaria_con_la_barra() {
1359        let pal = RagSidebarPalette::from_theme(&Theme::dark());
1360        let hay_carril = |st: &RagSidebarState, side: RagSide| -> bool {
1361            let v = rag_sidebar_view(st, spec(), side, &pal);
1362            // El panel es el hijo cuyo ancho es `panel_w` (el otro es la franja del
1363            // rail, y el divisor es absoluto).
1364            let p = v
1365                .children
1366                .iter()
1367                .find(|c| c.style.size.width == Dimension::length(st.panel_w))
1368                .expect("el panel");
1369            p.style.padding.right == LengthPercentage::length(DIVISOR_W)
1370        };
1371
1372        let afuera = RagSidebarState { open: Some(1), rail_outside: true, ..Default::default() };
1373        assert!(
1374            hay_carril(&afuera, RagSide::Left),
1375            "izquierdo con el rail afuera: el divisor y la barra caen en el mismo canto"
1376        );
1377
1378        // Los otros tres casos no se tocan, y reservar ahí sería ancho tirado.
1379        let adentro = RagSidebarState { open: Some(1), rail_outside: false, ..Default::default() };
1380        assert!(!hay_carril(&adentro, RagSide::Left), "el divisor cae sobre el rail");
1381        assert!(!hay_carril(&afuera, RagSide::Right), "a la derecha el divisor va al canto izquierdo");
1382        assert!(!hay_carril(&adentro, RagSide::Right), "y acá también cae sobre el rail");
1383    }
1384}