tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1//! 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}