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 tira de pestañas **del cajón**: chips que negocian su ancho y una activa
2//! con forma de prisma.
3//!
4//! Es el mismo dibujo que estrenó el cajón de shuma (AR21) —chip redondeado con
5//! punto de estado a la izquierda, ✕ discreta a la derecha, y la activa pintada
6//! como un trapecio con los cantos abiertos hacia el cuerpo—, promovido acá para
7//! que lo pueda usar cualquier app sin copiarlo. Lo que NO viene es lo que sólo
8//! significa en una terminal (el LED que late, el hilo de cava, los avisos de
9//! agente): eso sigue siendo de shuma. Acá queda la **forma**.
10//!
11//! La diferencia con [`crate::tabs_view`] no es cosmética: allá cada tab mide un
12//! ancho fijo y desborda; acá los chips **negocian** —`flex_basis` es el deseo,
13//! `flex_shrink` la negociación, `min_size` el piso—, así una pestaña sola se
14//! lee entera y con ocho se achican todas juntas antes de aparecer los puntos
15//! suspensivos.
16
17use alloc::format;
18use alloc::string::{String, ToString};
19use alloc::sync::Arc;
20use alloc::vec;
21use alloc::vec::Vec;
22
23use llimphi_compositor::llimphi_layout::taffy::{
24 prelude::{auto, length, percent, Dimension, FlexDirection, Position, Size, Style},
25 AlignItems, JustifyContent, Rect,
26};
27use llimphi_compositor::llimphi_text::Alignment;
28use llimphi_compositor::peniko::kurbo::{Affine, BezPath};
29use llimphi_compositor::peniko::{Color, Fill};
30use llimphi_compositor::{PaintRect, View};
31
32/// Ancho mínimo de una pestaña (px): con la tira llena, todas se achican hasta
33/// acá y recién entonces el título se corta con puntos suspensivos.
34pub const TAB_MIN: f32 = 96.0;
35/// Ancho máximo de una pestaña (px). Generoso —un título largo entra— pero no es
36/// el ancho de la tira: una pestaña sola sigue siendo una pestaña, no un
37/// encabezado.
38pub const TAB_MAX: f32 = 340.0;
39/// Cuánto más ancha es la pestaña **activa**: pide un 40 % más y su piso sube en
40/// la misma proporción. Es la única cuyo título importa leer entero.
41pub const ACTIVA_CRECE: f32 = 1.4;
42/// Ancho reservado para la ✕ de cerrar (px).
43const CERRAR_W: f32 = 18.0;
44/// Alto del chip (px). Subió de 22 a 26 el 2026-09-20: con el rótulo centrado de
45/// verdad, 22 px dejaban el texto tocando los dos cantos.
46const TITULO_H: f32 = 26.0;
47/// Alto de la tira completa (px). La diferencia con [`TITULO_H`] es el **margen
48/// de arriba y abajo** del chip —la mitad de cada lado—, y por eso los dos
49/// números van juntos: separarlos es cómo se pierde el aire.
50pub const TIRA_ALTO: f32 = TITULO_H + 2.0 * TIRA_MARGEN_Y;
51/// Aire arriba y abajo de cada chip dentro de la tira (px).
52const TIRA_MARGEN_Y: f32 = 4.0;
53/// Separación entre chips (px).
54const TIRA_GAP: f32 = 4.0;
55/// Aire a los costados de la tira (px).
56const TIRA_PAD: f32 = 6.0;
57/// Aire a los costados del rótulo **dentro** del chip (px).
58const CHIP_PAD_X: f32 = 12.0;
59/// Diámetro del punto de estado (px).
60const PUNTO: f32 = 7.0;
61
62/// Una pestaña de la tira.
63pub struct TabCajon {
64 /// Lo que se lee en el chip.
65 pub rotulo: String,
66 /// Punto de color a la izquierda del rótulo. `None` = sin punto, y entonces
67 /// el título arranca pegado al canto: un punto apagado ocupa lugar sin decir
68 /// nada.
69 pub punto: Option<Color>,
70 /// Tinte del chip, mezclado sobre su fondo (0.0 = nada). Para lo que hay que
71 /// notar de reojo sin leer: un archivo sin guardar, una pestaña con algo
72 /// pendiente.
73 pub tinte: Option<(Color, f32)>,
74}
75
76impl TabCajon {
77 /// Una pestaña con rótulo y nada más.
78 pub fn new(rotulo: impl Into<String>) -> Self {
79 Self { rotulo: rotulo.into(), punto: None, tinte: None }
80 }
81
82 /// Con punto de estado.
83 pub fn punto(mut self, c: Color) -> Self {
84 self.punto = Some(c);
85 self
86 }
87
88 /// Con el chip teñido (`f` = cuánto, 0..1).
89 pub fn tinte(mut self, c: Color, f: f32) -> Self {
90 self.tinte = Some((c, f));
91 self
92 }
93}
94
95/// Paleta de la tira. Los nombres son los del `Theme` semántico, no colores
96/// sueltos: la tira tiene que seguir el tema como cualquier otro widget.
97#[derive(Debug, Clone, Copy)]
98pub struct TabsCajonPalette {
99 pub bg_tira: Color,
100 pub bg_chip: Color,
101 pub bg_chip_activa: Color,
102 pub bg_hover: Color,
103 pub fg: Color,
104 pub fg_activa: Color,
105 /// La pestaña al frente como **degradé** del acento-gradiente del proceso
106 /// (`llimphi_theme::relleno_activo` sobre `bg_chip_activa`), o `None` =
107 /// lisa. 2026-10-12: *«los tabs están ocre sin gradiente»*.
108 pub bg_chip_activa_gradiente: Option<[Color; llimphi_theme::ACENTO_PARADAS]>,
109}
110
111impl Default for TabsCajonPalette {
112 fn default() -> Self {
113 Self::from_theme(&llimphi_theme::Theme::dark())
114 }
115}
116
117impl TabsCajonPalette {
118 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
119 Self {
120 bg_tira: t.bg_panel_alt,
121 bg_chip: t.bg_panel_alt,
122 bg_chip_activa: t.bg_selected,
123 bg_chip_activa_gradiente: llimphi_theme::relleno_activo(t.bg_selected),
124 bg_hover: t.bg_row_hover,
125 fg: t.fg_muted,
126 fg_activa: t.fg_text,
127 }
128 }
129}
130
131/// Especificación de la tira.
132pub struct TabsCajonSpec<Msg> {
133 pub tabs: Vec<TabCajon>,
134 pub active: usize,
135 pub on_select: Arc<dyn Fn(usize) -> Msg + Send + Sync>,
136 /// La ✕ por chip. `None` = pestañas que no se cierran desde acá.
137 pub on_close: Option<Arc<dyn Fn(usize) -> Msg + Send + Sync>>,
138 /// Botón derecho sobre un chip: `(índice, x, y)` **en pantalla**, que es lo
139 /// que necesita un menú anclado al cursor. `None` = la tira no ofrece menú.
140 pub on_context: Option<Arc<dyn Fn(usize, f32, f32) -> Msg + Send + Sync>>,
141 /// Contenido de la pestaña activa, debajo (o encima, con `abajo`) de la tira.
142 pub content: View<Msg>,
143 pub palette: TabsCajonPalette,
144 /// La tira **al pie** en vez de al tope. El prisma se espeja: los cantos se
145 /// abren hacia donde está el cuerpo, que es lo que hace que la pestaña activa
146 /// se lea pegada a lo que muestra.
147 pub abajo: bool,
148 /// Botones sueltos a la derecha de la tira (el «+» de pestaña nueva y lo que
149 /// haga falta). No negocian ancho: lo que se pierde al angostar son pestañas,
150 /// no botones.
151 pub extras: Vec<View<Msg>>,
152}
153
154/// Compone la tira + el contenido de la pestaña activa.
155pub fn tabs_cajon_view<Msg: Clone + 'static>(spec: TabsCajonSpec<Msg>) -> View<Msg> {
156 let TabsCajonSpec {
157 tabs,
158 active,
159 on_select,
160 on_close,
161 on_context,
162 content,
163 palette,
164 abajo,
165 extras,
166 } = spec;
167
168 let n = tabs.len();
169 let mut chips: Vec<View<Msg>> = Vec::with_capacity(n);
170 for (i, t) in tabs.iter().enumerate() {
171 chips.push(chip(
172 t,
173 i == active,
174 n,
175 &palette,
176 abajo,
177 (on_select)(i),
178 on_close.as_ref().map(|f| (f)(i)),
179 on_context.clone().map(|f| {
180 let g: Arc<dyn Fn(f32, f32) -> Msg + Send + Sync> =
181 Arc::new(move |x, y| (f)(i, x, y));
182 g
183 }),
184 ));
185 }
186 chips.extend(extras);
187
188 let tira = View::new(Style {
189 flex_direction: FlexDirection::Row,
190 size: Size { width: percent(1.0_f32), height: length(TIRA_ALTO) },
191 align_items: Some(AlignItems::CENTER),
192 flex_shrink: 0.0,
193 gap: Size { width: length(TIRA_GAP), height: length(0.0_f32) },
194 padding: Rect {
195 left: length(TIRA_PAD),
196 right: length(TIRA_PAD),
197 top: length(0.0_f32),
198 bottom: length(0.0_f32),
199 },
200 ..Default::default()
201 })
202 .fill(palette.bg_tira)
203 .clip(true)
204 .role(llimphi_compositor::Role::TabList)
205 .children(chips);
206
207 let cuerpo = View::new(Style {
208 size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
209 flex_grow: 1.0,
210 min_size: Size { width: length(0.0_f32), height: length(0.0_f32) },
211 ..Default::default()
212 })
213 .children(vec![content]);
214
215 View::new(Style {
216 flex_direction: FlexDirection::Column,
217 size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
218 ..Default::default()
219 })
220 .children(if abajo { vec![cuerpo, tira] } else { vec![tira, cuerpo] })
221}
222
223/// Un chip. La caja negocia su ancho; el contenido es punto + rótulo + ✕.
224fn chip<Msg: Clone + 'static>(
225 t: &TabCajon,
226 activa: bool,
227 n_tabs: usize,
228 palette: &TabsCajonPalette,
229 abajo: bool,
230 on_click: Msg,
231 on_close: Option<Msg>,
232 on_context: Option<Arc<dyn Fn(f32, f32) -> Msg + Send + Sync>>,
233) -> View<Msg> {
234 // **Todas se cierran, la última incluida.** Hasta el 2026-09-20 la ✕ se le
235 // negaba a la única pestaña que quedaba —«cerrarla no haría nada»—, y eso
236 // dejaba de ser cierto en cuanto la app tiene un estado vacío al que volver:
237 // `nada` abre una hoja en blanco. Quién puede quedarse sin pestañas lo decide
238 // el que las cuenta, no el chip.
239 let cerrable = on_close.is_some();
240 let _ = n_tabs;
241 let hueco_cerrar = if cerrable { CERRAR_W } else { 0.0 };
242 // Ancho deseado: lo que mide el rótulo con la fuente real, más el punto y el
243 // padding, acotado para que ocho pestañas sigan entrando.
244 let deseado = (llimphi_compositor::llimphi_text::text_width(&t.rotulo, 12.0)
245 + 2.0 * CHIP_PAD_X
246 + 16.0
247 + hueco_cerrar)
248 .clamp(TAB_MIN, TAB_MAX);
249 let (deseado, piso) = if activa {
250 ((deseado * ACTIVA_CRECE).min(TAB_MAX * ACTIVA_CRECE), TAB_MIN * ACTIVA_CRECE)
251 } else {
252 (deseado, TAB_MIN)
253 };
254
255 let base = if activa { palette.bg_chip_activa } else { palette.bg_chip };
256 let fill = match t.tinte {
257 Some((c, f)) => mezclar(base, c, f),
258 None => base,
259 };
260 let fg = if activa { palette.fg_activa } else { palette.fg };
261
262 let mut hijos: Vec<View<Msg>> = Vec::with_capacity(3);
263 if let Some(c) = t.punto {
264 hijos.push(
265 View::new(Style {
266 size: Size { width: length(PUNTO), height: length(PUNTO) },
267 flex_shrink: 0.0,
268 ..Default::default()
269 })
270 .fill(c)
271 .radius(f64::from(PUNTO) / 2.0),
272 );
273 }
274 hijos.push(
275 // **El alto va `auto`, no `TITULO_H`.** Un texto `Alignment::Start` se
276 // ancla ARRIBA de su caja (el centrado vertical del compositor sólo
277 // ocurre con `Center`, que acá no sirve: el rótulo se lee desde la
278 // izquierda). Con la caja de 22 px fijos el nombre quedaba pegado al
279 // canto de arriba con 8 px de aire abajo — que es lo que se ve como
280 // «no está centrado». Midiéndola por su texto, el `align_items:
281 // CENTER` de la fila la centra sola.
282 View::new(Style {
283 size: Size { width: Dimension::auto(), height: auto() },
284 flex_grow: 1.0,
285 min_size: Size { width: length(0.0_f32), height: auto() },
286 ..Default::default()
287 })
288 .text_aligned(t.rotulo.clone(), 12.0, fg, Alignment::Start)
289 .ellipsis(1),
290 );
291 // La ✕: presente pero DISCRETA (medio tono), con su propio realce al pasarle
292 // por encima — que es lo que evita el clic de más. No se pinta en la única
293 // pestaña que queda: cerrarla no haría nada, y un botón que no hace nada es
294 // peor que no tenerlo.
295 if let Some(msg) = on_close.filter(|_| cerrable) {
296 hijos.push(
297 View::new(Style {
298 size: Size { width: length(CERRAR_W), height: length(16.0_f32) },
299 flex_shrink: 0.0,
300 align_items: Some(AlignItems::CENTER),
301 justify_content: Some(JustifyContent::CENTER),
302 ..Default::default()
303 })
304 .radius(4.0)
305 .text_aligned(
306 "✕".to_string(),
307 11.0,
308 palette.fg.with_alpha(if activa { 0.75 } else { 0.40 }),
309 Alignment::Center,
310 )
311 .hover_fill(palette.bg_hover)
312 .cursor(llimphi_compositor::Cursor::Pointer)
313 .role(llimphi_compositor::Role::Button)
314 .aria_label(format!("Cerrar {}", t.rotulo))
315 .on_click(msg),
316 );
317 }
318
319 let fila = View::new(Style {
320 flex_direction: FlexDirection::Row,
321 size: Size { width: percent(1.0_f32), height: length(TITULO_H) },
322 align_items: Some(AlignItems::CENTER),
323 gap: Size { width: length(6.0_f32), height: length(0.0_f32) },
324 padding: Rect {
325 left: length(CHIP_PAD_X),
326 right: length(if cerrable { CHIP_PAD_X - 6.0 } else { CHIP_PAD_X }),
327 top: length(0.0_f32),
328 bottom: length(0.0_f32),
329 },
330 ..Default::default()
331 })
332 .children(hijos);
333
334 // La activa se pinta PRISMÁTICA: un trapecio con los cantos abiertos hacia el
335 // cuerpo, como la pestaña de una carpeta. Va como pintor de un hijo absoluto
336 // **detrás** del contenido y no como `fill` del chip: un `fill` es siempre un
337 // rectángulo (con radio, pero rectángulo), y la forma es justo lo que
338 // distingue a la que está al frente.
339 let mut capas: Vec<View<Msg>> = Vec::with_capacity(2);
340 if activa {
341 // Con tinte propio (una pestaña que trae su color) manda el tinte.
342 let degrade = if t.tinte.is_none() { palette.bg_chip_activa_gradiente } else { None };
343 capas.push(forma_prismatica_con(fill, degrade, abajo));
344 }
345 capas.push(fila);
346
347 let mut caja = View::new(Style {
348 flex_direction: FlexDirection::Column,
349 size: Size { width: Dimension::auto(), height: length(TITULO_H) },
350 flex_basis: length(deseado),
351 flex_grow: 0.0,
352 // La activa cede **la mitad** que las demás: cuando la tira se llena, las
353 // que se achican son las que no estás mirando.
354 flex_shrink: if activa { 0.5 } else { 1.0 },
355 min_size: Size { width: length(piso.min(deseado)), height: auto() },
356 max_size: Size { width: length(deseado), height: auto() },
357 ..Default::default()
358 })
359 .fill(if activa { Color::TRANSPARENT } else { fill })
360 .hover_fill(if activa { fill } else { palette.bg_hover })
361 .radius(4.0)
362 .cursor(llimphi_compositor::Cursor::Pointer)
363 // **El rol va donde está el `on_click`** (MANUAL §16.ter), y acá estuvo mal
364 // hasta el 2026-08-29: vivía en el nodo del rótulo, que no tiene handler.
365 // Resultado: el chip salía anónimo —lo que ninguna señalética puede
366 // apuntar— y AccessKit ofrecía `Action::Click` en un nodo que no lo tiene.
367 // El nombre va explícito porque el rótulo lo pinta un hijo.
368 .role(llimphi_compositor::Role::Tab)
369 .aria_label(t.rotulo.clone())
370 .aria_pressed(activa)
371 .on_click(on_click)
372 .children(capas);
373 // **En pantalla y no local**: el menú se ancla al cursor, y con coordenadas
374 // del chip saltaría al canto de la ventana (la misma trampa que ya mordió en
375 // `text-input`).
376 if let Some(f) = on_context {
377 caja = caja.on_right_click_screen(move |x, y, _w, _h| Some((f)(x, y)));
378 }
379 caja
380}
381
382/// El **prisma** de la pestaña activa: un nodo absoluto que pinta el trapecio de
383/// [`sendero_prismatico`] con `fill`.
384pub fn forma_prismatica<Msg: Clone + 'static>(fill: Color, espejada: bool) -> View<Msg> {
385 forma_prismatica_con(fill, None, espejada)
386}
387
388/// [`forma_prismatica`] que, con `degrade`, pinta el trapecio con ese
389/// degradé de izquierda a derecha en vez de `fill` liso.
390pub fn forma_prismatica_con<Msg: Clone + 'static>(
391 fill: Color,
392 degrade: Option<[Color; llimphi_theme::ACENTO_PARADAS]>,
393 espejada: bool,
394) -> View<Msg> {
395 View::new(Style {
396 position: Position::Absolute,
397 inset: Rect {
398 left: length(0.0_f32),
399 top: length(0.0_f32),
400 right: length(0.0_f32),
401 bottom: length(0.0_f32),
402 },
403 ..Default::default()
404 })
405 .paint_with(move |scene, _ts, rect: PaintRect| {
406 if rect.w <= 2.0 || rect.h <= 2.0 {
407 return;
408 }
409 let p = sendero_prismatico(rect, espejada);
410 match degrade {
411 Some(paradas) => {
412 // El rect del pintor ya está en px de la superficie: con el
413 // tramo de pantalla, las puntas van a los bordes del monitor.
414 let (a, b) = llimphi_theme::puntas_en_superficie().unwrap_or((
415 (f64::from(rect.x), f64::from(rect.y)),
416 (f64::from(rect.x + rect.w), f64::from(rect.y)),
417 ));
418 let g = llimphi_compositor::peniko::Gradient::new_linear(
419 llimphi_compositor::peniko::kurbo::Point::new(a.0, a.1),
420 llimphi_compositor::peniko::kurbo::Point::new(b.0, b.1),
421 )
422 .with_stops(paradas.as_slice());
423 scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &p);
424 }
425 None => scene.fill(Fill::NonZero, Affine::IDENTITY, fill, None, &p),
426 }
427 })
428}
429
430/// El **sendero** del trapecio, aparte del pintor: es la única parte de la forma
431/// que tiene una respuesta numérica, y con el pintor adentro de un `paint_with`
432/// no hay cómo preguntarle nada. Un test que sólo mira que el nodo exista no
433/// distingue un trapecio de su espejo — que es exactamente lo que hay que
434/// distinguir acá.
435///
436/// `espejada` = la tira está al pie, así que el canto ancho da para arriba.
437pub fn sendero_prismatico(rect: PaintRect, espejada: bool) -> BezPath {
438 const SESGO: f64 = 6.0;
439 const R: f64 = 4.0;
440 let (x0, y0) = (rect.x as f64, rect.y as f64);
441 let (x1, y1) = (x0 + rect.w as f64, y0 + rect.h as f64);
442 // `ancho` es el canto que da al cuerpo y `angosto` el que da al vacío;
443 // `dentro` es hacia dónde camina el redondeo desde el canto angosto. Los
444 // tres se dan vuelta juntos: la figura es la misma reflejada.
445 let (ancho, angosto, dentro) = if espejada { (y0, y1, -R) } else { (y1, y0, R) };
446 // Con la pestaña muy angosta el sesgo se achica: dos diagonales que se
447 // cruzan no son un trapecio, son una punta de flecha.
448 let s = SESGO.min((rect.w as f64) * 0.18);
449 let mut p = BezPath::new();
450 p.move_to((x0, ancho));
451 p.line_to((x0 + s, angosto + dentro));
452 p.quad_to((x0 + s, angosto), (x0 + s + R, angosto));
453 p.line_to((x1 - s - R, angosto));
454 p.quad_to((x1 - s, angosto), (x1 - s, angosto + dentro));
455 p.line_to((x1, ancho));
456 p.close_path();
457 p
458}
459
460/// Mezcla lineal de dos colores (`f` = cuánto del segundo).
461fn mezclar(a: Color, b: Color, f: f32) -> Color {
462 let f = f.clamp(0.0, 1.0);
463 let mut c = a;
464 for i in 0..4 {
465 c.components[i] = a.components[i] + (b.components[i] - a.components[i]) * f;
466 }
467 c
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 fn vertices(p: &BezPath) -> Vec<(f64, f64)> {
475 p.elements()
476 .iter()
477 .filter_map(|e| match e {
478 llimphi_compositor::peniko::kurbo::PathEl::MoveTo(pt)
479 | llimphi_compositor::peniko::kurbo::PathEl::LineTo(pt) => Some((pt.x, pt.y)),
480 _ => None,
481 })
482 .collect()
483 }
484
485 /// El trapecio abre sus cantos hacia el cuerpo, y espejado los abre al revés.
486 /// Sin esto, «la tira al pie» pintaría una pestaña que apunta al vacío.
487 #[test]
488 fn el_prisma_se_espeja() {
489 let rect = PaintRect { x: 0.0, y: 0.0, w: 100.0, h: 22.0 };
490 let normal = vertices(&sendero_prismatico(rect, false));
491 let espejada = vertices(&sendero_prismatico(rect, true));
492 // Normal: arranca en el canto de ABAJO (y = h) y el canto angosto va arriba.
493 assert_eq!(normal[0].1, 22.0);
494 assert!(normal[1].1 < 11.0, "el canto angosto va arriba: {:?}", normal[1]);
495 // Espejada: exactamente al revés.
496 assert_eq!(espejada[0].1, 0.0);
497 assert!(espejada[1].1 > 11.0, "el canto angosto va abajo: {:?}", espejada[1]);
498 }
499
500 /// Con la pestaña angosta el sesgo se achica: dos diagonales que se cruzan no
501 /// son un trapecio.
502 #[test]
503 fn el_sesgo_no_cruza_en_una_pestana_angosta() {
504 let rect = PaintRect { x: 0.0, y: 0.0, w: 20.0, h: 22.0 };
505 let v = vertices(&sendero_prismatico(rect, false));
506 // El canto angosto (de v[1] a v[2], los dos redondeos aparte) tiene que
507 // seguir siendo un segmento con ancho positivo.
508 assert!(v[2].0 > v[1].0, "el canto angosto se dio vuelta: {v:?}");
509 }
510}