tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! `boxdraw` — los **cuadros de dibujo** del terminal, trazados como
2//! **vectores** en vez de glifos de fuente.
3//!
4//! Por qué existe: los caracteres de `U+2500..U+259F` (marcos, cruces,
5//! bloques) son el esqueleto de todo TUI (zellij, htop, tablas de `psql`,
6//! los marcos de claude). Pintarlos como TEXTO tiene tres problemas que se
7//! ven a simple vista:
8//!
9//! 1. **Tofu.** Liberation Mono —la mono embebida— no trae los redondeados
10//! `╭╮╰╯` (los que usa zellij) ni los bloques de cuadrante. Caen al
11//! fallback DejaVu **Sans**, que es proporcional.
12//! 2. **Descuadre.** Ese fallback avanza distinto que la mono: cada glifo
13//! caído desfasa el resto de la fila, y el error se ACUMULA hacia la
14//! derecha.
15//! 3. **Juntas rotas.** Aun con la fuente correcta, dos celdas contiguas
16//! dejan una costura de sub-píxel entre `─` y `─`: la línea se ve
17//! punteada al redondear.
18//!
19//! Trazándolos nosotros los tres desaparecen: el trazo se dibuja SOBRE la
20//! geometría exacta de la celda, así que las juntas cierran por
21//! construcción y no hay fuente que consultar.
22//!
23//! El módulo es **puro** (sin vello, sin GPU): devuelve [`Trazo`]s en
24//! píxeles relativos al origen de la celda. Quien pinta los convierte a su
25//! backend — hoy el grid de shuma con `vello`.
26//!
27//! Cobertura: `U+2500..U+257F` (líneas ligeras/gruesas/dobles/punteadas,
28//! esquinas redondeadas, diagonales) y `U+2580..U+259F` (bloques, medios
29//! bloques, octavos, cuadrantes y sombras).
30//!
31//! Limitación conocida: las líneas **dobles** se trazan como dos rieles
32//! paralelos con corte por lado (ver `riel_corta_cerca`); las juntas mixtas
33//! doble↔gruesa quedan aproximadas. Las TUIs modernas usan ligera y
34//! redondeada, que sí son exactas.
35
36/// Un trazo elemental de un cuadro, en **píxeles relativos al origen de la
37/// celda** (esquina superior izquierda = `(0, 0)`).
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum Trazo {
40 /// Segmento recto de `(x0, y0)` a `(x1, y1)`, con `grosor` en px.
41 Linea {
42 x0: f64,
43 y0: f64,
44 x1: f64,
45 y1: f64,
46 grosor: f64,
47 },
48 /// Arco de circunferencia: centro `(cx, cy)`, radio `r`, de `a0` a `a1`
49 /// en radianes (sentido horario, 0 = eje +X, π/2 = eje +Y hacia abajo).
50 Arco {
51 cx: f64,
52 cy: f64,
53 r: f64,
54 a0: f64,
55 a1: f64,
56 grosor: f64,
57 },
58 /// Rectángulo RELLENO con opacidad `alfa` (bloques y sombras).
59 Bloque {
60 x0: f64,
61 y0: f64,
62 x1: f64,
63 y1: f64,
64 alfa: f32,
65 },
66}
67
68/// Peso de un brazo del cuadro.
69const NADA: u8 = 0;
70const LIG: u8 = 1;
71const GRU: u8 = 2;
72const DOB: u8 = 3;
73
74/// Brazos `[arriba, derecha, abajo, izquierda]` de cada punto de código del
75/// bloque `U+2500..=U+257F`. Los que no son brazos (arcos `256D..2570`,
76/// diagonales `2571..2573`) van en cero y los resuelve [`trazos`] aparte.
77#[rustfmt::skip]
78const BRAZOS: [[u8; 4]; 0x80] = [
79 // 2500 ─ 2501 ━ 2502 │ 2503 ┃
80 [0,LIG,0,LIG], [0,GRU,0,GRU], [LIG,0,LIG,0], [GRU,0,GRU,0],
81 // 2504 ┄ 2505 ┅ 2506 ┆ 2507 ┇ (punteados: mismo esqueleto)
82 [0,LIG,0,LIG], [0,GRU,0,GRU], [LIG,0,LIG,0], [GRU,0,GRU,0],
83 // 2508 ┈ 2509 ┉ 250A ┊ 250B ┋
84 [0,LIG,0,LIG], [0,GRU,0,GRU], [LIG,0,LIG,0], [GRU,0,GRU,0],
85 // 250C ┌ 250D ┍ 250E ┎ 250F ┏
86 [0,LIG,LIG,0], [0,GRU,LIG,0], [0,LIG,GRU,0], [0,GRU,GRU,0],
87 // 2510 ┐ 2511 ┑ 2512 ┒ 2513 ┓
88 [0,0,LIG,LIG], [0,0,LIG,GRU], [0,0,GRU,LIG], [0,0,GRU,GRU],
89 // 2514 └ 2515 ┕ 2516 ┖ 2517 ┗
90 [LIG,LIG,0,0], [LIG,GRU,0,0], [GRU,LIG,0,0], [GRU,GRU,0,0],
91 // 2518 ┘ 2519 ┙ 251A ┚ 251B ┛
92 [LIG,0,0,LIG], [LIG,0,0,GRU], [GRU,0,0,LIG], [GRU,0,0,GRU],
93 // 251C ├ 251D ┝ 251E ┞ 251F ┟
94 [LIG,LIG,LIG,0], [LIG,GRU,LIG,0], [GRU,LIG,LIG,0], [LIG,LIG,GRU,0],
95 // 2520 ┠ 2521 ┡ 2522 ┢ 2523 ┣
96 [GRU,LIG,GRU,0], [GRU,GRU,LIG,0], [LIG,GRU,GRU,0], [GRU,GRU,GRU,0],
97 // 2524 ┤ 2525 ┥ 2526 ┦ 2527 ┧
98 [LIG,0,LIG,LIG], [LIG,0,LIG,GRU], [GRU,0,LIG,LIG], [LIG,0,GRU,LIG],
99 // 2528 ┨ 2529 ┩ 252A ┪ 252B ┫
100 [GRU,0,GRU,LIG], [GRU,0,LIG,GRU], [LIG,0,GRU,GRU], [GRU,0,GRU,GRU],
101 // 252C ┬ 252D ┭ 252E ┮ 252F ┯
102 [0,LIG,LIG,LIG], [0,LIG,LIG,GRU], [0,GRU,LIG,LIG], [0,GRU,LIG,GRU],
103 // 2530 ┰ 2531 ┱ 2532 ┲ 2533 ┳
104 [0,LIG,GRU,LIG], [0,LIG,GRU,GRU], [0,GRU,GRU,LIG], [0,GRU,GRU,GRU],
105 // 2534 ┴ 2535 ┵ 2536 ┶ 2537 ┷
106 [LIG,LIG,0,LIG], [LIG,LIG,0,GRU], [LIG,GRU,0,LIG], [LIG,GRU,0,GRU],
107 // 2538 ┸ 2539 ┹ 253A ┺ 253B ┻
108 [GRU,LIG,0,LIG], [GRU,LIG,0,GRU], [GRU,GRU,0,LIG], [GRU,GRU,0,GRU],
109 // 253C ┼ 253D ┽ 253E ┾ 253F ┿
110 [LIG,LIG,LIG,LIG], [LIG,LIG,LIG,GRU], [LIG,GRU,LIG,LIG], [LIG,GRU,LIG,GRU],
111 // 2540 ╀ 2541 ╁ 2542 ╂ 2543 ╃
112 [GRU,LIG,LIG,LIG], [LIG,LIG,GRU,LIG], [GRU,LIG,GRU,LIG], [GRU,LIG,LIG,GRU],
113 // 2544 ╄ 2545 ╅ 2546 ╆ 2547 ╇
114 [GRU,GRU,LIG,LIG], [LIG,LIG,GRU,GRU], [LIG,GRU,GRU,LIG], [GRU,GRU,LIG,GRU],
115 // 2548 ╈ 2549 ╉ 254A ╊ 254B ╋
116 [LIG,GRU,GRU,GRU], [GRU,LIG,GRU,GRU], [GRU,GRU,GRU,LIG], [GRU,GRU,GRU,GRU],
117 // 254C ╌ 254D ╍ 254E ╎ 254F ╏
118 [0,LIG,0,LIG], [0,GRU,0,GRU], [LIG,0,LIG,0], [GRU,0,GRU,0],
119 // 2550 ═ 2551 ║ 2552 ╒ 2553 ╓
120 [0,DOB,0,DOB], [DOB,0,DOB,0], [0,DOB,LIG,0], [0,LIG,DOB,0],
121 // 2554 ╔ 2555 ╕ 2556 ╖ 2557 ╗
122 [0,DOB,DOB,0], [0,0,LIG,DOB], [0,0,DOB,LIG], [0,0,DOB,DOB],
123 // 2558 ╘ 2559 ╙ 255A ╚ 255B ╛
124 [LIG,DOB,0,0], [DOB,LIG,0,0], [DOB,DOB,0,0], [LIG,0,0,DOB],
125 // 255C ╜ 255D ╝ 255E ╞ 255F ╟
126 [DOB,0,0,LIG], [DOB,0,0,DOB], [LIG,DOB,LIG,0], [DOB,LIG,DOB,0],
127 // 2560 ╠ 2561 ╡ 2562 ╢ 2563 ╣
128 [DOB,DOB,DOB,0], [LIG,0,LIG,DOB], [DOB,0,DOB,LIG], [DOB,0,DOB,DOB],
129 // 2564 ╤ 2565 ╥ 2566 ╦ 2567 ╧
130 [0,DOB,LIG,DOB], [0,LIG,DOB,LIG], [0,DOB,DOB,DOB], [LIG,DOB,0,DOB],
131 // 2568 ╨ 2569 ╩ 256A ╪ 256B ╫
132 [DOB,LIG,0,LIG], [DOB,DOB,0,DOB], [LIG,DOB,LIG,DOB], [DOB,LIG,DOB,LIG],
133 // 256C ╬ 256D ╭ 256E ╮ 256F ╯ (los arcos: aparte)
134 [DOB,DOB,DOB,DOB], [0,0,0,0], [0,0,0,0], [0,0,0,0],
135 // 2570 ╰ 2571 ╱ 2572 ╲ 2573 ╳ (arco + diagonales: aparte)
136 [0,0,0,0], [0,0,0,0], [0,0,0,0], [0,0,0,0],
137 // 2574 ╴ 2575 ╵ 2576 ╶ 2577 ╷
138 [0,0,0,LIG], [LIG,0,0,0], [0,LIG,0,0], [0,0,LIG,0],
139 // 2578 ╸ 2579 ╹ 257A ╺ 257B ╻
140 [0,0,0,GRU], [GRU,0,0,0], [0,GRU,0,0], [0,0,GRU,0],
141 // 257C ╼ 257D ╽ 257E ╾ 257F ╿
142 [0,GRU,0,LIG], [LIG,0,GRU,0], [0,LIG,0,GRU], [GRU,0,LIG,0],
143];
144
145/// Cantidad de guiones de los punteados (`0` = línea continua), indexada
146/// igual que [`BRAZOS`].
147fn guiones(cp: u32) -> u8 {
148 match cp {
149 0x2504..=0x2507 => 3,
150 0x2508..=0x250B => 4,
151 0x254C..=0x254F => 2,
152 _ => 0,
153 }
154}
155
156/// `true` si este carácter lo traza el módulo (y por lo tanto NO debe ir al
157/// shaper de texto). Es la pregunta que hace el pintor por celda.
158pub fn cubre(ch: char) -> bool {
159 matches!(ch as u32, 0x2500..=0x259F)
160}
161
162/// Grosor de la línea ligera para una celda de `w` px de ancho. Nunca menos
163/// de 1 px: por debajo de eso el trazo se desvanece al rasterizar.
164fn grosor_ligero(w: f64) -> f64 {
165 (w * 0.11).max(1.0)
166}
167
168/// Grosor de la línea gruesa (`━┃`): el doble de la ligera, con piso de 2 px
169/// para que se distinga de ella incluso a fuente chica.
170fn grosor_grueso(w: f64) -> f64 {
171 (w * 0.22).max(2.0)
172}
173
174/// Separación entre los dos rieles de una línea doble.
175fn sep_doble(w: f64) -> f64 {
176 (w * 0.16).max(1.5)
177}
178
179/// Los trazos vectoriales de `ch` en una celda de `w`×`h` px. `None` si el
180/// carácter no es un cuadro de dibujo (lo pinta el texto).
181pub fn trazos(ch: char, w: f64, h: f64) -> Option<Vec<Trazo>> {
182 let cp = ch as u32;
183 if !(0x2500..=0x259F).contains(&cp) {
184 return None;
185 }
186 if (0x2580..=0x259F).contains(&cp) {
187 return Some(bloques(cp, w, h));
188 }
189 let idx = (cp - 0x2500) as usize;
190 // Arcos y diagonales: no salen de la tabla de brazos.
191 match cp {
192 0x256D..=0x2570 => return Some(arco(cp, w, h)),
193 0x2571..=0x2573 => return Some(diagonales(cp, w, h)),
194 _ => {}
195 }
196 let brazos = BRAZOS[idx];
197 let dash = guiones(cp);
198 Some(lineas(brazos, dash, w, h))
199}
200
201/// Índices de brazo en el array `[arriba, derecha, abajo, izquierda]`.
202const ARR: usize = 0;
203const DER: usize = 1;
204const ABA: usize = 2;
205const IZQ: usize = 3;
206
207/// Traza los brazos rectos (ligeros, gruesos y dobles) de una celda.
208fn lineas(brazos: [u8; 4], dash: u8, w: f64, h: f64) -> Vec<Trazo> {
209 let (cx, cy) = (w / 2.0, h / 2.0);
210 let d = sep_doble(w);
211 let hay_doble = brazos.iter().any(|b| *b == DOB);
212 let mut out = Vec::with_capacity(4);
213 for (i, peso) in brazos.iter().copied().enumerate() {
214 if peso == NADA {
215 continue;
216 }
217 let vertical = i == ARR || i == ABA;
218 // Sentido: -1 = hacia el borde chico (arriba/izquierda), +1 = hacia
219 // el borde grande (abajo/derecha).
220 let sentido: f64 = if i == ARR || i == IZQ { -1.0 } else { 1.0 };
221 if peso == DOB {
222 // Dos rieles paralelos. Cada uno corta CERCA del centro si el
223 // brazo perpendicular de su lado existe (deja la junta abierta,
224 // como el glifo real), y LEJOS si no (dobla la esquina).
225 for s in [-1.0_f64, 1.0] {
226 let perp = if vertical {
227 if s < 0.0 { brazos[IZQ] } else { brazos[DER] }
228 } else if s < 0.0 {
229 brazos[ARR]
230 } else {
231 brazos[ABA]
232 };
233 let corte = if perp != NADA { sentido * d } else { -sentido * d };
234 let g = grosor_ligero(w);
235 if vertical {
236 let x = cx + s * d;
237 let (ya, yb) = if sentido < 0.0 {
238 (0.0, cy + corte)
239 } else {
240 (cy + corte, h)
241 };
242 out.push(Trazo::Linea { x0: x, y0: ya, x1: x, y1: yb, grosor: g });
243 } else {
244 let y = cy + s * d;
245 let (xa, xb) = if sentido < 0.0 {
246 (0.0, cx + corte)
247 } else {
248 (cx + corte, w)
249 };
250 out.push(Trazo::Linea { x0: xa, y0: y, x1: xb, y1: y, grosor: g });
251 }
252 }
253 continue;
254 }
255 let g = if peso == GRU { grosor_grueso(w) } else { grosor_ligero(w) };
256 // El brazo se pasa MEDIO GROSOR del centro para que la junta cierre
257 // sin muesca; si hay dobles alrededor, llega hasta el riel lejano.
258 let sobra = if hay_doble { d } else { g / 2.0 };
259 if vertical {
260 let (ya, yb) = if sentido < 0.0 {
261 (0.0, cy + sobra)
262 } else {
263 (cy - sobra, h)
264 };
265 empujar(&mut out, cx, ya, cx, yb, g, dash);
266 } else {
267 let (xa, xb) = if sentido < 0.0 {
268 (0.0, cx + sobra)
269 } else {
270 (cx - sobra, w)
271 };
272 empujar(&mut out, xa, cy, xb, cy, g, dash);
273 }
274 }
275 out
276}
277
278/// Empuja un segmento — entero si `dash == 0`, o partido en `dash` guiones.
279fn empujar(out: &mut Vec<Trazo>, x0: f64, y0: f64, x1: f64, y1: f64, grosor: f64, dash: u8) {
280 if dash == 0 {
281 out.push(Trazo::Linea { x0, y0, x1, y1, grosor });
282 return;
283 }
284 // `n` guiones con `n-1` huecos; el hueco vale la mitad del guión.
285 let n = dash as f64;
286 let (dx, dy) = (x1 - x0, y1 - y0);
287 let paso = 1.0 / (n + (n - 1.0) * 0.5);
288 let hueco = paso * 0.5;
289 let mut t = 0.0;
290 for _ in 0..dash {
291 let t1 = (t + paso).min(1.0);
292 out.push(Trazo::Linea {
293 x0: x0 + dx * t,
294 y0: y0 + dy * t,
295 x1: x0 + dx * t1,
296 y1: y0 + dy * t1,
297 grosor,
298 });
299 t = t1 + hueco;
300 if t >= 1.0 {
301 break;
302 }
303 }
304}
305
306/// Esquinas REDONDEADAS `╭╮╯╰` — las que usa zellij para sus marcos.
307fn arco(cp: u32, w: f64, h: f64) -> Vec<Trazo> {
308 let (cx, cy) = (w / 2.0, h / 2.0);
309 let g = grosor_ligero(w);
310 // Radio del redondeo. Menor que media celda a propósito: si llegara a
311 // `w/2` el arco se comería el tramo recto horizontal y la esquina no
312 // engancharía con la celda vecina.
313 let r = (w * 0.35).min(h * 0.35);
314 const PI: f64 = core::f64::consts::PI;
315 // (brazo vertical, brazo horizontal, centro del arco, ángulos)
316 let (v_abajo, h_der) = match cp {
317 0x256D => (true, true), // ╭ abajo + derecha
318 0x256E => (true, false), // ╮ abajo + izquierda
319 0x256F => (false, false), // ╯ arriba + izquierda
320 _ => (false, true), // ╰ arriba + derecha
321 };
322 let ox = if h_der { cx + r } else { cx - r };
323 let oy = if v_abajo { cy + r } else { cy - r };
324 let (a0, a1) = match (v_abajo, h_der) {
325 (true, true) => (PI, PI * 1.5), // ╭
326 (true, false) => (PI * 1.5, PI * 2.0), // ╮
327 (false, false) => (0.0, PI * 0.5), // ╯
328 (false, true) => (PI * 0.5, PI), // ╰
329 };
330 let mut out = Vec::with_capacity(3);
331 // Tramo recto vertical: del borde hasta donde arranca el arco. Con la
332 // celda muy angosta el radio se come el tramo entero — ahí no hay recta
333 // que trazar (un segmento de largo cero no pinta nada).
334 if v_abajo {
335 empujar_si_mide(&mut out, cx, cy + r, cx, h, g);
336 } else {
337 empujar_si_mide(&mut out, cx, 0.0, cx, cy - r, g);
338 }
339 // Tramo recto horizontal.
340 if h_der {
341 empujar_si_mide(&mut out, cx + r, cy, w, cy, g);
342 } else {
343 empujar_si_mide(&mut out, 0.0, cy, cx - r, cy, g);
344 }
345 out.push(Trazo::Arco { cx: ox, cy: oy, r, a0, a1, grosor: g });
346 out
347}
348
349/// Empuja el segmento sólo si mide algo (evita rectas de largo cero).
350fn empujar_si_mide(out: &mut Vec<Trazo>, x0: f64, y0: f64, x1: f64, y1: f64, grosor: f64) {
351 if (x1 - x0).abs() > 0.01 || (y1 - y0).abs() > 0.01 {
352 out.push(Trazo::Linea { x0, y0, x1, y1, grosor });
353 }
354}
355
356/// Diagonales `╱╲╳`.
357fn diagonales(cp: u32, w: f64, h: f64) -> Vec<Trazo> {
358 let g = grosor_ligero(w);
359 let mut out = Vec::with_capacity(2);
360 if cp == 0x2571 || cp == 0x2573 {
361 out.push(Trazo::Linea { x0: 0.0, y0: h, x1: w, y1: 0.0, grosor: g });
362 }
363 if cp == 0x2572 || cp == 0x2573 {
364 out.push(Trazo::Linea { x0: 0.0, y0: 0.0, x1: w, y1: h, grosor: g });
365 }
366 out
367}
368
369/// Bloques, octavos, cuadrantes y sombras (`U+2580..=U+259F`).
370fn bloques(cp: u32, w: f64, h: f64) -> Vec<Trazo> {
371 let lleno = |x0: f64, y0: f64, x1: f64, y1: f64| Trazo::Bloque { x0, y0, x1, y1, alfa: 1.0 };
372 let oct = |n: f64| n / 8.0;
373 match cp {
374 0x2580 => vec![lleno(0.0, 0.0, w, h / 2.0)], // ▀
375 // 2581..2587: octavos DESDE ABAJO (▁ 1/8 … ▇ 7/8).
376 0x2581..=0x2587 => {
377 let f = oct((cp - 0x2580) as f64);
378 vec![lleno(0.0, h * (1.0 - f), w, h)]
379 }
380 0x2588 => vec![lleno(0.0, 0.0, w, h)], // █
381 // 2589..258F: octavos DESDE LA IZQUIERDA (▉ 7/8 … ▏ 1/8).
382 0x2589..=0x258F => {
383 let f = oct((8 - (cp - 0x2588)) as f64);
384 vec![lleno(0.0, 0.0, w * f, h)]
385 }
386 0x2590 => vec![lleno(w / 2.0, 0.0, w, h)], // ▐
387 // Sombras ░▒▓: el mismo bloque lleno con opacidad.
388 0x2591 => vec![Trazo::Bloque { x0: 0.0, y0: 0.0, x1: w, y1: h, alfa: 0.25 }],
389 0x2592 => vec![Trazo::Bloque { x0: 0.0, y0: 0.0, x1: w, y1: h, alfa: 0.5 }],
390 0x2593 => vec![Trazo::Bloque { x0: 0.0, y0: 0.0, x1: w, y1: h, alfa: 0.75 }],
391 0x2594 => vec![lleno(0.0, 0.0, w, h / 8.0)], // ▔
392 0x2595 => vec![lleno(w * 7.0 / 8.0, 0.0, w, h)], // ▕
393 // Cuadrantes 2596..259F.
394 _ => {
395 let (cx, cy) = (w / 2.0, h / 2.0);
396 let si = |b: bool, x0: f64, y0: f64, x1: f64, y1: f64| {
397 b.then(|| lleno(x0, y0, x1, y1))
398 };
399 // bits: [sup-izq, sup-der, inf-izq, inf-der]
400 let q: [bool; 4] = match cp {
401 0x2596 => [false, false, true, false], // ▖
402 0x2597 => [false, false, false, true], // ▗
403 0x2598 => [true, false, false, false], // ▘
404 0x2599 => [true, false, true, true], // ▙
405 0x259A => [true, false, false, true], // ▚
406 0x259B => [true, true, true, false], // ▛
407 0x259C => [true, true, false, true], // ▜
408 0x259D => [false, true, false, false], // ▝
409 0x259E => [false, true, true, false], // ▞
410 0x259F => [false, true, true, true], // ▟
411 _ => [false, false, false, false],
412 };
413 [
414 si(q[0], 0.0, 0.0, cx, cy),
415 si(q[1], cx, 0.0, w, cy),
416 si(q[2], 0.0, cy, cx, h),
417 si(q[3], cx, cy, w, h),
418 ]
419 .into_iter()
420 .flatten()
421 .collect()
422 }
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 const W: f64 = 8.0;
431 const H: f64 = 16.0;
432
433 /// Todo el rango declarado devuelve trazos: si algo cae al texto, vuelve
434 /// el tofu que este módulo existe para matar.
435 #[test]
436 fn cubre_todo_el_rango_sin_huecos() {
437 for cp in 0x2500u32..=0x259F {
438 let ch = char::from_u32(cp).unwrap();
439 assert!(cubre(ch), "{cp:04X} no declarado");
440 let t = trazos(ch, W, H).unwrap_or_else(|| panic!("{cp:04X} sin trazos"));
441 assert!(!t.is_empty(), "{cp:04X} devolvió lista vacía");
442 }
443 }
444
445 /// Fuera del rango no opina — el texto sigue su curso.
446 #[test]
447 fn lo_que_no_es_cuadro_no_se_traza() {
448 for ch in ['a', ' ', '❯', '●', '→', '\u{24FF}', '\u{25A0}'] {
449 assert!(!cubre(ch), "{ch:?} no debería estar cubierto");
450 assert!(trazos(ch, W, H).is_none());
451 }
452 }
453
454 /// La horizontal ligera cruza la celda de borde a borde a media altura —
455 /// es lo que hace que dos celdas contiguas cierren sin costura.
456 #[test]
457 fn la_horizontal_va_de_borde_a_borde() {
458 let t = trazos('─', W, H).unwrap();
459 assert_eq!(t.len(), 2, "dos brazos: izquierda y derecha");
460 let (mut min_x, mut max_x) = (f64::MAX, f64::MIN);
461 for tr in &t {
462 let Trazo::Linea { x0, y0, x1, y1, .. } = *tr else { panic!("no es línea") };
463 assert_eq!(y0, H / 2.0);
464 assert_eq!(y1, H / 2.0);
465 min_x = min_x.min(x0.min(x1));
466 max_x = max_x.max(x0.max(x1));
467 }
468 assert_eq!((min_x, max_x), (0.0, W));
469 }
470
471 /// La gruesa es más gorda que la ligera (si no, `━` y `─` se confunden).
472 #[test]
473 fn la_gruesa_pesa_mas_que_la_ligera() {
474 let g = |ch: char| match trazos(ch, W, H).unwrap()[0] {
475 Trazo::Linea { grosor, .. } => grosor,
476 _ => panic!("no es línea"),
477 };
478 assert!(g('━') > g('─'));
479 assert!(g('┃') > g('│'));
480 }
481
482 /// La esquina `┌` no deja muesca: el brazo derecho arranca ANTES del
483 /// centro y el de abajo termina DESPUÉS, así que la junta se solapa.
484 #[test]
485 fn la_esquina_cierra_sin_muesca() {
486 let t = trazos('┌', W, H).unwrap();
487 let mut der = None;
488 let mut aba = None;
489 for tr in &t {
490 let Trazo::Linea { x0, y0, x1, y1, .. } = *tr else { panic!() };
491 if (y0 - y1).abs() < f64::EPSILON {
492 der = Some((x0, x1));
493 } else {
494 aba = Some((y0, y1));
495 }
496 }
497 let (dx0, dx1) = der.expect("brazo derecho");
498 let (ay0, ay1) = aba.expect("brazo abajo");
499 assert!(dx0 < W / 2.0, "el brazo derecho debe pasar el centro");
500 assert_eq!(dx1, W);
501 assert!(ay0 < H / 2.0, "el brazo de abajo debe pasar el centro");
502 assert_eq!(ay1, H);
503 }
504
505 /// Los redondeados de zellij traen arco de verdad (no una esquina viva),
506 /// más los dos tramos rectos hasta los bordes que toca.
507 #[test]
508 fn los_redondeados_traen_arco() {
509 for ch in ['╭', '╮', '╯', '╰'] {
510 let t = trazos(ch, W, H).unwrap();
511 assert!(
512 t.iter().any(|x| matches!(x, Trazo::Arco { .. })),
513 "{ch} sin arco"
514 );
515 assert_eq!(t.len(), 3, "{ch}: dos rectas + un arco");
516 }
517 }
518
519 /// La doble son DOS rieles por brazo, separados y sin tocarse.
520 #[test]
521 fn la_doble_son_dos_rieles() {
522 let t = trazos('═', W, H).unwrap();
523 assert_eq!(t.len(), 4, "dos brazos × dos rieles");
524 let ys: Vec<f64> = t
525 .iter()
526 .map(|x| match x {
527 Trazo::Linea { y0, .. } => *y0,
528 _ => panic!(),
529 })
530 .collect();
531 let arriba = ys.iter().filter(|y| **y < H / 2.0).count();
532 assert_eq!(arriba, 2, "un riel arriba del centro por brazo");
533 }
534
535 /// La cruz doble `╬` deja las cuatro juntas ABIERTAS: ningún riel llega
536 /// al centro (así se ve el glifo real, con su cuadradito hueco).
537 #[test]
538 fn la_cruz_doble_deja_la_junta_abierta() {
539 let t = trazos('╬', W, H).unwrap();
540 assert_eq!(t.len(), 8, "cuatro brazos × dos rieles");
541 let d = sep_doble(W);
542 for tr in &t {
543 let Trazo::Linea { x0, y0, x1, y1, .. } = *tr else { panic!() };
544 let vertical = (x0 - x1).abs() < f64::EPSILON;
545 let corte = if vertical {
546 if y0 < H / 2.0 { y1 } else { y0 }
547 } else if x0 < W / 2.0 {
548 x1
549 } else {
550 x0
551 };
552 let centro = if vertical { H / 2.0 } else { W / 2.0 };
553 assert!(
554 (corte - centro).abs() >= d - 1e-9,
555 "el riel llegó al centro: {tr:?}"
556 );
557 }
558 }
559
560 /// Los octavos crecen monótonos: `▁` < `▄` < `▇` < `█`.
561 #[test]
562 fn los_octavos_crecen() {
563 let area = |ch: char| -> f64 {
564 trazos(ch, W, H)
565 .unwrap()
566 .iter()
567 .map(|t| match t {
568 Trazo::Bloque { x0, y0, x1, y1, alfa } => {
569 (x1 - x0) * (y1 - y0) * *alfa as f64
570 }
571 _ => 0.0,
572 })
573 .sum()
574 };
575 assert!(area('▁') < area('▄'));
576 assert!(area('▄') < area('▇'));
577 assert!(area('▇') < area('█'));
578 assert_eq!(area('█'), W * H);
579 assert_eq!(area('▀'), W * H / 2.0);
580 assert_eq!(area('▐'), W * H / 2.0);
581 }
582
583 /// Las sombras son el bloque entero con opacidad creciente.
584 #[test]
585 fn las_sombras_van_por_alfa() {
586 let alfa = |ch: char| match trazos(ch, W, H).unwrap()[0] {
587 Trazo::Bloque { alfa, .. } => alfa,
588 _ => panic!(),
589 };
590 assert!(alfa('░') < alfa('▒'));
591 assert!(alfa('▒') < alfa('▓'));
592 assert!(alfa('▓') < 1.0);
593 }
594
595 /// Los cuadrantes cubren exactamente el cuarto que nombran.
596 #[test]
597 fn los_cuadrantes_caen_donde_deben() {
598 let t = trazos('▘', W, H).unwrap(); // superior izquierdo
599 assert_eq!(
600 t[0],
601 Trazo::Bloque { x0: 0.0, y0: 0.0, x1: W / 2.0, y1: H / 2.0, alfa: 1.0 }
602 );
603 assert_eq!(trazos('▟', W, H).unwrap().len(), 3, "▟ son tres cuartos");
604 }
605
606 /// Los punteados salen partidos en la cantidad de guiones que nombran.
607 #[test]
608 fn los_punteados_se_parten() {
609 assert_eq!(trazos('┄', W, H).unwrap().len(), 3 * 2, "3 guiones × 2 brazos");
610 assert_eq!(trazos('┈', W, H).unwrap().len(), 4 * 2);
611 assert_eq!(trazos('╌', W, H).unwrap().len(), 2 * 2);
612 // Y el continuo NO se parte.
613 assert_eq!(trazos('─', W, H).unwrap().len(), 2);
614 }
615
616 /// Ningún trazo se sale de la celda — si se saliera, pisaría al vecino.
617 #[test]
618 fn nada_se_sale_de_la_celda() {
619 for cp in 0x2500u32..=0x259F {
620 let ch = char::from_u32(cp).unwrap();
621 for t in trazos(ch, W, H).unwrap() {
622 let (xs, ys) = match t {
623 Trazo::Linea { x0, y0, x1, y1, .. } => ([x0, x1], [y0, y1]),
624 Trazo::Bloque { x0, y0, x1, y1, .. } => ([x0, x1], [y0, y1]),
625 // Un cuadrante de arco no se sale del rectángulo de sus
626 // extremos (la panza va hacia adentro de ese rectángulo).
627 Trazo::Arco { cx, cy, r, a0, a1, .. } => (
628 [cx + r * a0.cos(), cx + r * a1.cos()],
629 [cy + r * a0.sin(), cy + r * a1.sin()],
630 ),
631 };
632 for x in xs {
633 assert!((-0.01..=W + 0.01).contains(&x), "{cp:04X} se sale en x: {x}");
634 }
635 for y in ys {
636 assert!((-0.01..=H + 0.01).contains(&y), "{cp:04X} se sale en y: {y}");
637 }
638 }
639 }
640 }
641}