tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ main widgets/terminal/src/boxdraw.rs Rust · 641 siq'ikuna · 25.3 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

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}