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/text-editor/src/geometria.rs Rust · 1483 siq'ikuna · 61.3 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

1//! La geometría del documento: dónde cae cada línea, cuánto mide el viewport y
2//! qué parte del buffer hay que materializar.
3//!
4//! Hasta acá el editor no tenía geometría: el `view` asumía una grilla de
5//! líneas de altura uniforme (`PAD_Y + n * line_height`) y el **viewport era un
6//! entero de líneas que contaba el host** —en `nada`, la constante
7//! `EDITOR_VISIBLE_LINES = 40`, calculada a mano contra una ventana de 760 px—.
8//! De esa única decisión salían seis cosas: el viewport no seguía a la ventana,
9//! el scroll era por línea entera y sin eje horizontal, el render tenía un tope
10//! duro (`.min(200)`) porque cada línea genera varias `View` y wgpu rechaza el
11//! bind group, y **plegado, ajuste de línea, líneas pegajosas e inlay hints
12//! eran imposibles**, no difíciles: los cuatro necesitan que la fila visual no
13//! sea la línea lógica y que una fila pueda medir distinto que otra.
14//!
15//! Este módulo introduce esa indirección. Hoy la [`Disposicion`] es la
16//! **identidad** —una línea es una fila, todas de la misma altura— y nada se ve
17//! distinto por eso; lo que cambia es que el resto del widget ya no habla de
18//! líneas cuando quiere decir filas. Plegado y ajuste de línea son otra
19//! implementación del mismo mapa, sin tocar a ningún llamador.
20//!
21//! ## Por qué el desplazamiento es un ancla y no un píxel absoluto
22//!
23//! Guardar el scroll como "píxel Y del documento" obliga a conocer el alto
24//! total para interpretarlo, y con filas de altura variable ese alto cambia
25//! cada vez que se pliega o se ajusta una línea: el mismo número pasaría a
26//! señalar otro lugar. El [`Desplazamiento`] es en cambio **una fila de ancla
27//! más un resto en píxeles dentro de ella**, que es lo que hace cualquier
28//! editor real: plegar algo más abajo no mueve lo que estás mirando. La
29//! conversión a píxel absoluto existe igual ([`Disposicion::y_de`]) porque la
30//! barra de scroll y el clamp la necesitan.
31//!
32//! Nada de acá usa `f32::floor`/`ceil`: no existen en `core` sin `libm`, y este
33//! widget entra a la jaula WASM y al kernel. Las divisiones se truncan con
34//! `as usize`, que para valores no negativos es lo mismo.
35
36use crate::view::EditorMetrics;
37
38use alloc::borrow::Cow;
39use alloc::string::String;
40use alloc::sync::Arc;
41// El macro `vec!` además del tipo: sin `std` no está en el preludio, y este
42// módulo lo usa diez veces. Faltaba, así que el crate no compilaba sin `std`
43// —ni el `llimphi-test` que lo arrastra por el grafo— y con `std` (el default
44// de las apps) el preludio lo tapaba.
45use alloc::vec;
46use alloc::vec::Vec;
47
48/// Tolerancia al dividir un píxel por el alto de fila. Absorbe el error de
49/// redondeo de `f32` acumulado en `fila * alto_fila` sin llegar a confundir dos
50/// filas vecinas: a 10.000 filas el error relativo de `f32` sigue muy por debajo
51/// de esto, y esto sigue muy por debajo de media fila.
52const EPS_FILA: f32 = 1e-4;
53
54/// Una fila visual: la unidad que el render dibuja y el mouse golpea.
55///
56/// Sin ajuste de línea es una línea lógica entera (`col_inicio == 0`,
57/// `col_fin == None`). Con ajuste, una línea larga produce varias filas y cada
58/// una declara su tramo de columnas: `col_fin` es la columna donde arranca la
59/// fila siguiente, o `None` cuando la fila llega hasta el final de la línea.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct Fila {
62    /// Línea lógica del buffer a la que pertenece esta fila.
63    pub linea: usize,
64    /// Columna (en chars) por la que arranca la fila dentro de su línea.
65    pub col_inicio: usize,
66    /// Columna (exclusiva) donde termina la fila, o `None` si llega al final de
67    /// la línea.
68    pub col_fin: Option<usize>,
69}
70
71impl Fila {
72    /// La fila que cubre una línea lógica entera.
73    pub const fn entera(linea: usize) -> Self {
74        Self { linea, col_inicio: 0, col_fin: None }
75    }
76
77    /// `true` si esta fila arranca la línea — la que lleva el número en el
78    /// gutter. Las de continuación no lo llevan: repetir el mismo número en
79    /// cuatro renglones seguidos es peor que dejarlos en blanco.
80    pub const fn es_primera(&self) -> bool {
81        self.col_inicio == 0
82    }
83
84    /// `true` si esta fila es la última de su línea — la que se lleva el final
85    /// del renglón (y el caret que cae más allá del texto).
86    pub const fn es_ultima(&self) -> bool {
87        self.col_fin.is_none()
88    }
89
90    /// `true` si la columna cae dentro del tramo de esta fila.
91    pub fn contiene_col(&self, col: usize) -> bool {
92        col >= self.col_inicio && self.col_fin.is_none_or(|f| col < f)
93    }
94}
95
96/// La columna con la que se marcan las **filas de relleno** de
97/// [`MapaAjuste::con_piso`]: una que ninguna línea real alcanza, para que
98/// ninguna consulta de posición aterrice en ellas.
99pub(crate) const COL_FANTASMA: u32 = u32::MAX;
100
101/// El mapa de **ajuste de línea** (soft wrap): en cuántas filas se reparte cada
102/// línea lógica y por qué columna arranca cada una.
103///
104/// Se construye una vez y se comparte con [`Arc`]: la [`Disposicion`] se arma
105/// en cada cuadro, y clonar un vector por línea ahí sería justo el costo que
106/// este módulo existe para evitar. Sólo guarda tramos de las líneas que **se
107/// parten**; si ninguna pasa el ancho, [`construir`](Self::construir) devuelve
108/// `None` y el editor sigue por el camino de la identidad sin pagar nada.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct MapaAjuste {
111    ancho_cols: usize,
112    lineas: usize,
113    /// `inicio[l]` = primera fila global de la línea `l`. `len = lineas + 1`,
114    /// así que `inicio[lineas]` es el total de filas.
115    inicio: Vec<u32>,
116    /// `cortes[f]` = columna donde arranca la fila global `f`.
117    cortes: Vec<u32>,
118}
119
120impl MapaAjuste {
121    /// Arma el mapa para un buffer de `lineas` líneas ajustadas a `ancho_cols`.
122    ///
123    /// `largo_de` tiene que ser **barato** (en una soga es `O(log n)`): se lo
124    /// llama una vez por línea y es lo que evita traer el texto del archivo
125    /// entero. `texto_de` sólo se invoca para las líneas que de verdad no
126    /// entran, que en código fuente son pocas.
127    ///
128    /// Devuelve `None` si no se partió ninguna línea: un mapa identidad cuesta
129    /// memoria y búsquedas binarias para no decir nada.
130    pub fn construir(
131        ancho_cols: usize,
132        lineas: usize,
133        largo_de: impl Fn(usize) -> usize,
134        texto_de: impl Fn(usize) -> String,
135    ) -> Option<Self> {
136        if ancho_cols == 0 || lineas == 0 {
137            return None;
138        }
139        let mut inicio: Vec<u32> = Vec::with_capacity(lineas + 1);
140        let mut cortes: Vec<u32> = Vec::with_capacity(lineas + 1);
141        let mut partio = false;
142        for l in 0..lineas {
143            inicio.push(cortes.len() as u32);
144            cortes.push(0);
145            // El `+ 1` de holgura: `largo_de` suele contar el fin de línea, y
146            // una línea de exactamente `ancho_cols` caracteres entra.
147            if largo_de(l) > ancho_cols {
148                for c in cortes_de_linea(&texto_de(l), ancho_cols) {
149                    cortes.push(c as u32);
150                    partio = true;
151                }
152            }
153        }
154        inicio.push(cortes.len() as u32);
155        partio.then_some(Self { ancho_cols, lineas, inicio, cortes })
156    }
157
158    /// **El mapa identidad**: una línea, una fila. Es el punto de partida para
159    /// ponerle pisos a un buffer que no ajusta ninguna línea — sin esto,
160    /// `construir` devuelve `None` (y hace bien: un mapa que no dice nada
161    /// cuesta memoria y binarias) y no habría dónde colgar el relleno.
162    pub fn identidad(lineas: usize) -> Option<Self> {
163        if lineas == 0 {
164            return None;
165        }
166        let inicio: Vec<u32> = (0..=lineas as u32).collect();
167        let cortes: Vec<u32> = vec![0; lineas];
168        Some(Self { ancho_cols: usize::MAX, lineas, inicio, cortes })
169    }
170
171    /// **Filas de relleno al final de una línea**, para que ocupe al menos
172    /// `pisos[l]`.
173    ///
174    /// Es lo que permite **alinear párrafo a párrafo entre columnas**: un
175    /// párrafo que ajusta en dos filas y su traducción en cinco dejan de estar
176    /// a distinta altura si el corto reserva las tres que le faltan. Sin eso,
177    /// la cinta que los une sale de un bloque y entra a otro que empieza en
178    /// otro renglón, y deja de leerse como continuidad — que es el pedido que
179    /// lo originó (pluma, 2026-09-15).
180    ///
181    /// Las filas de relleno se marcan con una columna **imposible**
182    /// ([`COL_FANTASMA`]) y no con el fin de la línea: así ninguna consulta de
183    /// posición cae en ellas (`fila_de` busca la última fila cuyo corte sea
184    /// ≤ la columna pedida), el caret no se va al hueco y lo único que hacen es
185    /// ocupar alto. Su texto sale vacío por el mismo camino de siempre.
186    pub fn con_piso(mut self, pisos: &[usize]) -> Self {
187        if pisos.len() != self.lineas {
188            return self;
189        }
190        let mut inicio: Vec<u32> = Vec::with_capacity(self.lineas + 1);
191        let mut cortes: Vec<u32> = Vec::with_capacity(self.cortes.len());
192        for l in 0..self.lineas {
193            inicio.push(cortes.len() as u32);
194            let a = self.inicio(l);
195            let b = self.inicio(l + 1);
196            cortes.extend_from_slice(&self.cortes[a..b]);
197            let reales = b - a;
198            for _ in reales..pisos[l].max(reales) {
199                cortes.push(COL_FANTASMA);
200            }
201        }
202        inicio.push(cortes.len() as u32);
203        self.inicio = inicio;
204        self.cortes = cortes;
205        self
206    }
207
208    /// El ancho (en columnas) contra el que se armó.
209    pub fn ancho_cols(&self) -> usize {
210        self.ancho_cols
211    }
212
213    /// Cuántas líneas lógicas describe. La [`Disposicion`] lo usa para
214    /// **descartar un mapa viejo**: uno que no corresponde al buffer no se nota
215    /// al aplicarlo, se nota como texto pintado en el renglón equivocado.
216    pub fn lineas(&self) -> usize {
217        self.lineas
218    }
219
220    /// Cuántas filas globales produce.
221    pub fn filas(&self) -> usize {
222        self.cortes.len()
223    }
224
225    fn inicio(&self, linea: usize) -> usize {
226        self.inicio[linea.min(self.lineas)] as usize
227    }
228
229    /// Fila global donde cae la columna `col` de la línea `linea`.
230    fn fila_de(&self, linea: usize, col: usize) -> usize {
231        let l = linea.min(self.lineas.saturating_sub(1));
232        let a = self.inicio(l);
233        let b = self.inicio(l + 1);
234        let k = self.cortes[a..b].partition_point(|c| (*c as usize) <= col);
235        a + k.saturating_sub(1)
236    }
237
238    /// La fila global `f`, resuelta a línea y tramo de columnas.
239    fn fila_en(&self, f: usize) -> Fila {
240        let f = f.min(self.filas().saturating_sub(1));
241        let linea = self.inicio.partition_point(|i| (*i as usize) <= f).saturating_sub(1);
242        let col_inicio = self.cortes[f] as usize;
243        let col_fin = (f + 1 < self.inicio(linea + 1)).then(|| self.cortes[f + 1] as usize);
244        Fila { linea, col_inicio, col_fin }
245    }
246}
247
248/// Las columnas donde se parte una línea, sin contar el 0.
249///
250/// Corta **después del último espacio** que entra en el ancho: partir una
251/// palabra al medio se lee peor que dejar el renglón corto, y es lo que hace
252/// cualquier editor. Si en todo el tramo no hay un espacio, corta duro — una
253/// línea de cuatro mil caracteres sin un blanco existe (un base64, un JSON
254/// minificado) y tiene que verse igual de bien.
255fn cortes_de_linea(texto: &str, ancho: usize) -> Vec<usize> {
256    let chars: Vec<char> = texto.trim_end_matches(['\n', '\r']).chars().collect();
257    let mut out = Vec::new();
258    let mut ini = 0usize;
259    while chars.len() - ini > ancho {
260        let duro = ini + ancho;
261        let corte = (ini + 1..=duro)
262            .rev()
263            .find(|i| chars[i - 1].is_whitespace())
264            .unwrap_or(duro);
265        out.push(corte);
266        ini = corte;
267    }
268    out
269}
270
271/// Índice en bytes de la columna `col` (en chars) de `texto`. Más allá del
272/// final devuelve el largo — cortar al final es siempre válido.
273pub fn byte_de_columna(texto: &str, col: usize) -> usize {
274    texto.char_indices().nth(col).map_or(texto.len(), |(i, _)| i)
275}
276
277/// Un tramo de líneas **ocultas** por un pliegue.
278///
279/// La línea que encabeza el bloque (la del `fn`, el `{`, el `<div>`) **no** se
280/// oculta: lo que se pliega es su cuerpo. Por eso el rango arranca en la línea
281/// siguiente a la cabecera.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub struct TramoPlegado {
284    /// Primera línea oculta.
285    pub desde: usize,
286    /// Última línea oculta, inclusive.
287    pub hasta: usize,
288}
289
290impl TramoPlegado {
291    pub const fn nuevo(desde: usize, hasta: usize) -> Self {
292        Self { desde, hasta }
293    }
294
295    /// Cuántas líneas oculta.
296    pub const fn ocultas(&self) -> usize {
297        if self.hasta >= self.desde {
298            self.hasta - self.desde + 1
299        } else {
300            0
301        }
302    }
303
304    pub const fn contiene(&self, linea: usize) -> bool {
305        linea >= self.desde && linea <= self.hasta
306    }
307
308    /// La línea que encabeza el pliegue: la de arriba, la que sigue visible y
309    /// lleva la flecha en el gutter.
310    pub const fn cabecera(&self) -> usize {
311        self.desde.saturating_sub(1)
312    }
313}
314
315/// El mapa entre líneas lógicas, filas visuales y píxeles.
316///
317/// Se construye por cuadro a partir del buffer y las métricas; no guarda estado
318/// del scroll. Sin pliegues ni ajuste de línea es la identidad y no cuesta nada
319/// armarla. Con cualquiera de los dos lleva además tablas de acumulados, que es
320/// lo que permite ir de línea a fila y de vuelta en `O(log n)` en vez de
321/// recorrer el archivo.
322///
323/// Los dos se componen sobre la misma cuenta: **el ajuste reparte una línea en
324/// varias filas y el pliegue esconde filas**. Por eso los acumulados del
325/// plegado se llevan en filas (globales, pre-pliegue) y no en líneas — si se
326/// llevaran en líneas, plegar un bloque de texto ajustado correría el
327/// documento por la cantidad equivocada.
328#[derive(Debug, Clone, PartialEq)]
329pub struct Disposicion {
330    lineas: usize,
331    alto_fila: f32,
332    /// El mapa de ajuste de línea, si está puesto. Sin él, una línea lógica es
333    /// exactamente una fila global.
334    ajuste: Option<Arc<MapaAjuste>>,
335    /// Tramos ocultos, **ordenados por `desde` y sin solaparse** — lo garantiza
336    /// [`con_pliegues`](Self::con_pliegues), que es la única puerta de entrada.
337    pliegues: Vec<TramoPlegado>,
338    /// `acum[i]` = filas ocultas por los tramos `0..i`.
339    acum: Vec<usize>,
340    /// `desde_fg[i]` = primera fila **global** que oculta el tramo `i`.
341    desde_fg: Vec<usize>,
342    /// `ocultas_fg[i]` = cuántas filas globales oculta el tramo `i`.
343    ocultas_fg: Vec<usize>,
344    /// `inicio_fila[i]` = la fila **visual** donde queda el hueco del tramo
345    /// `i`. Es el índice para la vuelta (de fila a línea): sin él habría que
346    /// buscar el acumulado adentro de la búsqueda, y una binaria adentro de
347    /// otra es la clase de detalle que después nadie entiende.
348    inicio_fila: Vec<usize>,
349}
350
351impl Disposicion {
352    /// El mapa identidad: una línea es una fila, todas de `alto_fila` px.
353    ///
354    /// `alto_fila` se clampea a un mínimo positivo: un alto de fila cero o
355    /// negativo haría divisiones por cero en todo el resto del módulo, y el
356    /// origen más probable de ese valor es un `EditorMetrics` a medio construir,
357    /// no una intención.
358    pub fn uniforme(lineas: usize, alto_fila: f32) -> Self {
359        Self {
360            lineas,
361            alto_fila: if alto_fila > 0.1 { alto_fila } else { 0.1 },
362            ajuste: None,
363            pliegues: Vec::new(),
364            acum: Vec::new(),
365            desde_fg: Vec::new(),
366            ocultas_fg: Vec::new(),
367            inicio_fila: Vec::new(),
368        }
369    }
370
371    /// La disposición que corresponde a un buffer de `lineas` líneas con estas
372    /// métricas. Es el constructor que usa el render.
373    pub fn para(lineas: usize, metrics: EditorMetrics) -> Self {
374        Self::uniforme(lineas, metrics.line_height)
375    }
376
377    /// La misma disposición, con el ajuste de línea puesto.
378    ///
379    /// Un mapa que **no describe este buffer** (otra cantidad de líneas) se
380    /// descarta en silencio y el editor cae al camino sin ajuste. Es deliberado:
381    /// un mapa viejo no falla al aplicarse, falla tres cuadros después como
382    /// texto pintado en el renglón equivocado, y eso no se diagnostica.
383    pub fn con_ajuste(mut self, mapa: Option<Arc<MapaAjuste>>) -> Self {
384        self.ajuste = mapa.filter(|m| m.lineas() == self.lineas);
385        self.reindexar();
386        self
387    }
388
389    /// `true` si hay ajuste de línea puesto.
390    pub fn ajustada(&self) -> bool {
391        self.ajuste.is_some()
392    }
393
394    /// La misma disposición, con tramos plegados.
395    ///
396    /// Los tramos entran **crudos**: se ordenan, se recortan al buffer y se
397    /// **fusionan** los que se solapan o se tocan. Es deliberado que la
398    /// normalización viva acá y no en el llamador — un solapamiento silencioso
399    /// haría que el mismo `y` contestara dos filas distintas según por qué lado
400    /// se preguntara, y ese tipo de error no se ve hasta que el caret aparece en
401    /// otro renglón.
402    pub fn con_pliegues(mut self, mut tramos: Vec<TramoPlegado>) -> Self {
403        let ultima = self.lineas.saturating_sub(1);
404        tramos.retain(|t| t.desde <= t.hasta && t.desde <= ultima && t.desde > 0);
405        for t in tramos.iter_mut() {
406            t.hasta = t.hasta.min(ultima);
407        }
408        tramos.sort_by_key(|t| t.desde);
409
410        let mut fusionados: Vec<TramoPlegado> = Vec::with_capacity(tramos.len());
411        for t in tramos {
412            match fusionados.last_mut() {
413                // `+ 1` porque dos tramos pegados (uno termina donde el otro
414                // empieza) son un solo hueco: dejarlos separados no cambia el
415                // mapa pero sí duplica el trabajo de toda búsqueda.
416                Some(prev) if t.desde <= prev.hasta + 1 => {
417                    prev.hasta = prev.hasta.max(t.hasta);
418                }
419                _ => fusionados.push(t),
420            }
421        }
422        self.pliegues = fusionados;
423        self.reindexar();
424        self
425    }
426
427    /// Recalcula los acumulados del plegado **en filas**. Lo llaman los dos
428    /// constructores aditivos, así que el orden en que se encadenen
429    /// (`con_ajuste().con_pliegues()` o al revés) da el mismo mapa.
430    fn reindexar(&mut self) {
431        let n = self.pliegues.len();
432        let mut acum = Vec::with_capacity(n);
433        let mut desde_fg = Vec::with_capacity(n);
434        let mut ocultas_fg = Vec::with_capacity(n);
435        let mut inicio_fila = Vec::with_capacity(n);
436        let mut suma = 0usize;
437        for i in 0..n {
438            let t = self.pliegues[i];
439            let ini = self.fg_borde(t.desde);
440            let fin = self.fg_borde(t.hasta + 1);
441            let ocultas = fin.saturating_sub(ini);
442            acum.push(suma);
443            desde_fg.push(ini);
444            ocultas_fg.push(ocultas);
445            inicio_fila.push(ini - suma);
446            suma += ocultas;
447        }
448        self.acum = acum;
449        self.desde_fg = desde_fg;
450        self.ocultas_fg = ocultas_fg;
451        self.inicio_fila = inicio_fila;
452    }
453
454    // -- Filas globales: el documento como si nada estuviera plegado. --------
455
456    /// Primera fila global de la línea, admitiendo `linea == lineas` como el
457    /// borde del documento (que es lo que necesita el final de un tramo).
458    fn fg_borde(&self, linea: usize) -> usize {
459        match &self.ajuste {
460            Some(m) => m.inicio(linea),
461            None => linea.min(self.lineas),
462        }
463    }
464
465    /// Fila global donde cae la columna `col` de la línea `linea`.
466    fn fg_de_pos(&self, linea: usize, col: usize) -> usize {
467        let l = linea.min(self.lineas.saturating_sub(1));
468        match &self.ajuste {
469            Some(m) => m.fila_de(l, col),
470            None => l,
471        }
472    }
473
474    /// Cuántas filas globales tiene el documento sin plegar nada.
475    fn fg_total(&self) -> usize {
476        match &self.ajuste {
477            Some(m) => m.filas(),
478            None => self.lineas,
479        }
480    }
481
482    /// La fila global `fg`, resuelta a línea y tramo de columnas.
483    fn linea_de_fg(&self, fg: usize) -> Fila {
484        match &self.ajuste {
485            Some(m) => m.fila_en(fg),
486            None => Fila::entera(fg.min(self.lineas.saturating_sub(1))),
487        }
488    }
489
490    // -- El plegado, contado en filas. ---------------------------------------
491
492    /// Los tramos plegados, ya normalizados.
493    pub fn pliegues(&self) -> &[TramoPlegado] {
494        &self.pliegues
495    }
496
497    /// El tramo que oculta esta línea, si hay alguno.
498    pub fn pliegue_que_oculta(&self, linea: usize) -> Option<&TramoPlegado> {
499        let i = self.pliegues.partition_point(|t| t.desde <= linea);
500        self.pliegues.get(i.checked_sub(1)?).filter(|t| t.contiene(linea))
501    }
502
503    /// `true` si la línea está escondida dentro de un pliegue.
504    pub fn esta_oculta(&self, linea: usize) -> bool {
505        self.pliegue_que_oculta(linea).is_some()
506    }
507
508    /// Cuántas filas quedan ocultas **antes** de la fila global `fg`.
509    fn ocultas_antes_fg(&self, fg: usize) -> usize {
510        let i = self.desde_fg.partition_point(|d| *d <= fg);
511        match i.checked_sub(1) {
512            None => 0,
513            Some(j) => {
514                // Si `fg` cae adentro del tramo, sólo cuentan las de arriba
515                // suyo dentro de ese mismo tramo.
516                let dentro = if fg < self.desde_fg[j] + self.ocultas_fg[j] {
517                    fg - self.desde_fg[j]
518                } else {
519                    self.ocultas_fg[j]
520                };
521                self.acum[j] + dentro
522            }
523        }
524    }
525
526    /// Cuántas filas hay ocultas en total.
527    fn ocultas_totales_fg(&self) -> usize {
528        match (self.acum.last(), self.ocultas_fg.last()) {
529            (Some(a), Some(o)) => a + o,
530            _ => 0,
531        }
532    }
533
534    // -- La cara pública: filas visuales y píxeles. --------------------------
535
536    /// Cuántas filas visuales tiene el documento. Un buffer vacío tiene una
537    /// fila (la línea vacía donde vive el caret), no cero.
538    pub fn filas(&self) -> usize {
539        self.fg_total().saturating_sub(self.ocultas_totales_fg()).max(1)
540    }
541
542    /// Cuántas líneas lógicas tiene el buffer que describe.
543    pub fn lineas(&self) -> usize {
544        self.lineas
545    }
546
547    /// Alto de una fila cualquiera. Con filas de altura variable dejará de ser
548    /// constante; los llamadores ya preguntan por fila.
549    pub fn alto_de(&self, _fila: usize) -> f32 {
550        self.alto_fila
551    }
552
553    /// Alto total del documento en píxeles.
554    pub fn alto_total(&self) -> f32 {
555        self.filas() as f32 * self.alto_fila
556    }
557
558    /// Y (px, desde el tope del documento) donde arranca la fila.
559    pub fn y_de(&self, fila: usize) -> f32 {
560        fila.min(self.filas()) as f32 * self.alto_fila
561    }
562
563    /// La fila que contiene el píxel `y` del documento. Se clampea al rango
564    /// válido: un `y` más allá del final devuelve la última fila.
565    ///
566    /// El `EPS_FILA` no es cosmético: `y_de(250)` con `alto_fila = 18.2` da
567    /// 4549.9995 en `f32`, y dividirlo de vuelta da 249.99997, que truncado es
568    /// **249**. Sin el epsilon, ir a una fila y volver a preguntar en qué fila
569    /// estás se corre una hacia arriba — y el scroll se arrastraría un renglón
570    /// cada vez que pasa por un pixel absoluto (la barra, el clamp, un salto).
571    pub fn fila_en_y(&self, y: f32) -> usize {
572        if y <= 0.0 {
573            return 0;
574        }
575        ((y / self.alto_fila + EPS_FILA) as usize).min(self.filas().saturating_sub(1))
576    }
577
578    /// La fila visual donde arranca una línea lógica. Una línea **oculta**
579    /// contesta la fila de la cabecera de su pliegue: es donde el ojo la
580    /// encuentra, y es lo que hace que llevar el caret a una línea plegada lo
581    /// lleve al pliegue en vez de a un lugar que no se ve.
582    pub fn fila_de_linea(&self, linea: usize) -> usize {
583        self.fila_de_pos(linea, 0)
584    }
585
586    /// La fila visual donde cae una **posición** del buffer. Con ajuste de
587    /// línea, dos columnas de la misma línea pueden estar en filas distintas —
588    /// y por eso el caret, la selección y los diagnostics preguntan por acá y
589    /// no por [`fila_de_linea`](Self::fila_de_linea).
590    pub fn fila_de_pos(&self, linea: usize, col: usize) -> usize {
591        let linea = linea.min(self.lineas.saturating_sub(1));
592        let (linea, col) = match self.pliegue_que_oculta(linea) {
593            Some(t) => (t.cabecera(), 0),
594            None => (linea, col),
595        };
596        let fg = self.fg_de_pos(linea, col);
597        fg.saturating_sub(self.ocultas_antes_fg(fg)).min(self.filas().saturating_sub(1))
598    }
599
600    /// La línea lógica (y el tramo de columnas) de una fila visual.
601    pub fn linea_de_fila(&self, fila: usize) -> Fila {
602        let fila = fila.min(self.filas().saturating_sub(1));
603        if self.pliegues.is_empty() {
604            return self.linea_de_fg(fila);
605        }
606        // El último tramo cuyo hueco ya quedó por encima de `fila`: todo lo que
607        // ocultan él y los anteriores se le suma a la fila para volver a la
608        // fila global.
609        let i = self.inicio_fila.partition_point(|inicio| *inicio <= fila);
610        let saltadas = match i.checked_sub(1) {
611            None => 0,
612            Some(j) => self.acum[j] + self.ocultas_fg[j],
613        };
614        self.linea_de_fg((fila + saltadas).min(self.fg_total().saturating_sub(1)))
615    }
616}
617
618/// Dónde está parado el scroll: una fila de ancla y el resto en píxeles dentro
619/// de ella, más el desplazamiento horizontal.
620///
621/// `fino_px ∈ [0, alto_de(fila_ancla))`. Fuera de ese rango el desplazamiento
622/// sigue siendo interpretable, pero [`normalizar`](Desplazamiento::normalizar)
623/// lo vuelve canónico moviendo el ancla — que es lo que hace el scroll suave
624/// cuando el resto se pasa de una fila.
625#[derive(Debug, Clone, Copy, PartialEq, Default)]
626pub struct Desplazamiento {
627    /// Fila visual que toca el borde superior del viewport.
628    pub fila_ancla: usize,
629    /// Cuántos píxeles de esa fila quedaron por encima del borde.
630    pub fino_px: f32,
631    /// Desplazamiento horizontal en píxeles (0 = pegado a la izquierda).
632    pub x_px: f32,
633}
634
635impl Desplazamiento {
636    /// El desplazamiento equivalente a estar en la fila `f`, sin resto.
637    pub const fn en_fila(f: usize) -> Self {
638        Self { fila_ancla: f, fino_px: 0.0, x_px: 0.0 }
639    }
640
641    /// Y absoluto (px desde el tope del documento) del borde superior.
642    pub fn y_documento(&self, d: &Disposicion) -> f32 {
643        d.y_de(self.fila_ancla) + self.fino_px
644    }
645
646    /// El desplazamiento que deja el borde superior en el píxel `y` del
647    /// documento. Es la vuelta de [`y_documento`](Self::y_documento) y la que
648    /// usa el arrastre de la barra de scroll, que piensa en absoluto.
649    pub fn desde_y(y: f32, x_px: f32, d: &Disposicion) -> Self {
650        let y = y.max(0.0);
651        let fila = d.fila_en_y(y);
652        Self { fila_ancla: fila, fino_px: (y - d.y_de(fila)).max(0.0), x_px }
653    }
654
655    /// Reacomoda el ancla para que el resto vuelva a caer dentro de su fila.
656    /// Es lo que convierte un delta de rueda en píxeles en un ancla válida.
657    pub fn normalizar(self, d: &Disposicion) -> Self {
658        Self::desde_y(self.y_documento(d), self.x_px, d)
659    }
660}
661
662/// El rectángulo por el que se mira el documento.
663///
664/// `ancho_px`/`alto_px` son **medidos**, no supuestos: el host los publica con
665/// un `llimphi_widget_scroll::TamanoVisible` desde el `paint_with` del editor y
666/// los lee al cuadro siguiente. Mientras valen `0.0` —el primer cuadro, y cada
667/// resize— el viewport [cae en un valor de arranque](Viewport::filas_completas)
668/// en vez de mentir.
669#[derive(Debug, Clone, Copy, PartialEq)]
670pub struct Viewport {
671    pub ancho_px: f32,
672    pub alto_px: f32,
673    pub desplazamiento: Desplazamiento,
674    pub metrics: EditorMetrics,
675    /// Cuántas filas de aire dejar entre el caret y el borde al auto-scrollear
676    /// (el *scrolloff* de un IDE). `0` pega el caret al borde, que es lo que
677    /// hacía el editor hasta acá.
678    pub margen_filas: usize,
679}
680
681/// Filas que se asumen mientras no hay medida. No es un viewport: es el valor
682/// del primer cuadro —y de cada resize— hasta que el `paint_with` publique el
683/// rect real.
684///
685/// Es **generoso a propósito**. El error tiene dos lados y no son simétricos:
686/// pintar de más se recorta y no se ve, pintar de menos deja media pantalla en
687/// fondo vacío hasta el cuadro siguiente. Y el cuadro siguiente no siempre
688/// llega solo: el bucle Elm repinta cuando hay un `Msg`, así que un editor
689/// abierto y quieto se quedaría con el piso puesto. Doscientas filas es,
690/// además, exactamente lo que materializaba el camino viejo con su tope.
691pub const FILAS_SIN_MEDIDA: usize = 200;
692
693impl Viewport {
694    /// Un viewport medido en píxeles.
695    pub fn medido(ancho_px: f32, alto_px: f32, metrics: EditorMetrics) -> Self {
696        Self {
697            ancho_px: ancho_px.max(0.0),
698            alto_px: alto_px.max(0.0),
699            desplazamiento: Desplazamiento::default(),
700            metrics,
701            margen_filas: 0,
702        }
703    }
704
705    /// El puente para los llamadores viejos, que cuentan líneas en vez de medir
706    /// píxeles: reconstruye un alto a partir de `visible_lines`. Existe para que
707    /// las firmas históricas del widget sigan andando sin cambios; **no lo uses
708    /// en código nuevo** — pedirle al host que cuente líneas es exactamente el
709    /// error que este módulo viene a sacar.
710    pub fn por_lineas(visible_lines: usize, metrics: EditorMetrics) -> Self {
711        Self::medido(0.0, visible_lines.max(1) as f32 * metrics.line_height, metrics)
712    }
713
714    pub fn con_desplazamiento(mut self, d: Desplazamiento) -> Self {
715        self.desplazamiento = d;
716        self
717    }
718
719    pub fn con_margen(mut self, filas: usize) -> Self {
720        self.margen_filas = filas;
721        self
722    }
723
724    /// Cuántas filas **enteras** entran. Es el número que reemplaza a la
725    /// constante que contaba el host, y el que usan página-arriba/abajo y el
726    /// auto-scroll. Nunca es 0: un viewport de menos de una fila igual muestra
727    /// una, recortada.
728    pub fn filas_completas(&self, d: &Disposicion) -> usize {
729        if self.alto_px <= 0.0 {
730            return FILAS_SIN_MEDIDA;
731        }
732        let alto_fila = d.alto_de(0);
733        ((self.alto_px / alto_fila) as usize).max(1)
734    }
735
736    /// Cuántas filas hay que **materializar**: las enteras más la que asoma
737    /// recortada arriba y la que asoma abajo. Es lo que evita el borde vacío
738    /// cuando el scroll cae entre dos filas.
739    pub fn filas_a_materializar(&self, d: &Disposicion) -> usize {
740        self.filas_completas(d) + 2
741    }
742
743    /// El rango `[primera, ultima)` de filas que hay que dibujar, y cuántos
744    /// píxeles de la primera quedan por encima del borde (siempre ≤ 0, para
745    /// sumarlo directo a la Y de dibujo).
746    ///
747    /// Esto es la virtualización: el costo del render pasa a depender del **alto
748    /// del viewport** y no del tamaño del archivo, que es lo que vuelve
749    /// innecesarios los topes de 200 y 512 líneas.
750    pub fn rango_visible(&self, d: &Disposicion) -> RangoVisible {
751        let desp = self.desplazamiento.normalizar(d);
752        let primera = desp.fila_ancla.min(d.filas().saturating_sub(1));
753        let ultima = (primera + self.filas_a_materializar(d)).min(d.filas());
754        RangoVisible { primera, ultima, recorte_px: -desp.fino_px }
755    }
756
757    /// Máximo desplazamiento vertical en píxeles: el que deja el final del
758    /// documento tocando el borde inferior.
759    ///
760    /// Es la misma definición que `llimphi_widget_scroll::max_offset`, y tiene
761    /// que serlo: el pulgar de la barra se dimensiona con **esa**, así que si
762    /// acá el tope fuera otro, arrastrar la barra hasta el fondo y seguir con la
763    /// rueda darían resultados distintos — el pulgar clavado abajo y el texto
764    /// todavía moviéndose.
765    ///
766    /// El camino viejo (`EditorState::scroll_by`) clampeaba a `line_count - 1`,
767    /// o sea permitía dejar una sola línea a la vista con el resto del panel en
768    /// blanco. Eso era un artefacto de contar líneas enteras, no una decisión;
769    /// `scroll_by` lo conserva para los consumidores que lo usan.
770    pub fn max_y(&self, d: &Disposicion) -> f32 {
771        (d.alto_total() - self.alto_visible(d)).max(0.0)
772    }
773
774    /// Aplica un delta vertical **en píxeles** y devuelve el desplazamiento
775    /// clampeado. La rueda, el arrastre de la barra y la inercia entran todos
776    /// por acá.
777    pub fn desplazar_y(&self, d: &Disposicion, delta_px: f32) -> Desplazamiento {
778        let y = (self.desplazamiento.y_documento(d) + delta_px).clamp(0.0, self.max_y(d));
779        Desplazamiento::desde_y(y, self.desplazamiento.x_px, d)
780    }
781
782    /// Aplica un delta horizontal en píxeles, clampeado a `[0, max_x]`.
783    ///
784    /// `ancho_contenido_px` lo calcula el llamador (la línea más larga por el
785    /// ancho de char); mientras no haya medida de ancho del viewport se deja
786    /// pasar cualquier valor no negativo, por el mismo motivo por el que
787    /// `offset_efectivo` de `llimphi-widget-scroll` no clampea sin medida.
788    pub fn desplazar_x(&self, delta_px: f32, ancho_contenido_px: f32) -> f32 {
789        let x = (self.desplazamiento.x_px + delta_px).max(0.0);
790        if self.ancho_px <= 0.0 {
791            return x;
792        }
793        x.min((ancho_contenido_px - self.ancho_px).max(0.0))
794    }
795
796    /// El desplazamiento que deja la fila `fila` dentro del viewport,
797    /// respetando [`margen_filas`](Self::margen_filas). Si ya está adentro, no
798    /// se mueve — un auto-scroll que reencuadra en cada tecla marea.
799    pub fn asegurar_fila_visible(&self, d: &Disposicion, fila: usize) -> Desplazamiento {
800        let alto_fila = d.alto_de(fila);
801        let margen = self.margen_filas as f32 * alto_fila;
802        let y_actual = self.desplazamiento.y_documento(d);
803        let tope = d.y_de(fila) - margen;
804        let fondo = d.y_de(fila) + alto_fila + margen - self.alto_visible(d);
805        let y = if tope < y_actual {
806            tope
807        } else if fondo > y_actual {
808            fondo
809        } else {
810            y_actual
811        };
812        Desplazamiento::desde_y(y.clamp(0.0, self.max_y(d)), self.desplazamiento.x_px, d)
813    }
814
815    /// De un píxel del área de contenido a `(línea, columna)` del buffer.
816    ///
817    /// Es la **inversa exacta** del render, y tiene que serlo: el click cae
818    /// donde el ojo ve el carácter, no donde estaría si el scroll fuera por
819    /// líneas enteras. `EditorMetrics::screen_to_pos` —la versión vieja— divide
820    /// por una grilla y suma `scroll_offset`, así que ignora el resto sub-fila y
821    /// el desplazamiento horizontal: con scroll fino, erra una línea.
822    ///
823    /// `local_x`/`local_y` se miden desde el borde del área de texto (la que el
824    /// widget publica en su [`TamanoVisible`](llimphi_widget_scroll::TamanoVisible)),
825    /// no desde el gutter.
826    pub fn pos_en(
827        &self,
828        d: &Disposicion,
829        texto_de_linea: impl Fn(usize) -> String,
830        medidor: &dyn MedidorTexto,
831        local_x: f32,
832        local_y: f32,
833    ) -> (usize, usize) {
834        let y_doc = self.desplazamiento.y_documento(d) + (local_y - crate::view::PAD_Y);
835        let fila = d.fila_en_y(y_doc.max(0.0));
836        let f = d.linea_de_fila(fila);
837        let x = (local_x + self.desplazamiento.x_px - crate::view::PAD_X).max(0.0);
838        let texto = texto_de_linea(f.linea);
839        let tramo = texto_de_fila(&texto, f);
840        // El clamp al final del tramo es lo que hace que clickear a la derecha
841        // de una fila ajustada deje el caret en ese renglón y no en el que
842        // sigue: sin él, `columna_en_x` sobre un tramo corto contesta una
843        // columna que ya pertenece a la fila de abajo.
844        let tope = f.col_fin.unwrap_or(usize::MAX);
845        (f.linea, (f.col_inicio + medidor.columna_en_x(&tramo, x)).min(tope))
846    }
847
848    /// El alto que se usa para las cuentas de encuadre. Sin medida todavía, se
849    /// deriva de [`FILAS_SIN_MEDIDA`] en vez de valer cero — con cero, el
850    /// `fondo` de `asegurar_fila_visible` empujaría el scroll al final en la
851    /// primera tecla.
852    fn alto_visible(&self, d: &Disposicion) -> f32 {
853        if self.alto_px > 0.0 {
854            self.alto_px
855        } else {
856            FILAS_SIN_MEDIDA as f32 * d.alto_de(0)
857        }
858    }
859}
860
861/// Qué filas dibujar y cuánto correr la primera hacia arriba.
862#[derive(Debug, Clone, Copy, PartialEq)]
863pub struct RangoVisible {
864    /// Primera fila a materializar (inclusive).
865    pub primera: usize,
866    /// Última fila a materializar (exclusive).
867    pub ultima: usize,
868    /// Píxeles a sumar a la Y de dibujo — negativo o cero: la parte de la
869    /// primera fila que quedó por encima del borde.
870    pub recorte_px: f32,
871}
872
873impl RangoVisible {
874    pub fn cuantas(&self) -> usize {
875        self.ultima.saturating_sub(self.primera)
876    }
877}
878
879/// Quién sabe cuánto mide un texto en píxeles.
880///
881/// El editor asume desde siempre una fuente monoespaciada
882/// (`char_width = font_size * 0.6`), y con eso el caret y la selección se
883/// desalinean en cuanto hay CJK o una proporcional. Esto es la **costura** para
884/// arreglarlo sin arreglarlo todavía: hoy la única implementación es la de
885/// siempre, y quien quiera medir de verdad con parley enchufa otra sin tocar el
886/// render. Cambiar el default es su propio trabajo — toca a los nueve
887/// consumidores del widget.
888pub trait MedidorTexto {
889    /// Ancho en píxeles de `texto`.
890    fn ancho(&self, texto: &str) -> f32;
891    /// X (px) donde arranca la columna `col` de `texto`.
892    fn x_de_columna(&self, texto: &str, col: usize) -> f32;
893    /// Columna cuyo centro queda más cerca de `x`. Es el hit-test.
894    fn columna_en_x(&self, texto: &str, x: f32) -> usize;
895}
896
897/// La medición de siempre: todos los chars miden `char_width`.
898#[derive(Debug, Clone, Copy)]
899pub struct Monoespaciada {
900    pub char_width: f32,
901}
902
903impl Monoespaciada {
904    pub fn de(metrics: EditorMetrics) -> Self {
905        Self { char_width: if metrics.char_width > 0.01 { metrics.char_width } else { 0.01 } }
906    }
907}
908
909impl MedidorTexto for Monoespaciada {
910    fn ancho(&self, texto: &str) -> f32 {
911        texto.chars().count() as f32 * self.char_width
912    }
913
914    fn x_de_columna(&self, _texto: &str, col: usize) -> f32 {
915        col as f32 * self.char_width
916    }
917
918    fn columna_en_x(&self, _texto: &str, x: f32) -> usize {
919        if x <= 0.0 {
920            return 0;
921        }
922        // Redondeo al char más cercano: clickear en la mitad derecha de un
923        // carácter pone el caret a su derecha, como en cualquier editor.
924        ((x / self.char_width) + 0.5) as usize
925    }
926}
927
928/// El texto de una fila, tal como el render lo necesita: la línea entera sin
929/// su fin de línea, o el tramo que le toca cuando la línea está ajustada.
930pub fn texto_de_fila<'a>(linea: &'a str, fila: Fila) -> Cow<'a, str> {
931    let limpio = linea.trim_end_matches(['\n', '\r']);
932    if fila.es_primera() && fila.es_ultima() {
933        return Cow::Borrowed(limpio);
934    }
935    let ini = byte_de_columna(limpio, fila.col_inicio);
936    let fin = fila.col_fin.map_or(limpio.len(), |c| byte_de_columna(limpio, c));
937    Cow::Borrowed(&limpio[ini..fin.max(ini)])
938}
939
940/// Cuántas columnas entran en el ancho medido — el ancho contra el que se
941/// ajustan las líneas.
942///
943/// Devuelve `None` mientras no hay medida: ajustar contra un ancho inventado
944/// pondría los cortes en otro lado que el texto, y un cuadro después habría que
945/// rehacerlos igual. El piso de 8 columnas es para que un panel apretado no
946/// produzca un mapa de una letra por renglón.
947pub fn ancho_ajuste_cols(ancho_px: f32, metrics: EditorMetrics) -> Option<usize> {
948    let util = ancho_px - crate::view::PAD_X * 2.0;
949    if util <= 0.0 || metrics.char_width <= 0.01 {
950        return None;
951    }
952    Some(((util / metrics.char_width) as usize).max(8))
953}
954
955/// Ancho en píxeles de la línea más larga — el "contenido" del scroll
956/// horizontal. Se mide sobre las filas visibles y no sobre el archivo entero: un
957/// barrido completo por cuadro es justamente el costo que este módulo evita, y
958/// el único efecto de la aproximación es que el tope del scroll horizontal se
959/// ajusta a medida que se navega.
960pub fn ancho_contenido(lineas: impl Iterator<Item = String>, medidor: &dyn MedidorTexto) -> f32 {
961    let mut max: f32 = 0.0;
962    for l in lineas {
963        let w = medidor.ancho(l.trim_end_matches(['\n', '\r']));
964        if w > max {
965            max = w;
966        }
967    }
968    max
969}
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974
975    fn metrics() -> EditorMetrics {
976        EditorMetrics::for_font_size(13.0) // line_height = 18.2
977    }
978
979    fn disp(lineas: usize) -> Disposicion {
980        Disposicion::para(lineas, metrics())
981    }
982
983    #[test]
984    fn el_viewport_sigue_al_alto_medido_y_no_a_una_constante() {
985        let d = disp(1000);
986        // El caso que fallaba: una ventana alta mostraba 40 líneas y punto.
987        let alto = 1400.0;
988        let vp = Viewport::medido(900.0, alto, metrics());
989        assert_eq!(vp.filas_completas(&d), 76); // 1400 / 18.2 = 76.9
990        // Y una ventana chica muestra menos, en vez de cortar por abajo.
991        let chico = Viewport::medido(900.0, 300.0, metrics());
992        assert_eq!(chico.filas_completas(&d), 16); // 300 / 18.2 = 16.4
993    }
994
995    #[test]
996    fn sin_medida_no_miente_cero() {
997        let d = disp(1000);
998        let vp = Viewport::medido(0.0, 0.0, metrics());
999        assert_eq!(vp.filas_completas(&d), FILAS_SIN_MEDIDA);
1000    }
1001
1002    #[test]
1003    fn ida_y_vuelta_linea_fila_y_pixel() {
1004        let d = disp(500);
1005        for linea in [0usize, 1, 17, 250, 499] {
1006            let fila = d.fila_de_linea(linea);
1007            let y = d.y_de(fila);
1008            assert_eq!(d.fila_en_y(y), fila);
1009            // Un píxel adentro de la fila sigue cayendo en la misma fila.
1010            assert_eq!(d.fila_en_y(y + d.alto_de(fila) - 0.01), fila);
1011            assert_eq!(d.linea_de_fila(fila).linea, linea);
1012        }
1013        // Barrido completo: ninguna fila puede perderse en el redondeo.
1014        for fila in 0..d.filas() {
1015            assert_eq!(d.fila_en_y(d.y_de(fila)), fila, "fila {fila}");
1016        }
1017    }
1018
1019    #[test]
1020    fn el_desplazamiento_sobrevive_la_ida_y_vuelta_a_pixel_absoluto() {
1021        let d = disp(200);
1022        let desp = Desplazamiento { fila_ancla: 30, fino_px: 7.5, x_px: 40.0 };
1023        let y = desp.y_documento(&d);
1024        let vuelta = Desplazamiento::desde_y(y, 40.0, &d);
1025        assert_eq!(vuelta.fila_ancla, 30);
1026        assert!((vuelta.fino_px - 7.5).abs() < 0.001);
1027        assert_eq!(vuelta.x_px, 40.0);
1028    }
1029
1030    #[test]
1031    fn el_scroll_fino_reacomoda_el_ancla_al_pasarse_de_fila() {
1032        let d = disp(200);
1033        // Un resto mayor que la fila no es un estado inválido: se normaliza.
1034        let desp = Desplazamiento { fila_ancla: 10, fino_px: 40.0, x_px: 0.0 };
1035        let n = desp.normalizar(&d);
1036        assert_eq!(n.fila_ancla, 12); // 40 px = 2 filas de 18.2 + 3.6
1037        assert!((n.fino_px - 3.6).abs() < 0.01);
1038    }
1039
1040    #[test]
1041    fn la_rueda_mueve_pixeles_no_lineas_enteras() {
1042        let d = disp(200);
1043        let vp = Viewport::medido(800.0, 400.0, metrics());
1044        let desp = vp.desplazar_y(&d, 5.0);
1045        // Cinco píxeles NO son una línea: antes, cualquier delta menor a una
1046        // línea o no hacía nada o saltaba entera.
1047        assert_eq!(desp.fila_ancla, 0);
1048        assert!((desp.fino_px - 5.0).abs() < 0.001);
1049    }
1050
1051    #[test]
1052    fn el_scroll_vertical_se_clampea_a_los_dos_extremos() {
1053        let d = disp(50);
1054        let vp = Viewport::medido(800.0, 400.0, metrics());
1055        assert_eq!(vp.desplazar_y(&d, -1000.0), Desplazamiento::en_fila(0));
1056        let abajo = vp.desplazar_y(&d, 99_999.0);
1057        assert!((abajo.y_documento(&d) - vp.max_y(&d)).abs() < 0.001);
1058        // El final del documento queda tocando el borde de abajo: 50 líneas de
1059        // 18,2 px son 910, menos los 400 del panel son 510 px, que caen en la
1060        // fila 28. Ni una fila más — scrollear al vacío no es una feature.
1061        assert_eq!(abajo.fila_ancla, 28);
1062        assert!((d.alto_total() - abajo.y_documento(&d) - 400.0).abs() < 0.01);
1063    }
1064
1065    #[test]
1066    fn el_rango_visible_no_crece_con_el_archivo() {
1067        let d_chico = disp(100);
1068        let d_enorme = disp(1_000_000);
1069        let vp = Viewport::medido(800.0, 400.0, metrics());
1070        // 400 / 18.2 = 21 enteras + 2 de borde.
1071        assert_eq!(vp.rango_visible(&d_chico).cuantas(), 23);
1072        assert_eq!(vp.rango_visible(&d_enorme).cuantas(), 23);
1073    }
1074
1075    #[test]
1076    fn el_rango_visible_arranca_en_el_ancla_y_declara_su_recorte() {
1077        let d = disp(1000);
1078        let vp = Viewport::medido(800.0, 400.0, metrics())
1079            .con_desplazamiento(Desplazamiento { fila_ancla: 100, fino_px: 6.0, x_px: 0.0 });
1080        let r = vp.rango_visible(&d);
1081        assert_eq!(r.primera, 100);
1082        assert!((r.recorte_px - -6.0).abs() < 0.001);
1083    }
1084
1085    #[test]
1086    fn asegurar_visible_no_mueve_lo_que_ya_se_ve() {
1087        let d = disp(1000);
1088        let vp = Viewport::medido(800.0, 400.0, metrics())
1089            .con_desplazamiento(Desplazamiento::en_fila(50));
1090        assert_eq!(vp.asegurar_fila_visible(&d, 55), Desplazamiento::en_fila(50));
1091    }
1092
1093    #[test]
1094    fn asegurar_visible_respeta_el_margen_arriba_y_abajo() {
1095        let d = disp(1000);
1096        let vp = Viewport::medido(800.0, 400.0, metrics())
1097            .con_margen(3)
1098            .con_desplazamiento(Desplazamiento::en_fila(50));
1099        // Hacia arriba: deja 3 filas de aire por encima del caret.
1100        let arriba = vp.asegurar_fila_visible(&d, 50);
1101        assert_eq!(arriba.fila_ancla, 47);
1102        // Hacia abajo: el caret no se pega al borde inferior.
1103        let abajo = vp.asegurar_fila_visible(&d, 71);
1104        let ultima_visible = abajo.y_documento(&d) + vp.alto_px;
1105        assert!(d.y_de(71) + d.alto_de(71) + 3.0 * d.alto_de(71) <= ultima_visible + 0.01);
1106    }
1107
1108    #[test]
1109    fn el_scroll_horizontal_se_clampea_al_contenido() {
1110        let d = disp(10);
1111        let _ = d;
1112        let vp = Viewport::medido(500.0, 400.0, metrics());
1113        assert_eq!(vp.desplazar_x(-50.0, 1200.0), 0.0);
1114        assert_eq!(vp.desplazar_x(99_999.0, 1200.0), 700.0); // 1200 - 500
1115        // Contenido más angosto que el viewport: no hay nada que desplazar.
1116        assert_eq!(vp.desplazar_x(300.0, 200.0), 0.0);
1117    }
1118
1119    #[test]
1120    fn la_medicion_mono_redondea_al_char_mas_cercano() {
1121        let m = Monoespaciada { char_width: 10.0 };
1122        assert_eq!(m.columna_en_x("hola", 0.0), 0);
1123        assert_eq!(m.columna_en_x("hola", 4.0), 0); // mitad izquierda
1124        assert_eq!(m.columna_en_x("hola", 6.0), 1); // mitad derecha
1125        assert_eq!(m.columna_en_x("hola", 25.0), 3);
1126        assert_eq!(m.ancho("hola"), 40.0);
1127        assert_eq!(m.x_de_columna("hola", 2), 20.0);
1128    }
1129
1130    #[test]
1131    fn el_ancho_de_contenido_ignora_el_fin_de_linea() {
1132        let m = Monoespaciada { char_width: 10.0 };
1133        let w = ancho_contenido(
1134            ["ab\n".into(), "abcde\n".into(), "a".into()].into_iter(),
1135            &m,
1136        );
1137        assert_eq!(w, 50.0);
1138    }
1139
1140    // ---------------------------------------------------------------
1141    // El plegado: la Disposicion deja de ser la identidad.
1142    // ---------------------------------------------------------------
1143
1144    #[test]
1145    fn plegar_saca_filas_del_documento() {
1146        let d = disp(100).con_pliegues(vec![TramoPlegado::nuevo(10, 19)]);
1147        // Diez líneas ocultas: diez filas menos, y el documento mide menos.
1148        assert_eq!(d.filas(), 90);
1149        assert_eq!(d.lineas(), 100);
1150        assert!((d.alto_total() - 90.0 * 18.2).abs() < 0.1);
1151    }
1152
1153    #[test]
1154    fn la_ida_y_vuelta_sobrevive_a_los_pliegues() {
1155        let d = disp(200).con_pliegues(vec![
1156            TramoPlegado::nuevo(10, 19),
1157            TramoPlegado::nuevo(50, 51),
1158            TramoPlegado::nuevo(120, 180),
1159        ]);
1160        // Toda línea visible tiene una fila, y esa fila vuelve a ella. Es la
1161        // propiedad que sostiene el caret, el click y el scroll con pliegues.
1162        for l in 0..d.lineas() {
1163            if d.esta_oculta(l) {
1164                continue;
1165            }
1166            let f = d.fila_de_linea(l);
1167            assert_eq!(d.linea_de_fila(f).linea, l, "línea {l} → fila {f} → otra línea");
1168        }
1169        // Y toda fila cae en una línea visible.
1170        for f in 0..d.filas() {
1171            let l = d.linea_de_fila(f).linea;
1172            assert!(!d.esta_oculta(l), "la fila {f} apunta a la línea oculta {l}");
1173            assert_eq!(d.fila_de_linea(l), f);
1174        }
1175    }
1176
1177    #[test]
1178    fn una_linea_oculta_contesta_la_fila_de_su_cabecera() {
1179        let d = disp(100).con_pliegues(vec![TramoPlegado::nuevo(10, 19)]);
1180        // La cabecera es la 9 — la del `fn`, la que sigue a la vista.
1181        let cabecera = d.fila_de_linea(9);
1182        // Cualquier línea de adentro contesta esa misma fila: llevar el caret a
1183        // algo plegado lo lleva al pliegue, no a un lugar invisible.
1184        for l in 10..=19 {
1185            assert_eq!(d.fila_de_linea(l), cabecera, "línea {l}");
1186        }
1187        // Y la primera visible después del pliegue es la siguiente fila.
1188        assert_eq!(d.fila_de_linea(20), cabecera + 1);
1189    }
1190
1191    #[test]
1192    fn los_tramos_se_normalizan_al_entrar() {
1193        // Desordenados, solapados, pegados, fuera de rango y uno inválido.
1194        let d = disp(50).con_pliegues(vec![
1195            TramoPlegado::nuevo(30, 35),
1196            TramoPlegado::nuevo(10, 14),
1197            TramoPlegado::nuevo(12, 20),   // solapa con el anterior
1198            TramoPlegado::nuevo(21, 22),   // pegado al fusionado
1199            TramoPlegado::nuevo(40, 999),  // se recorta al buffer
1200            TramoPlegado::nuevo(8, 3),     // al revés: se descarta
1201            TramoPlegado::nuevo(0, 2),     // sin cabecera posible: se descarta
1202        ]);
1203        let p = d.pliegues();
1204        assert_eq!(p.len(), 3, "{p:?}");
1205        assert_eq!(p[0], TramoPlegado::nuevo(10, 22));
1206        assert_eq!(p[1], TramoPlegado::nuevo(30, 35));
1207        assert_eq!(p[2], TramoPlegado::nuevo(40, 49));
1208        // Y con todo eso el mapa sigue siendo consistente.
1209        for f in 0..d.filas() {
1210            assert_eq!(d.fila_de_linea(d.linea_de_fila(f).linea), f);
1211        }
1212    }
1213
1214    #[test]
1215    fn sin_pliegues_es_exactamente_la_identidad_de_antes() {
1216        let d = disp(500);
1217        assert!(d.pliegues().is_empty());
1218        for l in 0..500 {
1219            assert_eq!(d.fila_de_linea(l), l);
1220            assert_eq!(d.linea_de_fila(l).linea, l);
1221        }
1222    }
1223
1224    #[test]
1225    fn plegar_todo_el_cuerpo_deja_al_menos_una_fila() {
1226        // Un archivo entero plegado bajo su primera línea: queda la cabecera.
1227        let d = disp(100).con_pliegues(vec![TramoPlegado::nuevo(1, 99)]);
1228        assert_eq!(d.filas(), 1);
1229        assert_eq!(d.linea_de_fila(0).linea, 0);
1230    }
1231
1232    #[test]
1233    fn el_pixel_de_una_fila_plegada_es_el_de_su_lugar_nuevo() {
1234        let d = disp(100).con_pliegues(vec![TramoPlegado::nuevo(10, 19)]);
1235        // La línea 20 subió diez lugares: su píxel es el de la fila 10.
1236        assert!((d.y_de(d.fila_de_linea(20)) - 10.0 * 18.2).abs() < 0.1);
1237        assert_eq!(d.fila_en_y(d.y_de(10)), 10);
1238    }
1239
1240    #[test]
1241    fn el_viewport_cuenta_filas_visibles_y_no_lineas() {
1242        let d = disp(1000).con_pliegues(vec![TramoPlegado::nuevo(1, 900)]);
1243        let vp = Viewport::medido(800.0, 400.0, metrics());
1244        // Quedan 100 filas; el documento entra casi entero y el scroll casi no
1245        // tiene a dónde ir.
1246        assert_eq!(d.filas(), 100);
1247        assert!((vp.max_y(&d) - (100.0 * 18.2 - 400.0)).abs() < 0.1);
1248    }
1249
1250    #[test]
1251    fn el_hit_test_es_la_inversa_exacta_del_render() {
1252        let d = disp(1000);
1253        let m = Monoespaciada::de(metrics());
1254        let texto = |_n: usize| String::from("una linea cualquiera de texto");
1255        // Sin scroll: el píxel del tope del área es la primera línea.
1256        let vp = Viewport::medido(800.0, 400.0, metrics());
1257        assert_eq!(vp.pos_en(&d, texto, &m, crate::view::PAD_X, crate::view::PAD_Y).0, 0);
1258
1259        // Con scroll fino, el mismo píxel cae en la fila que el render dibuja
1260        // ahí. El render pone la fila `primera` en `PAD_Y - fino_px`, así que
1261        // el píxel `PAD_Y` ya está adentro de ella mientras `fino_px > 0`.
1262        let vp = vp.con_desplazamiento(Desplazamiento { fila_ancla: 100, fino_px: 9.0, x_px: 0.0 });
1263        let (linea, _) = vp.pos_en(&d, texto, &m, crate::view::PAD_X, crate::view::PAD_Y);
1264        assert_eq!(linea, 100);
1265        // Un píxel más abajo del resto de esa fila ya es la siguiente.
1266        let (linea, _) = vp.pos_en(&d, texto, &m, crate::view::PAD_X, crate::view::PAD_Y + 10.0);
1267        assert_eq!(linea, 101);
1268    }
1269
1270    #[test]
1271    fn el_hit_test_sigue_al_scroll_horizontal() {
1272        let d = disp(10);
1273        let m = Monoespaciada { char_width: 10.0 };
1274        let texto = |_n: usize| String::from("0123456789abcdef");
1275        let vp = Viewport::medido(200.0, 400.0, metrics())
1276            .con_desplazamiento(Desplazamiento { fila_ancla: 0, fino_px: 0.0, x_px: 100.0 });
1277        // Clickear en el borde izquierdo con 100 px de scroll horizontal cae en
1278        // la columna 10, no en la 0.
1279        let (_, col) = vp.pos_en(&d, texto, &m, crate::view::PAD_X, crate::view::PAD_Y);
1280        assert_eq!(col, 10);
1281    }
1282
1283    #[test]
1284    fn el_hit_test_viejo_erra_con_scroll_fino_y_el_nuevo_no() {
1285        // El brazo de control de la regla 8: el instrumento tiene que fallar en
1286        // el estado que falla. Con el ancla en 100 y 9 px de resto, el render
1287        // dibuja la línea 100 arriba del todo; el camino viejo, que sólo sabe de
1288        // `scroll_offset`, contesta lo mismo hasta acá...
1289        let d = disp(1000);
1290        let m = Monoespaciada::de(metrics());
1291        let texto = |_n: usize| String::from("x");
1292        let vp = Viewport::medido(800.0, 400.0, metrics())
1293            .con_desplazamiento(Desplazamiento { fila_ancla: 100, fino_px: 9.0, x_px: 0.0 });
1294        let y = crate::view::PAD_Y + 12.0;
1295        let (nuevo, _) = vp.pos_en(&d, texto, &m, crate::view::PAD_X, y);
1296        let (viejo, _) = metrics().screen_to_pos(crate::view::PAD_X, y, 100);
1297        // ...pero doce píxeles más abajo el ojo ya está en la 101 y el viejo
1298        // sigue diciendo 100.
1299        assert_eq!(nuevo, 101);
1300        assert_eq!(viejo, 100);
1301    }
1302
1303    // ---------------------------------------------------------------
1304    // El ajuste de línea: la Disposicion deja de ser una fila por línea.
1305    // ---------------------------------------------------------------
1306
1307    /// Un buffer sintético: `largos[i]` chars en la línea `i`, con un espacio
1308    /// cada `paso` para que el corte por palabra tenga dónde caer.
1309    fn buffer(largos: &[usize], paso: usize) -> alloc::vec::Vec<String> {
1310        largos
1311            .iter()
1312            .map(|n| {
1313                (0..*n)
1314                    .map(|i| if paso > 0 && i > 0 && i % paso == 0 { ' ' } else { 'x' })
1315                    .collect()
1316            })
1317            .collect()
1318    }
1319
1320    fn con_ajuste(largos: &[usize], paso: usize, ancho: usize) -> Disposicion {
1321        let lineas = buffer(largos, paso);
1322        let mapa = MapaAjuste::construir(
1323            ancho,
1324            lineas.len(),
1325            |l| lineas[l].chars().count(),
1326            |l| lineas[l].clone(),
1327        );
1328        Disposicion::uniforme(lineas.len(), 18.2).con_ajuste(mapa.map(Arc::new))
1329    }
1330
1331    #[test]
1332    fn una_linea_larga_ocupa_varias_filas_y_las_cortas_una() {
1333        // Cuatro líneas de 5 chars y una de 100 sin blancos, a 20 columnas.
1334        let d = con_ajuste(&[5, 5, 100, 5, 5], 0, 20);
1335        assert!(d.ajustada());
1336        assert_eq!(d.lineas(), 5);
1337        // La de 100 entra en 5 filas de 20; las otras cuatro, una cada una.
1338        assert_eq!(d.filas(), 4 + 5);
1339        // Y el documento mide lo que ocupan las filas, no las líneas.
1340        assert!((d.alto_total() - 9.0 * 18.2).abs() < 0.1);
1341    }
1342
1343    #[test]
1344    fn sin_una_sola_linea_que_pase_el_ancho_no_hay_mapa() {
1345        // Un mapa identidad cuesta memoria y búsquedas binarias para no decir
1346        // nada: `construir` contesta `None` y el editor sigue por el camino de
1347        // siempre.
1348        let lineas = buffer(&[5, 9, 3], 4);
1349        assert!(MapaAjuste::construir(20, 3, |l| lineas[l].chars().count(), |l| lineas[l].clone())
1350            .is_none());
1351    }
1352
1353    #[test]
1354    fn la_ida_y_vuelta_sobrevive_al_ajuste() {
1355        let d = con_ajuste(&[5, 100, 7, 250, 5], 10, 20);
1356        // Toda fila cae en una posición, y esa posición vuelve a la fila. Es la
1357        // propiedad que sostiene el caret, el click y el scroll con ajuste.
1358        for f in 0..d.filas() {
1359            let fila = d.linea_de_fila(f);
1360            assert_eq!(d.fila_de_pos(fila.linea, fila.col_inicio), f, "fila {f} → {fila:?}");
1361        }
1362        // Y toda posición del buffer cae en la fila que la contiene.
1363        for l in 0..d.lineas() {
1364            for col in [0usize, 1, 19, 20, 21, 99] {
1365                let f = d.fila_de_pos(l, col);
1366                let fila = d.linea_de_fila(f);
1367                assert_eq!(fila.linea, l, "línea {l} col {col}");
1368                assert!(col < fila.col_inicio || fila.contiene_col(col) || fila.es_ultima());
1369            }
1370        }
1371    }
1372
1373    #[test]
1374    fn el_corte_cae_despues_del_ultimo_espacio_y_no_al_medio_de_una_palabra() {
1375        // "aaaa bbbb cccc dddd" con ancho 12: el corte no puede partir "cccc".
1376        let texto = String::from("aaaa bbbb cccc dddd");
1377        let mapa = MapaAjuste::construir(12, 1, |_| texto.chars().count(), |_| texto.clone())
1378            .expect("la línea pasa el ancho");
1379        let d = Disposicion::uniforme(1, 18.2).con_ajuste(Some(Arc::new(mapa)));
1380        assert_eq!(d.filas(), 2);
1381        // La segunda fila arranca en "cccc", que empieza en la columna 10.
1382        assert_eq!(d.linea_de_fila(1).col_inicio, 10);
1383        assert_eq!(texto_de_fila(&texto, d.linea_de_fila(0)), "aaaa bbbb ");
1384        assert_eq!(texto_de_fila(&texto, d.linea_de_fila(1)), "cccc dddd");
1385    }
1386
1387    #[test]
1388    fn una_linea_sin_un_solo_blanco_se_corta_duro() {
1389        // Un base64, un JSON minificado: no hay dónde cortar por palabra y
1390        // igual tiene que verse. Sin este caso, el `while` no termina.
1391        let d = con_ajuste(&[95], 0, 20);
1392        assert_eq!(d.filas(), 5);
1393        for f in 0..5 {
1394            assert_eq!(d.linea_de_fila(f).col_inicio, f * 20);
1395        }
1396    }
1397
1398    #[test]
1399    fn plegar_texto_ajustado_esconde_FILAS_y_no_lineas() {
1400        // El caso que rompe si los acumulados del plegado se llevan en líneas:
1401        // cinco líneas, la del medio ajustada a cuatro filas.
1402        let d = con_ajuste(&[5, 5, 35, 5, 5], 0, 10);
1403        assert_eq!(d.filas(), 4 + 4); // 4 cortas + 4 filas de la larga
1404        // Plegar de la 1 a la 3 esconde 1 + 4 + 1 = 6 FILAS, no 3 líneas.
1405        let plegada = con_ajuste(&[5, 5, 35, 5, 5], 0, 10)
1406            .con_pliegues(alloc::vec![TramoPlegado::nuevo(1, 3)]);
1407        assert_eq!(plegada.filas(), 8 - 6);
1408        // Y el mapa sigue siendo consistente en las dos direcciones.
1409        for f in 0..plegada.filas() {
1410            let fila = plegada.linea_de_fila(f);
1411            assert!(!plegada.esta_oculta(fila.linea));
1412            assert_eq!(plegada.fila_de_pos(fila.linea, fila.col_inicio), f);
1413        }
1414    }
1415
1416    #[test]
1417    fn el_orden_en_que_se_encadenan_ajuste_y_pliegue_da_el_mismo_mapa() {
1418        let lineas = buffer(&[5, 5, 35, 5, 5], 0);
1419        let mapa = Arc::new(
1420            MapaAjuste::construir(10, 5, |l| lineas[l].chars().count(), |l| lineas[l].clone())
1421                .unwrap(),
1422        );
1423        let tramos = alloc::vec![TramoPlegado::nuevo(1, 2)];
1424        let a = Disposicion::uniforme(5, 18.2)
1425            .con_ajuste(Some(mapa.clone()))
1426            .con_pliegues(tramos.clone());
1427        let b = Disposicion::uniforme(5, 18.2)
1428            .con_pliegues(tramos)
1429            .con_ajuste(Some(mapa));
1430        assert_eq!(a, b);
1431    }
1432
1433    #[test]
1434    fn un_mapa_de_otro_buffer_se_descarta_en_vez_de_mentir() {
1435        // Un mapa viejo no falla al aplicarse: falla tres cuadros después como
1436        // texto pintado en el renglón equivocado. Por eso se tira.
1437        let lineas = buffer(&[100, 100], 10);
1438        let mapa = Arc::new(
1439            MapaAjuste::construir(20, 2, |l| lineas[l].chars().count(), |l| lineas[l].clone())
1440                .unwrap(),
1441        );
1442        let d = Disposicion::uniforme(7, 18.2).con_ajuste(Some(mapa));
1443        assert!(!d.ajustada());
1444        assert_eq!(d.filas(), 7);
1445    }
1446
1447    #[test]
1448    fn el_caret_al_final_de_una_linea_ajustada_no_esta_en_la_fila_de_su_principio() {
1449        // El brazo de control: `fila_de_linea` —lo que preguntaba todo el
1450        // render hasta acá— contesta la fila del principio, y con ajuste eso
1451        // deja el caret cuatro renglones más arriba de donde se lo ve.
1452        let d = con_ajuste(&[100], 0, 20);
1453        assert_eq!(d.fila_de_linea(0), 0);
1454        assert_eq!(d.fila_de_pos(0, 0), 0);
1455        assert_eq!(d.fila_de_pos(0, 95), 4);
1456    }
1457
1458    #[test]
1459    fn el_hit_test_cae_en_la_columna_del_renglon_que_se_clickeo() {
1460        let lineas = buffer(&[100], 0);
1461        let d = con_ajuste(&[100], 0, 20);
1462        let m = Monoespaciada { char_width: 10.0 };
1463        let vp = Viewport::medido(220.0, 400.0, metrics());
1464        // Tercer renglón (fila 2 → columna 40), tres chars adentro.
1465        let y = crate::view::PAD_Y + 2.5 * 18.2;
1466        let x = crate::view::PAD_X + 35.0;
1467        let (l, c) = vp.pos_en(&d, |n| lineas[n].clone(), &m, x, y);
1468        assert_eq!(l, 0);
1469        assert_eq!(c, 44);
1470        // Y clickear más allá del final del renglón no salta al de abajo.
1471        let (_, c) = vp.pos_en(&d, |n| lineas[n].clone(), &m, crate::view::PAD_X + 9_999.0, y);
1472        assert_eq!(c, 60);
1473    }
1474
1475    #[test]
1476#[test]
1477    fn un_buffer_vacio_tiene_una_fila_y_no_cero() {
1478        let d = disp(0);
1479        assert_eq!(d.filas(), 1);
1480        assert_eq!(d.fila_en_y(0.0), 0);
1481        assert!((d.alto_total() - 18.2).abs() < 0.001);
1482    }
1483}