tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1//! 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}