//! La geometría del documento: dónde cae cada línea, cuánto mide el viewport y //! qué parte del buffer hay que materializar. //! //! Hasta acá el editor no tenía geometría: el `view` asumía una grilla de //! líneas de altura uniforme (`PAD_Y + n * line_height`) y el **viewport era un //! entero de líneas que contaba el host** —en `nada`, la constante //! `EDITOR_VISIBLE_LINES = 40`, calculada a mano contra una ventana de 760 px—. //! De esa única decisión salían seis cosas: el viewport no seguía a la ventana, //! el scroll era por línea entera y sin eje horizontal, el render tenía un tope //! duro (`.min(200)`) porque cada línea genera varias `View` y wgpu rechaza el //! bind group, y **plegado, ajuste de línea, líneas pegajosas e inlay hints //! eran imposibles**, no difíciles: los cuatro necesitan que la fila visual no //! sea la línea lógica y que una fila pueda medir distinto que otra. //! //! Este módulo introduce esa indirección. Hoy la [`Disposicion`] es la //! **identidad** —una línea es una fila, todas de la misma altura— y nada se ve //! distinto por eso; lo que cambia es que el resto del widget ya no habla de //! líneas cuando quiere decir filas. Plegado y ajuste de línea son otra //! implementación del mismo mapa, sin tocar a ningún llamador. //! //! ## Por qué el desplazamiento es un ancla y no un píxel absoluto //! //! Guardar el scroll como "píxel Y del documento" obliga a conocer el alto //! total para interpretarlo, y con filas de altura variable ese alto cambia //! cada vez que se pliega o se ajusta una línea: el mismo número pasaría a //! señalar otro lugar. El [`Desplazamiento`] es en cambio **una fila de ancla //! más un resto en píxeles dentro de ella**, que es lo que hace cualquier //! editor real: plegar algo más abajo no mueve lo que estás mirando. La //! conversión a píxel absoluto existe igual ([`Disposicion::y_de`]) porque la //! barra de scroll y el clamp la necesitan. //! //! Nada de acá usa `f32::floor`/`ceil`: no existen en `core` sin `libm`, y este //! widget entra a la jaula WASM y al kernel. Las divisiones se truncan con //! `as usize`, que para valores no negativos es lo mismo. use crate::view::EditorMetrics; use alloc::borrow::Cow; use alloc::string::String; use alloc::sync::Arc; // El macro `vec!` además del tipo: sin `std` no está en el preludio, y este // módulo lo usa diez veces. Faltaba, así que el crate no compilaba sin `std` // —ni el `llimphi-test` que lo arrastra por el grafo— y con `std` (el default // de las apps) el preludio lo tapaba. use alloc::vec; use alloc::vec::Vec; /// Tolerancia al dividir un píxel por el alto de fila. Absorbe el error de /// redondeo de `f32` acumulado en `fila * alto_fila` sin llegar a confundir dos /// filas vecinas: a 10.000 filas el error relativo de `f32` sigue muy por debajo /// de esto, y esto sigue muy por debajo de media fila. const EPS_FILA: f32 = 1e-4; /// Una fila visual: la unidad que el render dibuja y el mouse golpea. /// /// Sin ajuste de línea es una línea lógica entera (`col_inicio == 0`, /// `col_fin == None`). Con ajuste, una línea larga produce varias filas y cada /// una declara su tramo de columnas: `col_fin` es la columna donde arranca la /// fila siguiente, o `None` cuando la fila llega hasta el final de la línea. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Fila { /// Línea lógica del buffer a la que pertenece esta fila. pub linea: usize, /// Columna (en chars) por la que arranca la fila dentro de su línea. pub col_inicio: usize, /// Columna (exclusiva) donde termina la fila, o `None` si llega al final de /// la línea. pub col_fin: Option, } impl Fila { /// La fila que cubre una línea lógica entera. pub const fn entera(linea: usize) -> Self { Self { linea, col_inicio: 0, col_fin: None } } /// `true` si esta fila arranca la línea — la que lleva el número en el /// gutter. Las de continuación no lo llevan: repetir el mismo número en /// cuatro renglones seguidos es peor que dejarlos en blanco. pub const fn es_primera(&self) -> bool { self.col_inicio == 0 } /// `true` si esta fila es la última de su línea — la que se lleva el final /// del renglón (y el caret que cae más allá del texto). pub const fn es_ultima(&self) -> bool { self.col_fin.is_none() } /// `true` si la columna cae dentro del tramo de esta fila. pub fn contiene_col(&self, col: usize) -> bool { col >= self.col_inicio && self.col_fin.is_none_or(|f| col < f) } } /// La columna con la que se marcan las **filas de relleno** de /// [`MapaAjuste::con_piso`]: una que ninguna línea real alcanza, para que /// ninguna consulta de posición aterrice en ellas. pub(crate) const COL_FANTASMA: u32 = u32::MAX; /// El mapa de **ajuste de línea** (soft wrap): en cuántas filas se reparte cada /// línea lógica y por qué columna arranca cada una. /// /// Se construye una vez y se comparte con [`Arc`]: la [`Disposicion`] se arma /// en cada cuadro, y clonar un vector por línea ahí sería justo el costo que /// este módulo existe para evitar. Sólo guarda tramos de las líneas que **se /// parten**; si ninguna pasa el ancho, [`construir`](Self::construir) devuelve /// `None` y el editor sigue por el camino de la identidad sin pagar nada. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MapaAjuste { ancho_cols: usize, lineas: usize, /// `inicio[l]` = primera fila global de la línea `l`. `len = lineas + 1`, /// así que `inicio[lineas]` es el total de filas. inicio: Vec, /// `cortes[f]` = columna donde arranca la fila global `f`. cortes: Vec, } impl MapaAjuste { /// Arma el mapa para un buffer de `lineas` líneas ajustadas a `ancho_cols`. /// /// `largo_de` tiene que ser **barato** (en una soga es `O(log n)`): se lo /// llama una vez por línea y es lo que evita traer el texto del archivo /// entero. `texto_de` sólo se invoca para las líneas que de verdad no /// entran, que en código fuente son pocas. /// /// Devuelve `None` si no se partió ninguna línea: un mapa identidad cuesta /// memoria y búsquedas binarias para no decir nada. pub fn construir( ancho_cols: usize, lineas: usize, largo_de: impl Fn(usize) -> usize, texto_de: impl Fn(usize) -> String, ) -> Option { if ancho_cols == 0 || lineas == 0 { return None; } let mut inicio: Vec = Vec::with_capacity(lineas + 1); let mut cortes: Vec = Vec::with_capacity(lineas + 1); let mut partio = false; for l in 0..lineas { inicio.push(cortes.len() as u32); cortes.push(0); // El `+ 1` de holgura: `largo_de` suele contar el fin de línea, y // una línea de exactamente `ancho_cols` caracteres entra. if largo_de(l) > ancho_cols { for c in cortes_de_linea(&texto_de(l), ancho_cols) { cortes.push(c as u32); partio = true; } } } inicio.push(cortes.len() as u32); partio.then_some(Self { ancho_cols, lineas, inicio, cortes }) } /// **El mapa identidad**: una línea, una fila. Es el punto de partida para /// ponerle pisos a un buffer que no ajusta ninguna línea — sin esto, /// `construir` devuelve `None` (y hace bien: un mapa que no dice nada /// cuesta memoria y binarias) y no habría dónde colgar el relleno. pub fn identidad(lineas: usize) -> Option { if lineas == 0 { return None; } let inicio: Vec = (0..=lineas as u32).collect(); let cortes: Vec = vec![0; lineas]; Some(Self { ancho_cols: usize::MAX, lineas, inicio, cortes }) } /// **Filas de relleno al final de una línea**, para que ocupe al menos /// `pisos[l]`. /// /// Es lo que permite **alinear párrafo a párrafo entre columnas**: un /// párrafo que ajusta en dos filas y su traducción en cinco dejan de estar /// a distinta altura si el corto reserva las tres que le faltan. Sin eso, /// la cinta que los une sale de un bloque y entra a otro que empieza en /// otro renglón, y deja de leerse como continuidad — que es el pedido que /// lo originó (pluma, 2026-09-15). /// /// Las filas de relleno se marcan con una columna **imposible** /// ([`COL_FANTASMA`]) y no con el fin de la línea: así ninguna consulta de /// posición cae en ellas (`fila_de` busca la última fila cuyo corte sea /// ≤ la columna pedida), el caret no se va al hueco y lo único que hacen es /// ocupar alto. Su texto sale vacío por el mismo camino de siempre. pub fn con_piso(mut self, pisos: &[usize]) -> Self { if pisos.len() != self.lineas { return self; } let mut inicio: Vec = Vec::with_capacity(self.lineas + 1); let mut cortes: Vec = Vec::with_capacity(self.cortes.len()); for l in 0..self.lineas { inicio.push(cortes.len() as u32); let a = self.inicio(l); let b = self.inicio(l + 1); cortes.extend_from_slice(&self.cortes[a..b]); let reales = b - a; for _ in reales..pisos[l].max(reales) { cortes.push(COL_FANTASMA); } } inicio.push(cortes.len() as u32); self.inicio = inicio; self.cortes = cortes; self } /// El ancho (en columnas) contra el que se armó. pub fn ancho_cols(&self) -> usize { self.ancho_cols } /// Cuántas líneas lógicas describe. La [`Disposicion`] lo usa para /// **descartar un mapa viejo**: uno que no corresponde al buffer no se nota /// al aplicarlo, se nota como texto pintado en el renglón equivocado. pub fn lineas(&self) -> usize { self.lineas } /// Cuántas filas globales produce. pub fn filas(&self) -> usize { self.cortes.len() } fn inicio(&self, linea: usize) -> usize { self.inicio[linea.min(self.lineas)] as usize } /// Fila global donde cae la columna `col` de la línea `linea`. fn fila_de(&self, linea: usize, col: usize) -> usize { let l = linea.min(self.lineas.saturating_sub(1)); let a = self.inicio(l); let b = self.inicio(l + 1); let k = self.cortes[a..b].partition_point(|c| (*c as usize) <= col); a + k.saturating_sub(1) } /// La fila global `f`, resuelta a línea y tramo de columnas. fn fila_en(&self, f: usize) -> Fila { let f = f.min(self.filas().saturating_sub(1)); let linea = self.inicio.partition_point(|i| (*i as usize) <= f).saturating_sub(1); let col_inicio = self.cortes[f] as usize; let col_fin = (f + 1 < self.inicio(linea + 1)).then(|| self.cortes[f + 1] as usize); Fila { linea, col_inicio, col_fin } } } /// Las columnas donde se parte una línea, sin contar el 0. /// /// Corta **después del último espacio** que entra en el ancho: partir una /// palabra al medio se lee peor que dejar el renglón corto, y es lo que hace /// cualquier editor. Si en todo el tramo no hay un espacio, corta duro — una /// línea de cuatro mil caracteres sin un blanco existe (un base64, un JSON /// minificado) y tiene que verse igual de bien. fn cortes_de_linea(texto: &str, ancho: usize) -> Vec { let chars: Vec = texto.trim_end_matches(['\n', '\r']).chars().collect(); let mut out = Vec::new(); let mut ini = 0usize; while chars.len() - ini > ancho { let duro = ini + ancho; let corte = (ini + 1..=duro) .rev() .find(|i| chars[i - 1].is_whitespace()) .unwrap_or(duro); out.push(corte); ini = corte; } out } /// Índice en bytes de la columna `col` (en chars) de `texto`. Más allá del /// final devuelve el largo — cortar al final es siempre válido. pub fn byte_de_columna(texto: &str, col: usize) -> usize { texto.char_indices().nth(col).map_or(texto.len(), |(i, _)| i) } /// Un tramo de líneas **ocultas** por un pliegue. /// /// La línea que encabeza el bloque (la del `fn`, el `{`, el `
`) **no** se /// oculta: lo que se pliega es su cuerpo. Por eso el rango arranca en la línea /// siguiente a la cabecera. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TramoPlegado { /// Primera línea oculta. pub desde: usize, /// Última línea oculta, inclusive. pub hasta: usize, } impl TramoPlegado { pub const fn nuevo(desde: usize, hasta: usize) -> Self { Self { desde, hasta } } /// Cuántas líneas oculta. pub const fn ocultas(&self) -> usize { if self.hasta >= self.desde { self.hasta - self.desde + 1 } else { 0 } } pub const fn contiene(&self, linea: usize) -> bool { linea >= self.desde && linea <= self.hasta } /// La línea que encabeza el pliegue: la de arriba, la que sigue visible y /// lleva la flecha en el gutter. pub const fn cabecera(&self) -> usize { self.desde.saturating_sub(1) } } /// El mapa entre líneas lógicas, filas visuales y píxeles. /// /// Se construye por cuadro a partir del buffer y las métricas; no guarda estado /// del scroll. Sin pliegues ni ajuste de línea es la identidad y no cuesta nada /// armarla. Con cualquiera de los dos lleva además tablas de acumulados, que es /// lo que permite ir de línea a fila y de vuelta en `O(log n)` en vez de /// recorrer el archivo. /// /// Los dos se componen sobre la misma cuenta: **el ajuste reparte una línea en /// varias filas y el pliegue esconde filas**. Por eso los acumulados del /// plegado se llevan en filas (globales, pre-pliegue) y no en líneas — si se /// llevaran en líneas, plegar un bloque de texto ajustado correría el /// documento por la cantidad equivocada. #[derive(Debug, Clone, PartialEq)] pub struct Disposicion { lineas: usize, alto_fila: f32, /// El mapa de ajuste de línea, si está puesto. Sin él, una línea lógica es /// exactamente una fila global. ajuste: Option>, /// Tramos ocultos, **ordenados por `desde` y sin solaparse** — lo garantiza /// [`con_pliegues`](Self::con_pliegues), que es la única puerta de entrada. pliegues: Vec, /// `acum[i]` = filas ocultas por los tramos `0..i`. acum: Vec, /// `desde_fg[i]` = primera fila **global** que oculta el tramo `i`. desde_fg: Vec, /// `ocultas_fg[i]` = cuántas filas globales oculta el tramo `i`. ocultas_fg: Vec, /// `inicio_fila[i]` = la fila **visual** donde queda el hueco del tramo /// `i`. Es el índice para la vuelta (de fila a línea): sin él habría que /// buscar el acumulado adentro de la búsqueda, y una binaria adentro de /// otra es la clase de detalle que después nadie entiende. inicio_fila: Vec, } impl Disposicion { /// El mapa identidad: una línea es una fila, todas de `alto_fila` px. /// /// `alto_fila` se clampea a un mínimo positivo: un alto de fila cero o /// negativo haría divisiones por cero en todo el resto del módulo, y el /// origen más probable de ese valor es un `EditorMetrics` a medio construir, /// no una intención. pub fn uniforme(lineas: usize, alto_fila: f32) -> Self { Self { lineas, alto_fila: if alto_fila > 0.1 { alto_fila } else { 0.1 }, ajuste: None, pliegues: Vec::new(), acum: Vec::new(), desde_fg: Vec::new(), ocultas_fg: Vec::new(), inicio_fila: Vec::new(), } } /// La disposición que corresponde a un buffer de `lineas` líneas con estas /// métricas. Es el constructor que usa el render. pub fn para(lineas: usize, metrics: EditorMetrics) -> Self { Self::uniforme(lineas, metrics.line_height) } /// La misma disposición, con el ajuste de línea puesto. /// /// Un mapa que **no describe este buffer** (otra cantidad de líneas) se /// descarta en silencio y el editor cae al camino sin ajuste. Es deliberado: /// un mapa viejo no falla al aplicarse, falla tres cuadros después como /// texto pintado en el renglón equivocado, y eso no se diagnostica. pub fn con_ajuste(mut self, mapa: Option>) -> Self { self.ajuste = mapa.filter(|m| m.lineas() == self.lineas); self.reindexar(); self } /// `true` si hay ajuste de línea puesto. pub fn ajustada(&self) -> bool { self.ajuste.is_some() } /// La misma disposición, con tramos plegados. /// /// Los tramos entran **crudos**: se ordenan, se recortan al buffer y se /// **fusionan** los que se solapan o se tocan. Es deliberado que la /// normalización viva acá y no en el llamador — un solapamiento silencioso /// haría que el mismo `y` contestara dos filas distintas según por qué lado /// se preguntara, y ese tipo de error no se ve hasta que el caret aparece en /// otro renglón. pub fn con_pliegues(mut self, mut tramos: Vec) -> Self { let ultima = self.lineas.saturating_sub(1); tramos.retain(|t| t.desde <= t.hasta && t.desde <= ultima && t.desde > 0); for t in tramos.iter_mut() { t.hasta = t.hasta.min(ultima); } tramos.sort_by_key(|t| t.desde); let mut fusionados: Vec = Vec::with_capacity(tramos.len()); for t in tramos { match fusionados.last_mut() { // `+ 1` porque dos tramos pegados (uno termina donde el otro // empieza) son un solo hueco: dejarlos separados no cambia el // mapa pero sí duplica el trabajo de toda búsqueda. Some(prev) if t.desde <= prev.hasta + 1 => { prev.hasta = prev.hasta.max(t.hasta); } _ => fusionados.push(t), } } self.pliegues = fusionados; self.reindexar(); self } /// Recalcula los acumulados del plegado **en filas**. Lo llaman los dos /// constructores aditivos, así que el orden en que se encadenen /// (`con_ajuste().con_pliegues()` o al revés) da el mismo mapa. fn reindexar(&mut self) { let n = self.pliegues.len(); let mut acum = Vec::with_capacity(n); let mut desde_fg = Vec::with_capacity(n); let mut ocultas_fg = Vec::with_capacity(n); let mut inicio_fila = Vec::with_capacity(n); let mut suma = 0usize; for i in 0..n { let t = self.pliegues[i]; let ini = self.fg_borde(t.desde); let fin = self.fg_borde(t.hasta + 1); let ocultas = fin.saturating_sub(ini); acum.push(suma); desde_fg.push(ini); ocultas_fg.push(ocultas); inicio_fila.push(ini - suma); suma += ocultas; } self.acum = acum; self.desde_fg = desde_fg; self.ocultas_fg = ocultas_fg; self.inicio_fila = inicio_fila; } // -- Filas globales: el documento como si nada estuviera plegado. -------- /// Primera fila global de la línea, admitiendo `linea == lineas` como el /// borde del documento (que es lo que necesita el final de un tramo). fn fg_borde(&self, linea: usize) -> usize { match &self.ajuste { Some(m) => m.inicio(linea), None => linea.min(self.lineas), } } /// Fila global donde cae la columna `col` de la línea `linea`. fn fg_de_pos(&self, linea: usize, col: usize) -> usize { let l = linea.min(self.lineas.saturating_sub(1)); match &self.ajuste { Some(m) => m.fila_de(l, col), None => l, } } /// Cuántas filas globales tiene el documento sin plegar nada. fn fg_total(&self) -> usize { match &self.ajuste { Some(m) => m.filas(), None => self.lineas, } } /// La fila global `fg`, resuelta a línea y tramo de columnas. fn linea_de_fg(&self, fg: usize) -> Fila { match &self.ajuste { Some(m) => m.fila_en(fg), None => Fila::entera(fg.min(self.lineas.saturating_sub(1))), } } // -- El plegado, contado en filas. --------------------------------------- /// Los tramos plegados, ya normalizados. pub fn pliegues(&self) -> &[TramoPlegado] { &self.pliegues } /// El tramo que oculta esta línea, si hay alguno. pub fn pliegue_que_oculta(&self, linea: usize) -> Option<&TramoPlegado> { let i = self.pliegues.partition_point(|t| t.desde <= linea); self.pliegues.get(i.checked_sub(1)?).filter(|t| t.contiene(linea)) } /// `true` si la línea está escondida dentro de un pliegue. pub fn esta_oculta(&self, linea: usize) -> bool { self.pliegue_que_oculta(linea).is_some() } /// Cuántas filas quedan ocultas **antes** de la fila global `fg`. fn ocultas_antes_fg(&self, fg: usize) -> usize { let i = self.desde_fg.partition_point(|d| *d <= fg); match i.checked_sub(1) { None => 0, Some(j) => { // Si `fg` cae adentro del tramo, sólo cuentan las de arriba // suyo dentro de ese mismo tramo. let dentro = if fg < self.desde_fg[j] + self.ocultas_fg[j] { fg - self.desde_fg[j] } else { self.ocultas_fg[j] }; self.acum[j] + dentro } } } /// Cuántas filas hay ocultas en total. fn ocultas_totales_fg(&self) -> usize { match (self.acum.last(), self.ocultas_fg.last()) { (Some(a), Some(o)) => a + o, _ => 0, } } // -- La cara pública: filas visuales y píxeles. -------------------------- /// Cuántas filas visuales tiene el documento. Un buffer vacío tiene una /// fila (la línea vacía donde vive el caret), no cero. pub fn filas(&self) -> usize { self.fg_total().saturating_sub(self.ocultas_totales_fg()).max(1) } /// Cuántas líneas lógicas tiene el buffer que describe. pub fn lineas(&self) -> usize { self.lineas } /// Alto de una fila cualquiera. Con filas de altura variable dejará de ser /// constante; los llamadores ya preguntan por fila. pub fn alto_de(&self, _fila: usize) -> f32 { self.alto_fila } /// Alto total del documento en píxeles. pub fn alto_total(&self) -> f32 { self.filas() as f32 * self.alto_fila } /// Y (px, desde el tope del documento) donde arranca la fila. pub fn y_de(&self, fila: usize) -> f32 { fila.min(self.filas()) as f32 * self.alto_fila } /// La fila que contiene el píxel `y` del documento. Se clampea al rango /// válido: un `y` más allá del final devuelve la última fila. /// /// El `EPS_FILA` no es cosmético: `y_de(250)` con `alto_fila = 18.2` da /// 4549.9995 en `f32`, y dividirlo de vuelta da 249.99997, que truncado es /// **249**. Sin el epsilon, ir a una fila y volver a preguntar en qué fila /// estás se corre una hacia arriba — y el scroll se arrastraría un renglón /// cada vez que pasa por un pixel absoluto (la barra, el clamp, un salto). pub fn fila_en_y(&self, y: f32) -> usize { if y <= 0.0 { return 0; } ((y / self.alto_fila + EPS_FILA) as usize).min(self.filas().saturating_sub(1)) } /// La fila visual donde arranca una línea lógica. Una línea **oculta** /// contesta la fila de la cabecera de su pliegue: es donde el ojo la /// encuentra, y es lo que hace que llevar el caret a una línea plegada lo /// lleve al pliegue en vez de a un lugar que no se ve. pub fn fila_de_linea(&self, linea: usize) -> usize { self.fila_de_pos(linea, 0) } /// La fila visual donde cae una **posición** del buffer. Con ajuste de /// línea, dos columnas de la misma línea pueden estar en filas distintas — /// y por eso el caret, la selección y los diagnostics preguntan por acá y /// no por [`fila_de_linea`](Self::fila_de_linea). pub fn fila_de_pos(&self, linea: usize, col: usize) -> usize { let linea = linea.min(self.lineas.saturating_sub(1)); let (linea, col) = match self.pliegue_que_oculta(linea) { Some(t) => (t.cabecera(), 0), None => (linea, col), }; let fg = self.fg_de_pos(linea, col); fg.saturating_sub(self.ocultas_antes_fg(fg)).min(self.filas().saturating_sub(1)) } /// La línea lógica (y el tramo de columnas) de una fila visual. pub fn linea_de_fila(&self, fila: usize) -> Fila { let fila = fila.min(self.filas().saturating_sub(1)); if self.pliegues.is_empty() { return self.linea_de_fg(fila); } // El último tramo cuyo hueco ya quedó por encima de `fila`: todo lo que // ocultan él y los anteriores se le suma a la fila para volver a la // fila global. let i = self.inicio_fila.partition_point(|inicio| *inicio <= fila); let saltadas = match i.checked_sub(1) { None => 0, Some(j) => self.acum[j] + self.ocultas_fg[j], }; self.linea_de_fg((fila + saltadas).min(self.fg_total().saturating_sub(1))) } } /// Dónde está parado el scroll: una fila de ancla y el resto en píxeles dentro /// de ella, más el desplazamiento horizontal. /// /// `fino_px ∈ [0, alto_de(fila_ancla))`. Fuera de ese rango el desplazamiento /// sigue siendo interpretable, pero [`normalizar`](Desplazamiento::normalizar) /// lo vuelve canónico moviendo el ancla — que es lo que hace el scroll suave /// cuando el resto se pasa de una fila. #[derive(Debug, Clone, Copy, PartialEq, Default)] pub struct Desplazamiento { /// Fila visual que toca el borde superior del viewport. pub fila_ancla: usize, /// Cuántos píxeles de esa fila quedaron por encima del borde. pub fino_px: f32, /// Desplazamiento horizontal en píxeles (0 = pegado a la izquierda). pub x_px: f32, } impl Desplazamiento { /// El desplazamiento equivalente a estar en la fila `f`, sin resto. pub const fn en_fila(f: usize) -> Self { Self { fila_ancla: f, fino_px: 0.0, x_px: 0.0 } } /// Y absoluto (px desde el tope del documento) del borde superior. pub fn y_documento(&self, d: &Disposicion) -> f32 { d.y_de(self.fila_ancla) + self.fino_px } /// El desplazamiento que deja el borde superior en el píxel `y` del /// documento. Es la vuelta de [`y_documento`](Self::y_documento) y la que /// usa el arrastre de la barra de scroll, que piensa en absoluto. pub fn desde_y(y: f32, x_px: f32, d: &Disposicion) -> Self { let y = y.max(0.0); let fila = d.fila_en_y(y); Self { fila_ancla: fila, fino_px: (y - d.y_de(fila)).max(0.0), x_px } } /// Reacomoda el ancla para que el resto vuelva a caer dentro de su fila. /// Es lo que convierte un delta de rueda en píxeles en un ancla válida. pub fn normalizar(self, d: &Disposicion) -> Self { Self::desde_y(self.y_documento(d), self.x_px, d) } } /// El rectángulo por el que se mira el documento. /// /// `ancho_px`/`alto_px` son **medidos**, no supuestos: el host los publica con /// un `llimphi_widget_scroll::TamanoVisible` desde el `paint_with` del editor y /// los lee al cuadro siguiente. Mientras valen `0.0` —el primer cuadro, y cada /// resize— el viewport [cae en un valor de arranque](Viewport::filas_completas) /// en vez de mentir. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Viewport { pub ancho_px: f32, pub alto_px: f32, pub desplazamiento: Desplazamiento, pub metrics: EditorMetrics, /// Cuántas filas de aire dejar entre el caret y el borde al auto-scrollear /// (el *scrolloff* de un IDE). `0` pega el caret al borde, que es lo que /// hacía el editor hasta acá. pub margen_filas: usize, } /// Filas que se asumen mientras no hay medida. No es un viewport: es el valor /// del primer cuadro —y de cada resize— hasta que el `paint_with` publique el /// rect real. /// /// Es **generoso a propósito**. El error tiene dos lados y no son simétricos: /// pintar de más se recorta y no se ve, pintar de menos deja media pantalla en /// fondo vacío hasta el cuadro siguiente. Y el cuadro siguiente no siempre /// llega solo: el bucle Elm repinta cuando hay un `Msg`, así que un editor /// abierto y quieto se quedaría con el piso puesto. Doscientas filas es, /// además, exactamente lo que materializaba el camino viejo con su tope. pub const FILAS_SIN_MEDIDA: usize = 200; impl Viewport { /// Un viewport medido en píxeles. pub fn medido(ancho_px: f32, alto_px: f32, metrics: EditorMetrics) -> Self { Self { ancho_px: ancho_px.max(0.0), alto_px: alto_px.max(0.0), desplazamiento: Desplazamiento::default(), metrics, margen_filas: 0, } } /// El puente para los llamadores viejos, que cuentan líneas en vez de medir /// píxeles: reconstruye un alto a partir de `visible_lines`. Existe para que /// las firmas históricas del widget sigan andando sin cambios; **no lo uses /// en código nuevo** — pedirle al host que cuente líneas es exactamente el /// error que este módulo viene a sacar. pub fn por_lineas(visible_lines: usize, metrics: EditorMetrics) -> Self { Self::medido(0.0, visible_lines.max(1) as f32 * metrics.line_height, metrics) } pub fn con_desplazamiento(mut self, d: Desplazamiento) -> Self { self.desplazamiento = d; self } pub fn con_margen(mut self, filas: usize) -> Self { self.margen_filas = filas; self } /// Cuántas filas **enteras** entran. Es el número que reemplaza a la /// constante que contaba el host, y el que usan página-arriba/abajo y el /// auto-scroll. Nunca es 0: un viewport de menos de una fila igual muestra /// una, recortada. pub fn filas_completas(&self, d: &Disposicion) -> usize { if self.alto_px <= 0.0 { return FILAS_SIN_MEDIDA; } let alto_fila = d.alto_de(0); ((self.alto_px / alto_fila) as usize).max(1) } /// Cuántas filas hay que **materializar**: las enteras más la que asoma /// recortada arriba y la que asoma abajo. Es lo que evita el borde vacío /// cuando el scroll cae entre dos filas. pub fn filas_a_materializar(&self, d: &Disposicion) -> usize { self.filas_completas(d) + 2 } /// El rango `[primera, ultima)` de filas que hay que dibujar, y cuántos /// píxeles de la primera quedan por encima del borde (siempre ≤ 0, para /// sumarlo directo a la Y de dibujo). /// /// Esto es la virtualización: el costo del render pasa a depender del **alto /// del viewport** y no del tamaño del archivo, que es lo que vuelve /// innecesarios los topes de 200 y 512 líneas. pub fn rango_visible(&self, d: &Disposicion) -> RangoVisible { let desp = self.desplazamiento.normalizar(d); let primera = desp.fila_ancla.min(d.filas().saturating_sub(1)); let ultima = (primera + self.filas_a_materializar(d)).min(d.filas()); RangoVisible { primera, ultima, recorte_px: -desp.fino_px } } /// Máximo desplazamiento vertical en píxeles: el que deja el final del /// documento tocando el borde inferior. /// /// Es la misma definición que `llimphi_widget_scroll::max_offset`, y tiene /// que serlo: el pulgar de la barra se dimensiona con **esa**, así que si /// acá el tope fuera otro, arrastrar la barra hasta el fondo y seguir con la /// rueda darían resultados distintos — el pulgar clavado abajo y el texto /// todavía moviéndose. /// /// El camino viejo (`EditorState::scroll_by`) clampeaba a `line_count - 1`, /// o sea permitía dejar una sola línea a la vista con el resto del panel en /// blanco. Eso era un artefacto de contar líneas enteras, no una decisión; /// `scroll_by` lo conserva para los consumidores que lo usan. pub fn max_y(&self, d: &Disposicion) -> f32 { (d.alto_total() - self.alto_visible(d)).max(0.0) } /// Aplica un delta vertical **en píxeles** y devuelve el desplazamiento /// clampeado. La rueda, el arrastre de la barra y la inercia entran todos /// por acá. pub fn desplazar_y(&self, d: &Disposicion, delta_px: f32) -> Desplazamiento { let y = (self.desplazamiento.y_documento(d) + delta_px).clamp(0.0, self.max_y(d)); Desplazamiento::desde_y(y, self.desplazamiento.x_px, d) } /// Aplica un delta horizontal en píxeles, clampeado a `[0, max_x]`. /// /// `ancho_contenido_px` lo calcula el llamador (la línea más larga por el /// ancho de char); mientras no haya medida de ancho del viewport se deja /// pasar cualquier valor no negativo, por el mismo motivo por el que /// `offset_efectivo` de `llimphi-widget-scroll` no clampea sin medida. pub fn desplazar_x(&self, delta_px: f32, ancho_contenido_px: f32) -> f32 { let x = (self.desplazamiento.x_px + delta_px).max(0.0); if self.ancho_px <= 0.0 { return x; } x.min((ancho_contenido_px - self.ancho_px).max(0.0)) } /// El desplazamiento que deja la fila `fila` dentro del viewport, /// respetando [`margen_filas`](Self::margen_filas). Si ya está adentro, no /// se mueve — un auto-scroll que reencuadra en cada tecla marea. pub fn asegurar_fila_visible(&self, d: &Disposicion, fila: usize) -> Desplazamiento { let alto_fila = d.alto_de(fila); let margen = self.margen_filas as f32 * alto_fila; let y_actual = self.desplazamiento.y_documento(d); let tope = d.y_de(fila) - margen; let fondo = d.y_de(fila) + alto_fila + margen - self.alto_visible(d); let y = if tope < y_actual { tope } else if fondo > y_actual { fondo } else { y_actual }; Desplazamiento::desde_y(y.clamp(0.0, self.max_y(d)), self.desplazamiento.x_px, d) } /// De un píxel del área de contenido a `(línea, columna)` del buffer. /// /// Es la **inversa exacta** del render, y tiene que serlo: el click cae /// donde el ojo ve el carácter, no donde estaría si el scroll fuera por /// líneas enteras. `EditorMetrics::screen_to_pos` —la versión vieja— divide /// por una grilla y suma `scroll_offset`, así que ignora el resto sub-fila y /// el desplazamiento horizontal: con scroll fino, erra una línea. /// /// `local_x`/`local_y` se miden desde el borde del área de texto (la que el /// widget publica en su [`TamanoVisible`](llimphi_widget_scroll::TamanoVisible)), /// no desde el gutter. pub fn pos_en( &self, d: &Disposicion, texto_de_linea: impl Fn(usize) -> String, medidor: &dyn MedidorTexto, local_x: f32, local_y: f32, ) -> (usize, usize) { let y_doc = self.desplazamiento.y_documento(d) + (local_y - crate::view::PAD_Y); let fila = d.fila_en_y(y_doc.max(0.0)); let f = d.linea_de_fila(fila); let x = (local_x + self.desplazamiento.x_px - crate::view::PAD_X).max(0.0); let texto = texto_de_linea(f.linea); let tramo = texto_de_fila(&texto, f); // El clamp al final del tramo es lo que hace que clickear a la derecha // de una fila ajustada deje el caret en ese renglón y no en el que // sigue: sin él, `columna_en_x` sobre un tramo corto contesta una // columna que ya pertenece a la fila de abajo. let tope = f.col_fin.unwrap_or(usize::MAX); (f.linea, (f.col_inicio + medidor.columna_en_x(&tramo, x)).min(tope)) } /// El alto que se usa para las cuentas de encuadre. Sin medida todavía, se /// deriva de [`FILAS_SIN_MEDIDA`] en vez de valer cero — con cero, el /// `fondo` de `asegurar_fila_visible` empujaría el scroll al final en la /// primera tecla. fn alto_visible(&self, d: &Disposicion) -> f32 { if self.alto_px > 0.0 { self.alto_px } else { FILAS_SIN_MEDIDA as f32 * d.alto_de(0) } } } /// Qué filas dibujar y cuánto correr la primera hacia arriba. #[derive(Debug, Clone, Copy, PartialEq)] pub struct RangoVisible { /// Primera fila a materializar (inclusive). pub primera: usize, /// Última fila a materializar (exclusive). pub ultima: usize, /// Píxeles a sumar a la Y de dibujo — negativo o cero: la parte de la /// primera fila que quedó por encima del borde. pub recorte_px: f32, } impl RangoVisible { pub fn cuantas(&self) -> usize { self.ultima.saturating_sub(self.primera) } } /// Quién sabe cuánto mide un texto en píxeles. /// /// El editor asume desde siempre una fuente monoespaciada /// (`char_width = font_size * 0.6`), y con eso el caret y la selección se /// desalinean en cuanto hay CJK o una proporcional. Esto es la **costura** para /// arreglarlo sin arreglarlo todavía: hoy la única implementación es la de /// siempre, y quien quiera medir de verdad con parley enchufa otra sin tocar el /// render. Cambiar el default es su propio trabajo — toca a los nueve /// consumidores del widget. pub trait MedidorTexto { /// Ancho en píxeles de `texto`. fn ancho(&self, texto: &str) -> f32; /// X (px) donde arranca la columna `col` de `texto`. fn x_de_columna(&self, texto: &str, col: usize) -> f32; /// Columna cuyo centro queda más cerca de `x`. Es el hit-test. fn columna_en_x(&self, texto: &str, x: f32) -> usize; } /// La medición de siempre: todos los chars miden `char_width`. #[derive(Debug, Clone, Copy)] pub struct Monoespaciada { pub char_width: f32, } impl Monoespaciada { pub fn de(metrics: EditorMetrics) -> Self { Self { char_width: if metrics.char_width > 0.01 { metrics.char_width } else { 0.01 } } } } impl MedidorTexto for Monoespaciada { fn ancho(&self, texto: &str) -> f32 { texto.chars().count() as f32 * self.char_width } fn x_de_columna(&self, _texto: &str, col: usize) -> f32 { col as f32 * self.char_width } fn columna_en_x(&self, _texto: &str, x: f32) -> usize { if x <= 0.0 { return 0; } // Redondeo al char más cercano: clickear en la mitad derecha de un // carácter pone el caret a su derecha, como en cualquier editor. ((x / self.char_width) + 0.5) as usize } } /// El texto de una fila, tal como el render lo necesita: la línea entera sin /// su fin de línea, o el tramo que le toca cuando la línea está ajustada. pub fn texto_de_fila<'a>(linea: &'a str, fila: Fila) -> Cow<'a, str> { let limpio = linea.trim_end_matches(['\n', '\r']); if fila.es_primera() && fila.es_ultima() { return Cow::Borrowed(limpio); } let ini = byte_de_columna(limpio, fila.col_inicio); let fin = fila.col_fin.map_or(limpio.len(), |c| byte_de_columna(limpio, c)); Cow::Borrowed(&limpio[ini..fin.max(ini)]) } /// Cuántas columnas entran en el ancho medido — el ancho contra el que se /// ajustan las líneas. /// /// Devuelve `None` mientras no hay medida: ajustar contra un ancho inventado /// pondría los cortes en otro lado que el texto, y un cuadro después habría que /// rehacerlos igual. El piso de 8 columnas es para que un panel apretado no /// produzca un mapa de una letra por renglón. pub fn ancho_ajuste_cols(ancho_px: f32, metrics: EditorMetrics) -> Option { let util = ancho_px - crate::view::PAD_X * 2.0; if util <= 0.0 || metrics.char_width <= 0.01 { return None; } Some(((util / metrics.char_width) as usize).max(8)) } /// Ancho en píxeles de la línea más larga — el "contenido" del scroll /// horizontal. Se mide sobre las filas visibles y no sobre el archivo entero: un /// barrido completo por cuadro es justamente el costo que este módulo evita, y /// el único efecto de la aproximación es que el tope del scroll horizontal se /// ajusta a medida que se navega. pub fn ancho_contenido(lineas: impl Iterator, medidor: &dyn MedidorTexto) -> f32 { let mut max: f32 = 0.0; for l in lineas { let w = medidor.ancho(l.trim_end_matches(['\n', '\r'])); if w > max { max = w; } } max } #[cfg(test)] mod tests { use super::*; fn metrics() -> EditorMetrics { EditorMetrics::for_font_size(13.0) // line_height = 18.2 } fn disp(lineas: usize) -> Disposicion { Disposicion::para(lineas, metrics()) } #[test] fn el_viewport_sigue_al_alto_medido_y_no_a_una_constante() { let d = disp(1000); // El caso que fallaba: una ventana alta mostraba 40 líneas y punto. let alto = 1400.0; let vp = Viewport::medido(900.0, alto, metrics()); assert_eq!(vp.filas_completas(&d), 76); // 1400 / 18.2 = 76.9 // Y una ventana chica muestra menos, en vez de cortar por abajo. let chico = Viewport::medido(900.0, 300.0, metrics()); assert_eq!(chico.filas_completas(&d), 16); // 300 / 18.2 = 16.4 } #[test] fn sin_medida_no_miente_cero() { let d = disp(1000); let vp = Viewport::medido(0.0, 0.0, metrics()); assert_eq!(vp.filas_completas(&d), FILAS_SIN_MEDIDA); } #[test] fn ida_y_vuelta_linea_fila_y_pixel() { let d = disp(500); for linea in [0usize, 1, 17, 250, 499] { let fila = d.fila_de_linea(linea); let y = d.y_de(fila); assert_eq!(d.fila_en_y(y), fila); // Un píxel adentro de la fila sigue cayendo en la misma fila. assert_eq!(d.fila_en_y(y + d.alto_de(fila) - 0.01), fila); assert_eq!(d.linea_de_fila(fila).linea, linea); } // Barrido completo: ninguna fila puede perderse en el redondeo. for fila in 0..d.filas() { assert_eq!(d.fila_en_y(d.y_de(fila)), fila, "fila {fila}"); } } #[test] fn el_desplazamiento_sobrevive_la_ida_y_vuelta_a_pixel_absoluto() { let d = disp(200); let desp = Desplazamiento { fila_ancla: 30, fino_px: 7.5, x_px: 40.0 }; let y = desp.y_documento(&d); let vuelta = Desplazamiento::desde_y(y, 40.0, &d); assert_eq!(vuelta.fila_ancla, 30); assert!((vuelta.fino_px - 7.5).abs() < 0.001); assert_eq!(vuelta.x_px, 40.0); } #[test] fn el_scroll_fino_reacomoda_el_ancla_al_pasarse_de_fila() { let d = disp(200); // Un resto mayor que la fila no es un estado inválido: se normaliza. let desp = Desplazamiento { fila_ancla: 10, fino_px: 40.0, x_px: 0.0 }; let n = desp.normalizar(&d); assert_eq!(n.fila_ancla, 12); // 40 px = 2 filas de 18.2 + 3.6 assert!((n.fino_px - 3.6).abs() < 0.01); } #[test] fn la_rueda_mueve_pixeles_no_lineas_enteras() { let d = disp(200); let vp = Viewport::medido(800.0, 400.0, metrics()); let desp = vp.desplazar_y(&d, 5.0); // Cinco píxeles NO son una línea: antes, cualquier delta menor a una // línea o no hacía nada o saltaba entera. assert_eq!(desp.fila_ancla, 0); assert!((desp.fino_px - 5.0).abs() < 0.001); } #[test] fn el_scroll_vertical_se_clampea_a_los_dos_extremos() { let d = disp(50); let vp = Viewport::medido(800.0, 400.0, metrics()); assert_eq!(vp.desplazar_y(&d, -1000.0), Desplazamiento::en_fila(0)); let abajo = vp.desplazar_y(&d, 99_999.0); assert!((abajo.y_documento(&d) - vp.max_y(&d)).abs() < 0.001); // El final del documento queda tocando el borde de abajo: 50 líneas de // 18,2 px son 910, menos los 400 del panel son 510 px, que caen en la // fila 28. Ni una fila más — scrollear al vacío no es una feature. assert_eq!(abajo.fila_ancla, 28); assert!((d.alto_total() - abajo.y_documento(&d) - 400.0).abs() < 0.01); } #[test] fn el_rango_visible_no_crece_con_el_archivo() { let d_chico = disp(100); let d_enorme = disp(1_000_000); let vp = Viewport::medido(800.0, 400.0, metrics()); // 400 / 18.2 = 21 enteras + 2 de borde. assert_eq!(vp.rango_visible(&d_chico).cuantas(), 23); assert_eq!(vp.rango_visible(&d_enorme).cuantas(), 23); } #[test] fn el_rango_visible_arranca_en_el_ancla_y_declara_su_recorte() { let d = disp(1000); let vp = Viewport::medido(800.0, 400.0, metrics()) .con_desplazamiento(Desplazamiento { fila_ancla: 100, fino_px: 6.0, x_px: 0.0 }); let r = vp.rango_visible(&d); assert_eq!(r.primera, 100); assert!((r.recorte_px - -6.0).abs() < 0.001); } #[test] fn asegurar_visible_no_mueve_lo_que_ya_se_ve() { let d = disp(1000); let vp = Viewport::medido(800.0, 400.0, metrics()) .con_desplazamiento(Desplazamiento::en_fila(50)); assert_eq!(vp.asegurar_fila_visible(&d, 55), Desplazamiento::en_fila(50)); } #[test] fn asegurar_visible_respeta_el_margen_arriba_y_abajo() { let d = disp(1000); let vp = Viewport::medido(800.0, 400.0, metrics()) .con_margen(3) .con_desplazamiento(Desplazamiento::en_fila(50)); // Hacia arriba: deja 3 filas de aire por encima del caret. let arriba = vp.asegurar_fila_visible(&d, 50); assert_eq!(arriba.fila_ancla, 47); // Hacia abajo: el caret no se pega al borde inferior. let abajo = vp.asegurar_fila_visible(&d, 71); let ultima_visible = abajo.y_documento(&d) + vp.alto_px; assert!(d.y_de(71) + d.alto_de(71) + 3.0 * d.alto_de(71) <= ultima_visible + 0.01); } #[test] fn el_scroll_horizontal_se_clampea_al_contenido() { let d = disp(10); let _ = d; let vp = Viewport::medido(500.0, 400.0, metrics()); assert_eq!(vp.desplazar_x(-50.0, 1200.0), 0.0); assert_eq!(vp.desplazar_x(99_999.0, 1200.0), 700.0); // 1200 - 500 // Contenido más angosto que el viewport: no hay nada que desplazar. assert_eq!(vp.desplazar_x(300.0, 200.0), 0.0); } #[test] fn la_medicion_mono_redondea_al_char_mas_cercano() { let m = Monoespaciada { char_width: 10.0 }; assert_eq!(m.columna_en_x("hola", 0.0), 0); assert_eq!(m.columna_en_x("hola", 4.0), 0); // mitad izquierda assert_eq!(m.columna_en_x("hola", 6.0), 1); // mitad derecha assert_eq!(m.columna_en_x("hola", 25.0), 3); assert_eq!(m.ancho("hola"), 40.0); assert_eq!(m.x_de_columna("hola", 2), 20.0); } #[test] fn el_ancho_de_contenido_ignora_el_fin_de_linea() { let m = Monoespaciada { char_width: 10.0 }; let w = ancho_contenido( ["ab\n".into(), "abcde\n".into(), "a".into()].into_iter(), &m, ); assert_eq!(w, 50.0); } // --------------------------------------------------------------- // El plegado: la Disposicion deja de ser la identidad. // --------------------------------------------------------------- #[test] fn plegar_saca_filas_del_documento() { let d = disp(100).con_pliegues(vec![TramoPlegado::nuevo(10, 19)]); // Diez líneas ocultas: diez filas menos, y el documento mide menos. assert_eq!(d.filas(), 90); assert_eq!(d.lineas(), 100); assert!((d.alto_total() - 90.0 * 18.2).abs() < 0.1); } #[test] fn la_ida_y_vuelta_sobrevive_a_los_pliegues() { let d = disp(200).con_pliegues(vec![ TramoPlegado::nuevo(10, 19), TramoPlegado::nuevo(50, 51), TramoPlegado::nuevo(120, 180), ]); // Toda línea visible tiene una fila, y esa fila vuelve a ella. Es la // propiedad que sostiene el caret, el click y el scroll con pliegues. for l in 0..d.lineas() { if d.esta_oculta(l) { continue; } let f = d.fila_de_linea(l); assert_eq!(d.linea_de_fila(f).linea, l, "línea {l} → fila {f} → otra línea"); } // Y toda fila cae en una línea visible. for f in 0..d.filas() { let l = d.linea_de_fila(f).linea; assert!(!d.esta_oculta(l), "la fila {f} apunta a la línea oculta {l}"); assert_eq!(d.fila_de_linea(l), f); } } #[test] fn una_linea_oculta_contesta_la_fila_de_su_cabecera() { let d = disp(100).con_pliegues(vec![TramoPlegado::nuevo(10, 19)]); // La cabecera es la 9 — la del `fn`, la que sigue a la vista. let cabecera = d.fila_de_linea(9); // Cualquier línea de adentro contesta esa misma fila: llevar el caret a // algo plegado lo lleva al pliegue, no a un lugar invisible. for l in 10..=19 { assert_eq!(d.fila_de_linea(l), cabecera, "línea {l}"); } // Y la primera visible después del pliegue es la siguiente fila. assert_eq!(d.fila_de_linea(20), cabecera + 1); } #[test] fn los_tramos_se_normalizan_al_entrar() { // Desordenados, solapados, pegados, fuera de rango y uno inválido. let d = disp(50).con_pliegues(vec![ TramoPlegado::nuevo(30, 35), TramoPlegado::nuevo(10, 14), TramoPlegado::nuevo(12, 20), // solapa con el anterior TramoPlegado::nuevo(21, 22), // pegado al fusionado TramoPlegado::nuevo(40, 999), // se recorta al buffer TramoPlegado::nuevo(8, 3), // al revés: se descarta TramoPlegado::nuevo(0, 2), // sin cabecera posible: se descarta ]); let p = d.pliegues(); assert_eq!(p.len(), 3, "{p:?}"); assert_eq!(p[0], TramoPlegado::nuevo(10, 22)); assert_eq!(p[1], TramoPlegado::nuevo(30, 35)); assert_eq!(p[2], TramoPlegado::nuevo(40, 49)); // Y con todo eso el mapa sigue siendo consistente. for f in 0..d.filas() { assert_eq!(d.fila_de_linea(d.linea_de_fila(f).linea), f); } } #[test] fn sin_pliegues_es_exactamente_la_identidad_de_antes() { let d = disp(500); assert!(d.pliegues().is_empty()); for l in 0..500 { assert_eq!(d.fila_de_linea(l), l); assert_eq!(d.linea_de_fila(l).linea, l); } } #[test] fn plegar_todo_el_cuerpo_deja_al_menos_una_fila() { // Un archivo entero plegado bajo su primera línea: queda la cabecera. let d = disp(100).con_pliegues(vec![TramoPlegado::nuevo(1, 99)]); assert_eq!(d.filas(), 1); assert_eq!(d.linea_de_fila(0).linea, 0); } #[test] fn el_pixel_de_una_fila_plegada_es_el_de_su_lugar_nuevo() { let d = disp(100).con_pliegues(vec![TramoPlegado::nuevo(10, 19)]); // La línea 20 subió diez lugares: su píxel es el de la fila 10. assert!((d.y_de(d.fila_de_linea(20)) - 10.0 * 18.2).abs() < 0.1); assert_eq!(d.fila_en_y(d.y_de(10)), 10); } #[test] fn el_viewport_cuenta_filas_visibles_y_no_lineas() { let d = disp(1000).con_pliegues(vec![TramoPlegado::nuevo(1, 900)]); let vp = Viewport::medido(800.0, 400.0, metrics()); // Quedan 100 filas; el documento entra casi entero y el scroll casi no // tiene a dónde ir. assert_eq!(d.filas(), 100); assert!((vp.max_y(&d) - (100.0 * 18.2 - 400.0)).abs() < 0.1); } #[test] fn el_hit_test_es_la_inversa_exacta_del_render() { let d = disp(1000); let m = Monoespaciada::de(metrics()); let texto = |_n: usize| String::from("una linea cualquiera de texto"); // Sin scroll: el píxel del tope del área es la primera línea. let vp = Viewport::medido(800.0, 400.0, metrics()); assert_eq!(vp.pos_en(&d, texto, &m, crate::view::PAD_X, crate::view::PAD_Y).0, 0); // Con scroll fino, el mismo píxel cae en la fila que el render dibuja // ahí. El render pone la fila `primera` en `PAD_Y - fino_px`, así que // el píxel `PAD_Y` ya está adentro de ella mientras `fino_px > 0`. let vp = vp.con_desplazamiento(Desplazamiento { fila_ancla: 100, fino_px: 9.0, x_px: 0.0 }); let (linea, _) = vp.pos_en(&d, texto, &m, crate::view::PAD_X, crate::view::PAD_Y); assert_eq!(linea, 100); // Un píxel más abajo del resto de esa fila ya es la siguiente. let (linea, _) = vp.pos_en(&d, texto, &m, crate::view::PAD_X, crate::view::PAD_Y + 10.0); assert_eq!(linea, 101); } #[test] fn el_hit_test_sigue_al_scroll_horizontal() { let d = disp(10); let m = Monoespaciada { char_width: 10.0 }; let texto = |_n: usize| String::from("0123456789abcdef"); let vp = Viewport::medido(200.0, 400.0, metrics()) .con_desplazamiento(Desplazamiento { fila_ancla: 0, fino_px: 0.0, x_px: 100.0 }); // Clickear en el borde izquierdo con 100 px de scroll horizontal cae en // la columna 10, no en la 0. let (_, col) = vp.pos_en(&d, texto, &m, crate::view::PAD_X, crate::view::PAD_Y); assert_eq!(col, 10); } #[test] fn el_hit_test_viejo_erra_con_scroll_fino_y_el_nuevo_no() { // El brazo de control de la regla 8: el instrumento tiene que fallar en // el estado que falla. Con el ancla en 100 y 9 px de resto, el render // dibuja la línea 100 arriba del todo; el camino viejo, que sólo sabe de // `scroll_offset`, contesta lo mismo hasta acá... let d = disp(1000); let m = Monoespaciada::de(metrics()); let texto = |_n: usize| String::from("x"); let vp = Viewport::medido(800.0, 400.0, metrics()) .con_desplazamiento(Desplazamiento { fila_ancla: 100, fino_px: 9.0, x_px: 0.0 }); let y = crate::view::PAD_Y + 12.0; let (nuevo, _) = vp.pos_en(&d, texto, &m, crate::view::PAD_X, y); let (viejo, _) = metrics().screen_to_pos(crate::view::PAD_X, y, 100); // ...pero doce píxeles más abajo el ojo ya está en la 101 y el viejo // sigue diciendo 100. assert_eq!(nuevo, 101); assert_eq!(viejo, 100); } // --------------------------------------------------------------- // El ajuste de línea: la Disposicion deja de ser una fila por línea. // --------------------------------------------------------------- /// Un buffer sintético: `largos[i]` chars en la línea `i`, con un espacio /// cada `paso` para que el corte por palabra tenga dónde caer. fn buffer(largos: &[usize], paso: usize) -> alloc::vec::Vec { largos .iter() .map(|n| { (0..*n) .map(|i| if paso > 0 && i > 0 && i % paso == 0 { ' ' } else { 'x' }) .collect() }) .collect() } fn con_ajuste(largos: &[usize], paso: usize, ancho: usize) -> Disposicion { let lineas = buffer(largos, paso); let mapa = MapaAjuste::construir( ancho, lineas.len(), |l| lineas[l].chars().count(), |l| lineas[l].clone(), ); Disposicion::uniforme(lineas.len(), 18.2).con_ajuste(mapa.map(Arc::new)) } #[test] fn una_linea_larga_ocupa_varias_filas_y_las_cortas_una() { // Cuatro líneas de 5 chars y una de 100 sin blancos, a 20 columnas. let d = con_ajuste(&[5, 5, 100, 5, 5], 0, 20); assert!(d.ajustada()); assert_eq!(d.lineas(), 5); // La de 100 entra en 5 filas de 20; las otras cuatro, una cada una. assert_eq!(d.filas(), 4 + 5); // Y el documento mide lo que ocupan las filas, no las líneas. assert!((d.alto_total() - 9.0 * 18.2).abs() < 0.1); } #[test] fn sin_una_sola_linea_que_pase_el_ancho_no_hay_mapa() { // Un mapa identidad cuesta memoria y búsquedas binarias para no decir // nada: `construir` contesta `None` y el editor sigue por el camino de // siempre. let lineas = buffer(&[5, 9, 3], 4); assert!(MapaAjuste::construir(20, 3, |l| lineas[l].chars().count(), |l| lineas[l].clone()) .is_none()); } #[test] fn la_ida_y_vuelta_sobrevive_al_ajuste() { let d = con_ajuste(&[5, 100, 7, 250, 5], 10, 20); // Toda fila cae en una posición, y esa posición vuelve a la fila. Es la // propiedad que sostiene el caret, el click y el scroll con ajuste. for f in 0..d.filas() { let fila = d.linea_de_fila(f); assert_eq!(d.fila_de_pos(fila.linea, fila.col_inicio), f, "fila {f} → {fila:?}"); } // Y toda posición del buffer cae en la fila que la contiene. for l in 0..d.lineas() { for col in [0usize, 1, 19, 20, 21, 99] { let f = d.fila_de_pos(l, col); let fila = d.linea_de_fila(f); assert_eq!(fila.linea, l, "línea {l} col {col}"); assert!(col < fila.col_inicio || fila.contiene_col(col) || fila.es_ultima()); } } } #[test] fn el_corte_cae_despues_del_ultimo_espacio_y_no_al_medio_de_una_palabra() { // "aaaa bbbb cccc dddd" con ancho 12: el corte no puede partir "cccc". let texto = String::from("aaaa bbbb cccc dddd"); let mapa = MapaAjuste::construir(12, 1, |_| texto.chars().count(), |_| texto.clone()) .expect("la línea pasa el ancho"); let d = Disposicion::uniforme(1, 18.2).con_ajuste(Some(Arc::new(mapa))); assert_eq!(d.filas(), 2); // La segunda fila arranca en "cccc", que empieza en la columna 10. assert_eq!(d.linea_de_fila(1).col_inicio, 10); assert_eq!(texto_de_fila(&texto, d.linea_de_fila(0)), "aaaa bbbb "); assert_eq!(texto_de_fila(&texto, d.linea_de_fila(1)), "cccc dddd"); } #[test] fn una_linea_sin_un_solo_blanco_se_corta_duro() { // Un base64, un JSON minificado: no hay dónde cortar por palabra y // igual tiene que verse. Sin este caso, el `while` no termina. let d = con_ajuste(&[95], 0, 20); assert_eq!(d.filas(), 5); for f in 0..5 { assert_eq!(d.linea_de_fila(f).col_inicio, f * 20); } } #[test] fn plegar_texto_ajustado_esconde_FILAS_y_no_lineas() { // El caso que rompe si los acumulados del plegado se llevan en líneas: // cinco líneas, la del medio ajustada a cuatro filas. let d = con_ajuste(&[5, 5, 35, 5, 5], 0, 10); assert_eq!(d.filas(), 4 + 4); // 4 cortas + 4 filas de la larga // Plegar de la 1 a la 3 esconde 1 + 4 + 1 = 6 FILAS, no 3 líneas. let plegada = con_ajuste(&[5, 5, 35, 5, 5], 0, 10) .con_pliegues(alloc::vec![TramoPlegado::nuevo(1, 3)]); assert_eq!(plegada.filas(), 8 - 6); // Y el mapa sigue siendo consistente en las dos direcciones. for f in 0..plegada.filas() { let fila = plegada.linea_de_fila(f); assert!(!plegada.esta_oculta(fila.linea)); assert_eq!(plegada.fila_de_pos(fila.linea, fila.col_inicio), f); } } #[test] fn el_orden_en_que_se_encadenan_ajuste_y_pliegue_da_el_mismo_mapa() { let lineas = buffer(&[5, 5, 35, 5, 5], 0); let mapa = Arc::new( MapaAjuste::construir(10, 5, |l| lineas[l].chars().count(), |l| lineas[l].clone()) .unwrap(), ); let tramos = alloc::vec![TramoPlegado::nuevo(1, 2)]; let a = Disposicion::uniforme(5, 18.2) .con_ajuste(Some(mapa.clone())) .con_pliegues(tramos.clone()); let b = Disposicion::uniforme(5, 18.2) .con_pliegues(tramos) .con_ajuste(Some(mapa)); assert_eq!(a, b); } #[test] fn un_mapa_de_otro_buffer_se_descarta_en_vez_de_mentir() { // Un mapa viejo no falla al aplicarse: falla tres cuadros después como // texto pintado en el renglón equivocado. Por eso se tira. let lineas = buffer(&[100, 100], 10); let mapa = Arc::new( MapaAjuste::construir(20, 2, |l| lineas[l].chars().count(), |l| lineas[l].clone()) .unwrap(), ); let d = Disposicion::uniforme(7, 18.2).con_ajuste(Some(mapa)); assert!(!d.ajustada()); assert_eq!(d.filas(), 7); } #[test] fn el_caret_al_final_de_una_linea_ajustada_no_esta_en_la_fila_de_su_principio() { // El brazo de control: `fila_de_linea` —lo que preguntaba todo el // render hasta acá— contesta la fila del principio, y con ajuste eso // deja el caret cuatro renglones más arriba de donde se lo ve. let d = con_ajuste(&[100], 0, 20); assert_eq!(d.fila_de_linea(0), 0); assert_eq!(d.fila_de_pos(0, 0), 0); assert_eq!(d.fila_de_pos(0, 95), 4); } #[test] fn el_hit_test_cae_en_la_columna_del_renglon_que_se_clickeo() { let lineas = buffer(&[100], 0); let d = con_ajuste(&[100], 0, 20); let m = Monoespaciada { char_width: 10.0 }; let vp = Viewport::medido(220.0, 400.0, metrics()); // Tercer renglón (fila 2 → columna 40), tres chars adentro. let y = crate::view::PAD_Y + 2.5 * 18.2; let x = crate::view::PAD_X + 35.0; let (l, c) = vp.pos_en(&d, |n| lineas[n].clone(), &m, x, y); assert_eq!(l, 0); assert_eq!(c, 44); // Y clickear más allá del final del renglón no salta al de abajo. let (_, c) = vp.pos_en(&d, |n| lineas[n].clone(), &m, crate::view::PAD_X + 9_999.0, y); assert_eq!(c, 60); } #[test] #[test] fn un_buffer_vacio_tiene_una_fila_y_no_cero() { let d = disp(0); assert_eq!(d.filas(), 1); assert_eq!(d.fila_en_y(0.0), 0); assert!((d.alto_total() - 18.2).abs() < 0.001); } }