tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Definiciones

⎇ main llimphi-ui/src/fantasma.rs Rust · 285 líneas · 11.8 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//! **El fantasma del arrastre**: lo que se ve viajando pegado al cursor
2//! mientras arrastrás algo.
3//!
4//! Hasta el 2026-09-10 un arrastre con `drag_payload` era **invisible**. La
5//! única señal era que el destino bajo el puntero se iluminaba, con lo cual:
6//!
7//! * no había forma de saber **qué** estabas arrastrando (¿el archivo que
8//!   apuntaste, o los siete que tenías marcados?);
9//! * arrancar un arrastre sin querer —bajar el botón y moverse dos píxeles—
10//!   no se distinguía de no haber arrancado nada;
11//! * y sobre el vacío, donde no hay destino que se ilumine, el gesto no daba
12//!   ninguna respuesta hasta que soltabas.
13//!
14//! El fantasma es deliberadamente pobre: una chapa con el rótulo y, si son
15//! varias cosas, cuántas. Tiene que servir igual para una fila de archivos,
16//! una pestaña y un panel teselado, y se rearma **por cuadro** mientras dura
17//! el gesto.
18//!
19//! **Los colores son fijos y no salen del theme.** El runtime no conoce el
20//! theme de la app —lo elige cada app y ni siquiera vive en este crate—, y el
21//! fantasma se pinta *encima de todo*, incluido el contenido de otra app en
22//! una captura: una chapa oscura translúcida con texto claro se lee sobre
23//! cualquier fondo, que es exactamente lo que tiene que hacer un objeto que
24//! flota sobre un lugar que no controla.
25
26use llimphi_compositor::llimphi_layout::taffy::{
27    prelude::{length, percent, FlexDirection, Position, Size, Style},
28    AlignItems, JustifyContent, Rect,
29};
30use llimphi_compositor::peniko::Color;
31use llimphi_compositor::{CargaArrastre, View};
32
33/// Alto de la chapa. Una línea de 12 px con aire arriba y abajo.
34pub const ALTO: f32 = 26.0;
35
36/// Separación entre la punta del cursor y la esquina de la chapa. El fantasma
37/// va **abajo-derecha** por lo mismo que cualquier cursor con adorno: la punta
38/// tiene que quedar libre para ver qué estás apuntando.
39pub const SEPARACION: f32 = 14.0;
40
41/// Ancho máximo. Un nombre de archivo largo se recorta: el fantasma dice *qué*
42/// llevás, no es el lugar donde leerlo entero.
43pub const ANCHO_MAX: f32 = 260.0;
44
45/// Ancho mínimo, para que una carga de rótulo corto no salga como una pastilla.
46pub const ANCHO_MIN: f32 = 64.0;
47
48const TEXTO_PX: f32 = 12.0;
49const PAD_X: f32 = 10.0;
50
51/// **Dónde va la chapa**, dado el cursor y el ancho que pide su contenido.
52///
53/// Función pura para poder exigirle por test las dos cosas que importan y que
54/// no se ven en un pantallazo: que no se salga de la ventana (arrastrando
55/// contra el borde derecho o el de abajo, que es donde uno arrastra para
56/// soltar en otra cosa), y que no tape la punta del cursor.
57pub fn caja(
58    cursor: (f32, f32),
59    ancho_contenido: f32,
60    ventana: (f32, f32),
61) -> (f32, f32, f32, f32) {
62    let w = (ancho_contenido + PAD_X * 2.0).clamp(ANCHO_MIN, ANCHO_MAX);
63    let (vw, vh) = ventana;
64    // Abajo-derecha del cursor; si no entra, se voltea al otro lado en vez de
65    // quedar recortada contra el borde.
66    let x = if cursor.0 + SEPARACION + w <= vw {
67        cursor.0 + SEPARACION
68    } else {
69        (cursor.0 - SEPARACION - w).max(0.0)
70    };
71    let y = if cursor.1 + SEPARACION + ALTO <= vh {
72        cursor.1 + SEPARACION
73    } else {
74        (cursor.1 - SEPARACION - ALTO).max(0.0)
75    };
76    (x, y, w, ALTO)
77}
78
79/// El ancho que pide el contenido: el rótulo, más la chapita del número si son
80/// varias cosas.
81pub fn ancho_contenido(carga: &CargaArrastre) -> f32 {
82    let t = llimphi_compositor::llimphi_text::text_width(&carga.rotulo, TEXTO_PX);
83    let n = if carga.cuantos > 1 {
84        llimphi_compositor::llimphi_text::text_width(&carga.cuantos.to_string(), 11.0) + 18.0
85    } else {
86        0.0
87    };
88    t + n
89}
90
91/// La vista de la chapa, ya posicionada en coordenadas de ventana.
92///
93/// Se compone como un `View` y se pinta con el `paint` de siempre en vez de
94/// dibujar a mano sobre la escena: así el texto pasa por el mismo medidor y el
95/// mismo tipógrafo que el resto de la UI —un fantasma con otra caja de texto
96/// sería otro renderer— y la caja se puede montar en un test sin GPU.
97pub fn vista<Msg: Clone + 'static>(
98    carga: &CargaArrastre,
99    caja: (f32, f32, f32, f32),
100) -> View<Msg> {
101    let (x, y, w, h) = caja;
102    let fondo = Color::from_rgba8(24, 24, 28, 236);
103    let borde = Color::from_rgba8(255, 255, 255, 46);
104    let texto = Color::from_rgba8(238, 238, 242, 255);
105
106    let mut hijos: Vec<View<Msg>> = Vec::new();
107    hijos.push(
108        View::new(Style {
109            flex_grow: 1.0,
110            min_size: Size { width: length(0.0_f32), height: length(0.0_f32) },
111            size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
112            align_items: Some(AlignItems::CENTER),
113            ..Default::default()
114        })
115        .text(carga.rotulo.clone(), TEXTO_PX, texto)
116        .ellipsis(1),
117    );
118    if carga.cuantos > 1 {
119        // La chapita del número. Es lo que contesta «¿esto se lleva el archivo
120        // que apunté o los siete que tenía marcados?», que es la pregunta que
121        // hace que uno suelte con confianza o no suelte.
122        hijos.push(
123            View::new(Style {
124                size: Size { width: length(0.0_f32), height: length(18.0_f32) },
125                min_size: Size { width: length(18.0_f32), height: length(18.0_f32) },
126                padding: Rect {
127                    left: length(5.0_f32),
128                    right: length(5.0_f32),
129                    top: length(0.0_f32),
130                    bottom: length(0.0_f32),
131                },
132                align_items: Some(AlignItems::CENTER),
133                justify_content: Some(JustifyContent::CENTER),
134                flex_shrink: 0.0,
135                ..Default::default()
136            })
137            .fill(Color::from_rgba8(86, 148, 246, 255))
138            .radius(9.0)
139            .text(carga.cuantos.to_string(), 11.0, Color::from_rgba8(255, 255, 255, 255)),
140        );
141    }
142
143    // **Va envuelto en un contenedor del tamaño de la ventana**, y no suelto.
144    // Un nodo `position: absolute` en la RAÍZ de su árbol no tiene bloque
145    // contenedor contra el cual posicionarse: el motor lo dispone como si
146    // fuera el flujo normal y los `inset` no hacen nada. Medido en metal el
147    // 2026-09-11: el fantasma no aparecía por ningún lado, y no era que no se
148    // pintara — se pintaba en (0,0) con el tamaño del viewport.
149    let chapa = View::new(Style {
150        position: Position::Absolute,
151        inset: Rect {
152            left: length(x),
153            top: length(y),
154            right: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
155            bottom: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
156        },
157        size: Size { width: length(w), height: length(h) },
158        flex_direction: FlexDirection::Row,
159        align_items: Some(AlignItems::CENTER),
160        gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
161        padding: Rect {
162            left: length(PAD_X),
163            right: length(PAD_X),
164            top: length(0.0_f32),
165            bottom: length(0.0_f32),
166        },
167        ..Default::default()
168    })
169    .fill(fondo)
170    .radius(7.0)
171    .border(1.0, borde)
172    .children(hijos);
173
174    View::new(Style {
175        position: Position::Relative,
176        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
177        ..Default::default()
178    })
179    .children(vec![chapa])
180}
181
182#[cfg(test)]
183mod pruebas {
184    use super::*;
185
186    /// **La chapa queda DONDE dice `caja`, una vez montada.**
187    ///
188    /// El guardián del bug del 2026-09-11: la chapa era la raíz de su propio
189    /// árbol con `position: absolute`, y un absoluto en la raíz no tiene
190    /// bloque contenedor contra el cual posicionarse — el motor lo disponía
191    /// como flujo normal, o sea en (0,0) y del tamaño del viewport. En metal
192    /// eso se ve como «el fantasma no aparece», que es indistinguible de «no
193    /// se pinta»: dos horas de buscar en el lado equivocado.
194    ///
195    /// Se mide sobre el layout REAL, sin GPU.
196    #[test]
197    fn la_chapa_aterriza_donde_dice_la_caja() {
198        use llimphi_compositor::llimphi_layout::{taffy, LayoutTree};
199        use llimphi_compositor::llimphi_text::Typesetter;
200        use llimphi_compositor::{measure_text_node, mount, Mounted};
201
202        let carga = CargaArrastre::varias("informe.pdf", 3);
203        let ventana = (1200.0_f32, 800.0);
204        let esperado = caja((400.0, 300.0), ancho_contenido(&carga), ventana);
205        let v = vista::<i32>(&carga, esperado);
206
207        let mut ts = Typesetter::new();
208        let mut layout = LayoutTree::new();
209        let mounted: Mounted<i32> = mount(&mut layout, v);
210        let computed = {
211            let tmap = &mounted.text_measures;
212            layout
213                .compute_with_measure(mounted.root, ventana, |nid, known, avail| {
214                    match tmap.get(&nid) {
215                        Some(tm) => measure_text_node(&mut ts, tm, known, avail),
216                        None => taffy::Size::ZERO,
217                    }
218                })
219                .expect("layout")
220        };
221        // La chapa es el nodo del tamaño pedido; el contenedor de afuera mide
222        // la ventana entera.
223        let chapa = mounted
224            .nodes
225            .iter()
226            .filter_map(|n| computed.get(n.id))
227            .find(|r| (r.w - esperado.2).abs() < 1.0 && (r.h - esperado.3).abs() < 1.0)
228            .expect("la chapa tiene que estar montada con el tamaño pedido");
229        assert!(
230            (chapa.x - esperado.0).abs() < 1.0 && (chapa.y - esperado.1).abs() < 1.0,
231            "la chapa quedó en ({}, {}) y la caja decía ({}, {})",
232            chapa.x, chapa.y, esperado.0, esperado.1,
233        );
234        // Y no en el origen, que es exactamente cómo se veía el bug.
235        assert!(chapa.x > 1.0 && chapa.y > 1.0);
236    }
237
238    #[test]
239    fn el_fantasma_no_tapa_la_punta_del_cursor() {
240        let (x, y, _, _) = caja((100.0, 100.0), 80.0, (1000.0, 800.0));
241        assert!(x > 100.0 && y > 100.0, "va abajo-derecha, no encima del cursor");
242        assert!(x - 100.0 >= SEPARACION - 0.01);
243    }
244
245    #[test]
246    fn contra_el_borde_se_voltea_en_vez_de_recortarse() {
247        // Arrastrar hacia el borde derecho/inferior es exactamente lo que hace
248        // el que va a soltar en otra ventana: si ahí la chapa se sale, el
249        // fantasma desaparece justo cuando más falta hace.
250        let (vw, vh) = (1000.0_f32, 800.0_f32);
251        let (x, y, w, h) = caja((vw - 4.0, vh - 4.0), 200.0, (vw, vh));
252        assert!(x >= 0.0 && x + w <= vw, "se salió por la derecha: {x}+{w}");
253        assert!(y >= 0.0 && y + h <= vh, "se salió por abajo: {y}+{h}");
254        assert!(x < vw - 4.0, "se volteó a la izquierda del cursor");
255    }
256
257    #[test]
258    fn una_ventana_mas_chica_que_la_chapa_no_da_coordenadas_negativas() {
259        // Caso degenerado (una ventana diminuta, o el cursor fuera): lo que no
260        // puede pasar es pintar en negativo, que es una chapa que no se ve.
261        let (x, y, _, _) = caja((10.0, 10.0), 400.0, (40.0, 30.0));
262        assert!(x >= 0.0 && y >= 0.0, "({x}, {y})");
263    }
264
265    #[test]
266    fn el_ancho_sigue_al_rotulo_pero_con_techo_y_piso() {
267        let corto = caja((0.0, 0.0), 10.0, (2000.0, 2000.0)).2;
268        let largo = caja((0.0, 0.0), 5000.0, (2000.0, 2000.0)).2;
269        assert_eq!(corto, ANCHO_MIN);
270        assert_eq!(largo, ANCHO_MAX);
271        let medio = caja((0.0, 0.0), 120.0, (2000.0, 2000.0)).2;
272        assert!(medio > corto && medio < largo);
273    }
274
275    #[test]
276    fn el_numero_ensancha_la_chapa() {
277        // Sin la chapita, «7 archivos» y «archivo» ocuparían lo mismo y el
278        // número se comería el rótulo.
279        let uno = ancho_contenido(&CargaArrastre::una("informe.pdf"));
280        let varios = ancho_contenido(&CargaArrastre::varias("informe.pdf", 7));
281        assert!(varios > uno + 10.0, "uno={uno} varios={varios}");
282        // Y `cuantos: 1` no pinta número: es la misma carga que `una`.
283        assert_eq!(ancho_contenido(&CargaArrastre::varias("informe.pdf", 1)), uno);
284    }
285}