gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main widgets/tabs/src/cajon.rs Rust · 510 líneas · 20.2 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 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}