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//! **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}