//! Render del editor. Layout: gutter izquierdo (line numbers) + área //! principal (texto + selección como rects + caret bloque). El scroll //! vertical es implícito por viewport — el caller decide cuántas líneas //! caben en el `height` que pasa. //! //! Limitaciones del PMV de render: //! - **Char width fijo** — asume fuente monoespaciada y un ancho de //! carácter en píxeles fijo. Para CJK / proportional el caret y la //! selección se desalinean. Para texto ASCII monoespaciado es exacto. //! - **Selección multilínea** se pinta como un rect por línea afectada //! (sin "rio" continuo); estilo Sublime Text / antiguo, lectura clara. //! - **Sin syntax highlight todavía** — eso vive en su propio bloque y //! requiere `llimphi-text` rich (Vec); aquí cada línea va //! monocolor `fg_text`. use alloc::borrow::Cow; use alloc::string::{String, ToString}; use alloc::vec; use alloc::sync::Arc; use alloc::vec::Vec; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style}, AlignItems, }; use llimphi_compositor::peniko::Color; use llimphi_compositor::llimphi_text::Alignment; use llimphi_compositor::View; use crate::cursor::Pos; use crate::diagnostics::{Diagnostic, Severity}; use crate::geometria::{texto_de_fila, Fila, Monoespaciada, Viewport}; use llimphi_widget_scroll::TamanoVisible; use crate::highlight::{Language, Span, SyntaxPalette, TokenKind}; use crate::state::EditorState; /// Tope de líneas que la variante embebida (`text_editor_view_colored`) /// renderiza de una. La virtualización del editor-de-archivos capa a 200 para /// no generar miles de Views (wgpu rechaza el bind group); pero la variante /// embebida deja el scroll al contenedor de afuera y necesita pintar TODAS sus /// líneas (si no, la mitad de abajo queda sin pintar = negro al anclar el panel /// al fondo). Este tope es sólo la red de seguridad de wgpu — el caller acota el /// total real (el shell, por su `MAX_VISIBLE = 400`). Probado: ~400 líneas /// renderizan sin que wgpu rechace nada (el render plano viejo ya lo hacía). pub const EMBEDDED_LINE_CAP: usize = 512; /// Paleta del editor. Defaults dark. #[derive(Debug, Clone, Copy)] pub struct EditorPalette { pub bg: Color, pub bg_gutter: Color, pub bg_selection: Color, pub bg_current_line: Color, pub fg_text: Color, pub fg_line_number: Color, pub fg_line_number_active: Color, pub caret: Color, /// Fondo del bracket bajo el cursor + su par. Un acento sutil. pub bg_bracket_pair: Color, /// Fondo de cada match del find activo. pub bg_match: Color, /// Subrayado de diagnostic — Error. pub diag_error: Color, /// Subrayado de diagnostic — Warning. pub diag_warning: Color, /// Subrayado de diagnostic — Information. pub diag_info: Color, /// Subrayado de diagnostic — Hint. pub diag_hint: Color, } impl Default for EditorPalette { fn default() -> Self { Self::from_theme(&llimphi_theme::Theme::dark()) } } impl EditorPalette { pub fn from_theme(t: &llimphi_theme::Theme) -> Self { // Reutilizamos slots del theme; los que no existen como semánticos // se derivan con `mix`/transparencia conceptual. Self { bg: t.bg_input, bg_gutter: t.bg_panel, bg_selection: t.bg_selected, bg_current_line: t.bg_panel_alt, fg_text: t.fg_text, fg_line_number: t.fg_muted, fg_line_number_active: t.fg_text, caret: t.accent, bg_bracket_pair: t.bg_button_hover, bg_match: t.bg_button_hover, diag_error: t.fg_destructive, diag_warning: Color::from_rgb8(229, 192, 123), diag_info: Color::from_rgb8(97, 175, 239), diag_hint: t.fg_muted, } } } /// Cómo renderizar la columna izquierda del editor. /// /// - [`GutterStyle::Numbers`] es el comportamiento clásico de IDE: /// "1", "2", "3"… alineados a la derecha del gutter. /// - [`GutterStyle::Phantom`] suprime los números y dibuja en su lugar /// un tick **muy sutil** por línea (un pequeño segmento horizontal /// con baja opacidad). Sirve para prosa narrativa donde el número de /// línea es ruido — la línea sigue estando, pero "fingiendo no /// estar". El gutter en este modo se acorta a un sliver fino. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum GutterStyle { #[default] Numbers, Phantom, /// Sin gutter: ancho 0, nada dibujado. Para vistas tipo terminal donde /// numerar líneas es ruido. None, } /// Métricas del editor — todo derivado del `font_size`. Cambiar la /// fuente requiere recalcular `char_width` empíricamente para la mono /// que use llimphi-text; los valores aquí son razonables para /// `font_size = 12` con la mono default de parley. #[derive(Debug, Clone, Copy, PartialEq)] pub struct EditorMetrics { pub font_size: f32, /// Alto de cada línea en píxeles (font_size * line_height_ratio). pub line_height: f32, /// Ancho promedio de un char (mono). Si la fuente no es mono, esto /// es sólo una aproximación. pub char_width: f32, /// Ancho del gutter (incluye padding interno). pub gutter_width: f32, /// Cómo se pinta el gutter. Default [`GutterStyle::Numbers`] — el /// comportamiento clásico se conserva para callers existentes. pub gutter_style: GutterStyle, /// **Una línea vacía en el medio de la selección se pinta hasta el margen** /// en vez de con el chorrito de un carácter que marca el salto de línea. /// /// En un editor de código el chorrito es lo correcto: las líneas en blanco /// son pocas y se leen como lo que son. En uno de **prosa** cada párrafo /// está separado por una línea en blanco, así que seleccionar cuatro /// párrafos pintaba cuatro franjas sueltas con un hilito entre medio — el /// operador lo reportó el 2026-09-14 como «sólo se selecciona un /// interlineado, las líneas impares». Opt-in: el default es el chorrito. pub seleccion_hasta_el_margen: bool, /// Si `true`, cada línea **guarda** (índices en /// `EditorState::guard_lines`) recibe un segmento horizontal con /// baja opacidad atravesando su centro — un divisor fantasma que /// sugiere "aquí termina un bloque" sin gritar. Sin guardas, esto /// no hace nada visible. Default `false`: comportamiento IDE /// clásico. pub phantom_guard_lines: bool, } impl Default for EditorMetrics { fn default() -> Self { Self::for_font_size(12.0) } } impl EditorMetrics { pub const fn for_font_size(font_size: f32) -> Self { Self { font_size, line_height: font_size * 1.4, char_width: font_size * 0.6, gutter_width: font_size * 3.5, gutter_style: GutterStyle::Numbers, phantom_guard_lines: false, seleccion_hasta_el_margen: false, } } /// Variante "prosa": gutter fantasma (ticks sutiles, sin números) + /// divisores fantasma en cada guarda. Ancho del gutter reducido a /// un sliver porque ya no necesita acomodar dígitos. /// /// Pensado para editores narrativos tipo `cuerpo_ide` donde el /// número de línea es ruido y las junctions están marcadas como /// guardas. pub const fn prosa(font_size: f32) -> Self { Self { font_size, line_height: font_size * 1.4, char_width: font_size * 0.6, gutter_width: font_size * 1.0, gutter_style: GutterStyle::Phantom, phantom_guard_lines: true, seleccion_hasta_el_margen: false, } } /// Variante "terminal": sin gutter (ancho 0, sin números ni ticks) — para /// pintar la salida de un PTY como texto plano donde numerar es ruido. pub const fn terminal(font_size: f32) -> Self { Self { font_size, line_height: font_size * 1.4, char_width: font_size * 0.6, gutter_width: 0.0, gutter_style: GutterStyle::None, phantom_guard_lines: false, seleccion_hasta_el_margen: false, } } /// Ver [`Self::seleccion_hasta_el_margen`]: la selección de una línea vacía /// intermedia se lee como un bloque y no como un hilo. pub const fn con_seleccion_hasta_el_margen(mut self, on: bool) -> Self { self.seleccion_hasta_el_margen = on; self } /// Convierte coords locales del **área de contenido** (no del gutter) /// a `(line, col)` absolutas en el buffer. `local_x` se mide desde el /// borde izquierdo del área de texto (sin el padding interno de 4 px); /// `local_y` desde la primera línea visible. /// /// Devuelve coordenadas siempre dentro del buffer — el caller /// generalmente las pasa a `EditorState::set_caret_at` que clampea /// `col` al ancho real de la línea. pub fn screen_to_pos(self, local_x: f32, local_y: f32, scroll_offset: usize) -> (usize, usize) { let line_local = ((local_y - PAD_Y).max(0.0) / self.line_height) as usize; let col = redondear(((local_x - PAD_X).max(0.0)) / self.char_width) as usize; (scroll_offset + line_local, col) } } /// Sangría izquierda del área de texto (px): aire entre el gutter y el /// primer carácter. El caret, la selección, el preedit y `screen_to_pos` /// usan la misma constante para no desalinearse. /// Sangría del texto respecto del borde del panel. Público porque el tope del /// scroll horizontal lo calcula el host (ver `ancho_del_texto` en pluma) y /// tiene que sumar el mismo margen que el dibujo. pub const PAD_X: f32 = 10.0; /// Aire vertical arriba de la primera línea (px). El gutter lo respeta para /// que los números queden a la par del texto. /// /// **Pública** desde el 2026-09-14: quien pinta ENCIMA del editor —un overlay, /// una banda que tiene que empezar donde empieza el renglón— necesita el mismo /// origen que el texto, y sin esto lo adivina. Pluma la adivinó en cero y sus /// bandas salían 7 px arriba del tinte de la línea con la que tenían que /// alinearse. pub const PAD_Y: f32 = 7.0; /// Margen izquierdo del número de línea (px) — separa del borde del panel. const GUTTER_PAD_L: f32 = 6.0; /// Respiro entre el número de línea y el texto (px). const GUTTER_PAD_R: f32 = 8.0; /// X (px, dentro del área de contenido) del carácter en la columna `col`. #[inline] fn text_x(col: usize, m: EditorMetrics) -> f32 { PAD_X + col as f32 * m.char_width } /// Y (px) del tope de la línea `local_line` (relativa al viewport). #[inline] fn text_y(local_line: usize, m: EditorMetrics) -> f32 { PAD_Y + local_line as f32 * m.line_height } /// Render principal sin syntax highlight — todas las líneas visibles /// en `palette.fg_text`. `visible_lines` es cuántas líneas mostrar como /// máximo en el viewport. /// /// `on_pointer` se invoca con el evento del mouse dentro del área de /// texto (no del gutter): el caller decide cómo mover el caret / /// extender selección. Ver [`PointerEvent`]. pub fn text_editor_view( state: &EditorState, palette: &EditorPalette, metrics: EditorMetrics, visible_lines: usize, on_pointer: impl Fn(PointerEvent) -> Option + Send + Sync + Clone + 'static, ) -> View { text_editor_view_highlighted( state, palette, metrics, visible_lines, Language::Plain, on_pointer, ) } /// Evento de mouse que el view envía al caller dentro del área de texto. /// El caller convierte `(x, y)` con [`EditorMetrics::screen_to_pos`] y /// aplica `set_caret_at` (Click) o `extend_selection_to` (Drag). /// /// `Drag` entrega `initial` (pos del press inicial, constante durante el /// drag) + `delta` (delta desde el evento anterior). El caller debe /// acumular el delta — el view no mantiene state. Patrón típico: /// `accum += (dx, dy); actual = (initial_x + accum.0, initial_y + accum.1)`. #[derive(Debug, Clone, Copy)] pub enum PointerEvent { Click { x: f32, y: f32 }, /// Un paso del arrastre. `initial_*` es **dónde se apretó** (fijo durante /// todo el gesto) y `dx`/`dy` son el delta **de este paso**, no el /// acumulado — es la convención de `draggable_at` en toda la casa. El /// consumidor acumula: `pos = initial + Σ delta`. Drag { initial_x: f32, initial_y: f32, dx: f32, dy: f32 }, /// **Se soltó el botón.** Cierra el gesto: es donde el consumidor pone a /// cero su acumulador. Sin esto no había forma de saber que un arrastre /// terminó, y el siguiente arrancaba con el recorrido del anterior encima. DragEnd, } /// Override de estilo de texto sobre un rango de **columnas-char** /// `[start_col, end_col)` de una línea. A diferencia de [`Span`] (que sólo /// lleva una `TokenKind` → color), un `StyledSpan` lleva el set completo de /// propiedades rich-text que el caller quiera pisar: color de glifo, color de /// fondo (resaltado), familia, tamaño, peso, itálica, subrayado y tachado. /// Cada campo es opcional — `None` hereda del default del editor. /// /// El widget traduce internamente las columnas-char a offsets de byte (que es /// lo que consume `text_spans`/parley) y pinta el `bg` como rect por debajo /// del texto (parley no lleva fondo por span). Pensado para editores de prosa /// que estilan zonas o la selección (pluma multilienzo); no se combina con el /// syntax highlight por `Language` — es una vía de pintado paralela. #[derive(Debug, Clone, Default, PartialEq)] pub struct StyledSpan { pub start_col: usize, pub end_col: usize, pub fg: Option, pub bg: Option, pub font_family: Option, pub size_px: Option, pub weight: Option, pub italic: Option, pub underline: Option, pub strikethrough: Option, } /// Como [`text_editor_view_highlighted`] pero el caller provee, por línea, un /// conjunto de [`StyledSpan`] con estilo rich-text completo (color/fondo/ /// fuente/tamaño/peso/itálica/subrayado/tachado). `styled_per_line[n]` son los /// spans de la línea `n` del buffer (índice absoluto). Las líneas sin spans se /// pintan planas en `palette.fg_text`. Va por encima del syntax highlight: si /// pasas spans, mandan ellos (el caso de pluma, que es prosa sin lenguaje). pub fn text_editor_view_styled( state: &EditorState, palette: &EditorPalette, metrics: EditorMetrics, visible_lines: usize, styled_per_line: &[Vec], match_ranges: &[(usize, usize)], on_pointer: impl Fn(PointerEvent) -> Option + Send + Sync + Clone + 'static, ) -> View { let caret = state.cursor.caret; let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark()); let visible = visible_lines.max(1).min(200); let line_count = state.line_count(); let scroll = state.scroll_offset.min(line_count.saturating_sub(1)); let end_line = (scroll + visible).min(line_count); let height = (end_line - scroll) as f32 * metrics.line_height; // El camino viejo no conoce pliegues: su ventana es contigua, y la // disposición sin pliegues es exactamente la identidad de siempre. let disp_plana = crate::geometria::Disposicion::para(line_count, metrics); let ventana = Ventana::nueva(&disp_plana, scroll, end_line.saturating_sub(scroll)); let gutter = build_gutter(state, &ventana, caret.line, metrics, palette, 0.0, None); let content = build_content( state, palette, metrics, height, &ventana, Vec::new(), &syntax, match_ranges, None, Some(styled_per_line), Ayudas::default(), Desplazamiento2D::default(), None, None, on_pointer, ); View::new(Style { flex_direction: FlexDirection::Row, flex_grow: 1.0, size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, min_size: Size { width: auto(), height: length(height) }, ..Default::default() }) .fill(palette.bg) .clip(true) // Editor de texto multilínea: los lectores cambian de modo de navegación // con este rol (por línea/palabra) en vez de leerlo como un bloque. No // mandamos el contenido en `value` a propósito: puede ser un archivo // entero, y el buffer se navega por el árbol, no por el nombre. .role(llimphi_compositor::Role::MultilineTextInput) .aria_label("Editor de texto") .children(vec![gutter, content]) } /// Render con syntax highlight + **viewport scrolling**: sólo se renderizan /// las líneas en `[state.scroll_offset, scroll_offset + visible_lines)`. /// /// `visible_lines` es cuántas líneas máximo dibujamos por frame; el caller /// se asegura de tener un container con altura ≥ `visible_lines * line_height` /// o aplica clip propio. Para archivos grandes (1000+ líneas), el cap es /// crítico — sin él generaríamos miles de Views y wgpu rechazaría el bind /// group por `max_*_buffer_binding_size`. /// /// Recomendación para el caller: tras cada edición, llamar a /// [`EditorState::ensure_caret_visible`] con el mismo `visible_lines` para /// que el viewport siga al caret. pub fn text_editor_view_highlighted( state: &EditorState, palette: &EditorPalette, metrics: EditorMetrics, visible_lines: usize, language: Language, on_pointer: impl Fn(PointerEvent) -> Option + Send + Sync + Clone + 'static, ) -> View { text_editor_view_full( state, palette, metrics, visible_lines, language, &[], on_pointer, ) } /// Qué pintar además del texto, y dónde publicar la medida. /// /// Existe para que [`text_editor_view_viewport`] no tenga ocho argumentos /// sueltos: todo lo opcional entra por acá con `Default`. pub struct OpcionesRender<'a, Msg> { /// Lenguaje para el resaltado. `Plain` = texto monocolor. pub language: Language, /// Ocurrencias del find activo a resaltar `(char_start, char_end)`. pub match_ranges: &'a [(usize, usize)], /// **Spans rich-text por línea** (color, fondo, fuente, tamaño, peso, /// itálica): la vía de pluma para estilar zonas y selección de prosa. /// Cuando es `Some`, ganan sobre el resaltado de sintaxis. /// /// Existía sólo en el camino viejo ([`text_editor_view_styled`]), y eso /// dejaba a una app de prosa eligiendo entre **estilos** y **ajuste de /// línea** — porque el ajuste vive en la disposición, que sólo conoce este /// camino. Pluma tenía que quedarse con los estilos, así que su texto no /// ajustaba: un párrafo largo se cortaba contra el borde de la columna. pub styled: Option<&'a [Vec]>, /// Dónde publicar el tamaño real del área de contenido. **Pasalo**: sin /// medida el viewport no sabe cuántas filas entran y cae en /// [`FILAS_SIN_MEDIDA`](crate::geometria::FILAS_SIN_MEDIDA), que es un piso /// para el primer cuadro y no un tamaño. pub medida: Option<&'a TamanoVisible>, /// Qué `Msg` emitir para desplazar el documento, con el delta **en píxeles** /// del documento. Lo usan la barra de posición (arrastre del pulgar y salto /// de página al clickear el canal) y la rueda cuando el cursor está sobre el /// editor. /// /// Sin esto la barra se dibuja pero no se agarra, y la rueda depende de que /// el host la rutee por su `on_wheel` global — que es lo que hacían todos /// los consumidores, y significa que el editor no scrollea si vive adentro /// de un contenedor que atiende la rueda antes que él. pub on_scroll: Option Option + Send + Sync>>, /// Lo mismo que [`Self::on_scroll`] pero en el eje **horizontal**: delta en /// píxeles, positivo hacia la derecha. Lo usan la barra de abajo y la rueda /// lateral (el `dx` del trackpad, y el que el compositor sintetiza cuando /// se gira con Shift). /// /// Sin esto el eje horizontal existe en la geometría y no se puede mover: /// apagar el ajuste de línea deja el texto cortado contra el borde y sin /// ninguna forma de llegar al resto. Lo pagó pluma hasta el 2026-09-16. pub on_scroll_x: Option Option + Send + Sync>>, /// **El botón derecho sobre el texto**, con coordenadas de VENTANA (las que /// quiere un menú para anclarse donde está el puntero). /// /// Sin esto el right-click no lo contesta nadie dentro del editor y lo /// termina atendiendo la raíz de la app — que funciona para un editor solo, /// pero en un multilienzo abre el menú sobre el lienzo ACTIVO aunque uno /// haya apuntado a la columna de al lado. pub on_menu: Option Option + Send + Sync>>, /// Qué `Msg` emitir al clickear la flecha de plegado del gutter, con la /// línea que encabeza el bloque. Sin esto no se dibujan flechas: un botón /// que no hace nada es peor que no tenerlo. pub on_pliegue: Option Option + Send + Sync>>, /// Cuántas líneas de **contexto** pegar arriba de todo: el `impl`, el `fn`, /// la clave del YAML de la que es lo que se está mirando. `0` = ninguna. /// /// Se quedan las de **adentro** cuando hay más ancestros que lugar: el `fn` /// en el que estás importa más que el `mod` de arriba de todo. pub pegajosas: usize, /// Qué `Msg` emitir al clickear una línea pegajosa, con su número de línea. /// Sin esto la banda se dibuja pero no lleva a ningún lado. pub on_pegajosa: Option Option + Send + Sync>>, /// Rayitas verticales en cada nivel de sangría. Ayudan a ver de qué bloque /// es una línea sin contar espacios con el dedo. pub guias_de_sangria: bool, /// Columna donde pintar el **margen**: la raya vertical que marca hasta /// dónde conviene que llegue una línea. `None` = sin margen. pub margen_columna: Option, } impl Default for OpcionesRender<'_, Msg> { // A mano y no por `derive`: `Language` no implementa `Default` (vive en el // núcleo agnóstico y no le corresponde elegir un lenguaje por omisión), y // `derive` además exigiría `Msg: Default`, que no viene al caso. fn default() -> Self { Self { language: Language::Plain, match_ranges: &[], styled: None, medida: None, on_scroll: None, on_scroll_x: None, on_menu: None, on_pliegue: None, pegajosas: 0, on_pegajosa: None, guias_de_sangria: false, margen_columna: None, } } } /// El render con **viewport medido**: la variante que hay que usar. /// /// A diferencia de las funciones que le siguen —que reciben `visible_lines` y /// asumen que el host contó bien—, ésta se dibuja contra un [`Viewport`] en /// píxeles y virtualiza por rango: sólo materializa las filas que caen adentro, /// más una arriba y otra abajo para que el borde no quede vacío al scrollear /// entre dos filas. El costo del render pasa a depender del **alto del panel** y /// no del tamaño del archivo — que es lo que vuelve innecesarios los topes de /// 200 y 512 líneas que había que poner porque wgpu rechazaba el bind group. /// /// El desplazamiento fino (sub-fila) y el horizontal **no** se aplican línea por /// línea: se aplican una vez, a la capa que contiene a todos los hijos. Por eso /// las veinte funciones de dibujo siguen posicionando en coordenadas locales a /// la primera fila materializada, sin enterarse de que existe un scroll. pub fn text_editor_view_viewport( state: &EditorState, palette: &EditorPalette, vp: &Viewport, opciones: OpcionesRender<'_, Msg>, on_pointer: impl Fn(PointerEvent) -> Option + Send + Sync + Clone + 'static, ) -> View { let metrics = vp.metrics; let caret = state.cursor.caret; let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark()); let disp = state.disposicion(metrics); let rango = vp.rango_visible(&disp); // La ventana habla de FILAS y traduce a líneas. Antes esto era // `scroll..end_line`, un rango de líneas consecutivas — que con un pliegue // en el medio pinta las líneas escondidas y corre todo lo de abajo. let ventana = Ventana::nueva(&disp, rango.primera, rango.cuantas()); // Ancho del contenido: se mide sobre lo visible y no sobre el archivo // entero. Un barrido completo por cuadro es justo el costo que este camino // evita; el único efecto es que el tope del scroll horizontal se ajusta a // medida que se navega. let medidor = Monoespaciada::de(metrics); // Con ajuste de línea no hay contenido a la derecha por definición: cada // fila entra en el panel. Medir la línea más larga ahí daría un ancho de // scroll que no existe. let ancho_texto = if disp.ajustada() { 0.0 } else { crate::geometria::ancho_contenido( ventana.recorrer().map(|(_, n)| state.buffer.line(n)), &medidor, ) }; // La capa interna cubre el contenido o el viewport, lo que sea más ancho: // si sólo cubriera el viewport, al desplazarse en X los fondos de línea // (renglón activo, tintes, selección) dejarían una franja muerta a la // derecha. Es una capa absoluta, así que su ancho no empuja al padre — el // panel se sigue midiendo solo. let ancho_capa = (ancho_texto + PAD_X * 2.0).max(vp.ancho_px); let spans = state.highlighted_spans(opciones.language); // Se arma antes que el contenido porque los dos leen los mismos spans y el // contenido se los queda. let banda = banda_pegajosa( state, palette, metrics, &ventana, opciones.pegajosas, &spans, &syntax, -vp.desplazamiento.x_px, opciones.on_pegajosa.clone(), ); let gutter = build_gutter( state, &ventana, caret.line, metrics, palette, rango.recorte_px, opciones.on_pliegue.clone(), ); let content = build_content( state, palette, metrics, // Sin piso de alto. El `min_size` del camino viejo existe para el modo // embebido, que reserva alto para todas las líneas porque scrollea // desde afuera; acá el editor llena el panel y punto. Ponerle como piso // el alto que midió el cuadro anterior sería peor que inútil: el nodo // pediría no achicarse nunca por debajo de su propio tamaño previo, y // achicar la ventana dejaría de encoger el editor. 0.0, &ventana, spans, &syntax, opciones.match_ranges, None, opciones.styled, Ayudas { guias: opciones.guias_de_sangria, margen: opciones.margen_columna, indent: state.options.indent_size.max(1), }, Desplazamiento2D { x: -vp.desplazamiento.x_px, y: rango.recorte_px, ancho_capa, }, opciones.medida, opciones.on_menu.clone(), on_pointer, ); let mut hijos = vec![gutter, content]; // La banda de contexto va DESPUÉS del contenido —lo tapa— y ANTES de la // barra, que se pinta encima de todo. if let Some(banda) = banda { hijos.push(banda); } // La franja de marcas va debajo de la barra: comparten el canal derecho, y // la barra tiene que quedar arriba para seguir recibiendo el arrastre. if let Some(franja) = franja_de_marcas(state, palette, vp, &disp, opciones.match_ranges) { hijos.push(franja); } if let Some(barra) = barra_de_scroll(vp, &disp, opciones.on_scroll.clone()) { hijos.push(barra); } // El eje horizontal sólo existe sin ajuste de línea: con ajuste, `ancho_texto` // es 0 por definición y la barra no se dibuja. if let Some(barra) = barra_de_scroll_h( vp, vp.desplazamiento.x_px, ancho_texto + PAD_X * 2.0, opciones.on_scroll_x.clone(), ) { hijos.push(barra); } let raiz = View::new(Style { flex_direction: FlexDirection::Row, flex_grow: 1.0, size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, ..Default::default() }) .fill(palette.bg) .clip(true) .role(llimphi_compositor::Role::MultilineTextInput) .aria_label("Editor de texto") .children(hijos); match opciones.on_scroll { // La rueda la atiende el **editor**, no el `on_wheel` global del host. // Es la diferencia entre «scrollea si la app se acordó de rutearla» y // «scrollea porque el cursor está encima»: un editor metido adentro de // un contenedor que ya atiende la rueda no llegaba a verla nunca. // // `on_scroll` entrega el delta en LÍNEAS —la unidad del runtime, la // misma que consume `llimphi-widget-scroll`— y acá se convierte a los // píxeles con los que trabaja la geometría. Tres líneas por muesca es el // paso de siempre. Some(cb) => { let y_actual = vp.desplazamiento.y_documento(&disp); let max_y = vp.max_y(&disp); // La rueda LATERAL (el `dx` del trackpad, y el que el compositor // sintetiza al girar con Shift) va al eje horizontal. Se descartaba: // `on_scroll` sólo miraba `dy`, así que sin ajuste de línea el texto // quedaba cortado contra el borde y no había gesto que lo moviera. let cb_x = opciones.on_scroll_x.clone(); let ancho_char = metrics.char_width; raiz.on_scroll(move |dx, dy| { if dy == 0.0 && dx != 0.0 { let cb_x = cb_x.as_ref()?; return (cb_x)(dx * 3.0 * ancho_char); } let px = dy * 3.0 * metrics.line_height; if px == 0.0 { return None; } // Topado hacia ese lado: devolvemos `None` para que el runtime // le pase el evento al ancestro scrollable más cercano. Es el // contrato del scroll anidado, y sin esto un editor que llegó a // su fin se traga la rueda y el panel que lo contiene no se // mueve nunca. if (px < 0.0 && y_actual <= 0.0) || (px > 0.0 && y_actual >= max_y) { return None; } (cb)(px) }) } None => raiz, } } /// Hasta cuántas líneas mirar hacia arriba buscando la cadena de contexto. Es /// el caso patológico —un archivo enorme donde nada llega a la columna cero—: /// se corta y se dibuja lo que se encontró, que es un encabezado de menos y /// nunca uno equivocado. pub const TOPE_PEGAJOSAS: usize = 2_000; /// El contexto pegado arriba del editor: de qué `impl`, de qué `fn`, de qué /// clave del YAML es lo que se está mirando. /// /// Es una capa **absoluta encima del contenido**, no una banda que lo empuja: /// si empujara, su alto cambiaría al scrollear y el texto daría saltos. La /// contra es que tapa los primeros renglones — por eso el caret se encuadra con /// `Viewport::margen_filas` puesto en la cantidad de pegajosas, que es /// exactamente el aire que hace falta. /// /// Las líneas salen de `plegado::cadena_de_bloques`, la misma regla de sangría /// que decide qué se pliega. No hay una segunda definición de «bloque» dando /// vueltas. #[allow(clippy::too_many_arguments)] fn banda_pegajosa( state: &EditorState, palette: &EditorPalette, metrics: EditorMetrics, v: &Ventana<'_>, max: usize, spans: &[Vec], syntax: &SyntaxPalette, x_off: f32, on_click: Option Option + Send + Sync>>, ) -> Option> { if max == 0 || v.filas() == 0 { return None; } let lineas = state.lineas_pegajosas(v.linea_de(0), max); if lineas.is_empty() { return None; } let alto = lineas.len() as f32 * metrics.line_height; let mut numeros: Vec> = Vec::with_capacity(lineas.len()); let mut textos: Vec> = Vec::with_capacity(lineas.len()); for (i, n) in lineas.iter().copied().enumerate() { if matches!(metrics.gutter_style, GutterStyle::Numbers) { numeros.push( View::new(Style { position: Position::Absolute, inset: Rect { left: length(GUTTER_PAD_L), top: length(text_y(i, metrics)), right: length(GUTTER_PAD_R), bottom: auto(), }, size: Size { width: length((metrics.gutter_width - GUTTER_PAD_L - GUTTER_PAD_R).max(1.0)), height: length(metrics.line_height), }, align_items: Some(AlignItems::CENTER), ..Default::default() }) .text_aligned( (n + 1).to_string(), metrics.font_size * 0.85, palette.fg_line_number, Alignment::End, ) .mono(), ); } let texto = state.buffer.line(n); let texto = texto.trim_end_matches(['\n', '\r']).to_string(); textos.push(match spans.get(n) { Some(sp) => line_text_tokens(i, &texto, sp, metrics, palette, syntax), None => line_text_plain(i, texto, metrics, palette), }); } // El texto va en su propia capa, corrida por el gutter y por el scroll // horizontal: las pegajosas acompañan el desplazamiento lateral, si no // dirían una columna distinta de la que se está leyendo abajo. let capa_texto = View::new(Style { position: Position::Absolute, inset: Rect { left: length(metrics.gutter_width), top: length(0.0_f32), right: length(0.0_f32), bottom: length(0.0_f32), }, ..Default::default() }) .clip(true) .children(vec![capa_desplazada(textos, x_off, 0.0, 0.0)]); // Una raya al pie que separa el contexto del texto de verdad. Sin ella, con // el mismo fondo, no se sabe dónde termina lo pegado y empieza lo que se // está editando. let raya = View::new(Style { position: Position::Absolute, inset: Rect { left: length(0.0_f32), top: length(alto - 1.0), right: length(0.0_f32), bottom: auto(), }, size: Size { width: percent(1.0_f32), height: length(1.0_f32) }, ..Default::default() }) .fill(with_alpha(palette.fg_line_number, 0.35)); let mut hijos = numeros; hijos.push(capa_texto); hijos.push(raya); // El blanco de click es la fila entera, como la flecha del pliegue. Cae en // la línea que corresponde a la altura clickeada. let banda = View::new(Style { position: Position::Absolute, inset: Rect { left: length(0.0_f32), top: length(0.0_f32), right: length(0.0_f32), bottom: auto(), }, size: Size { width: percent(1.0_f32), height: length(alto) }, ..Default::default() }) .fill(palette.bg_gutter) .clip(true) .aria_label("Contexto") .children(hijos); Some(match on_click { Some(cb) => { let lh = metrics.line_height; banda .cursor(llimphi_compositor::Cursor::Pointer) .on_click_at(move |_x, y, _w, _h| { let i = ((y / lh) as usize).min(lineas.len().saturating_sub(1)); (cb)(lineas[i]) }) } None => banda, }) } /// Ancho del **pulgar**, en px. const BARRA_W: f32 = 6.0; /// Ancho del **canal**: el área que se puede agarrar. Más ancha que el pulgar a /// propósito — un blanco de 6 px se falla —, y es además la que recibe el click /// de salto de página. const CANAL_W: f32 = 14.0; /// Aire entre la barra y el borde derecho. const BARRA_PAD: f32 = 2.0; /// Alto de una marca de la franja, en px. const MARCA_H: f32 = 2.0; /// Aire a cada lado de la marca dentro del canal. const MARCA_PAD: f32 = 3.0; /// Prioridad de una marca cuando dos caen en el mismo píxel. Gana el número más /// alto: un error no se puede perder detrás de un match del find. fn prioridad(sev: Severity) -> u8 { match sev { Severity::Error => 4, Severity::Warning => 3, Severity::Information => 1, Severity::Hint => 0, } } /// El *error stripe*: el mapa del archivo entero sobre el borde derecho, con /// una marca por diagnóstico y una por match del find. /// /// Es la respuesta a «¿hay un error más abajo?» sin scrollear, y comparte el /// canal con la barra de posición — por eso va **antes** que ella en la lista de /// hijos: la barra se pinta encima y sigue siendo la que recibe el arrastre. /// /// **Las marcas se funden por píxel.** Sin eso, un archivo con diez mil /// advertencias produciría diez mil nodos por cuadro, que es exactamente el /// costo que la etapa 1 sacó del render. Una franja no puede mostrar más marcas /// que píxeles tiene, así que dos que caen en el mismo lugar son una sola y gana /// la más grave. fn franja_de_marcas( state: &EditorState, palette: &EditorPalette, vp: &Viewport, disp: &crate::geometria::Disposicion, match_ranges: &[(usize, usize)], ) -> Option> { if vp.alto_px <= 0.0 { return None; } let filas = disp.filas().max(1) as f32; let alto = vp.alto_px; let y_de = |linea: usize, col: usize| { (disp.fila_de_pos(linea, col) as f32 / filas) * alto }; // `(cubeta de píxeles, prioridad, color)`. La cubeta es del alto de la // marca: dos marcas que se dibujarían encima son una sola. let mut marcas: Vec<(i32, u8, Color)> = Vec::new(); for d in &state.diagnostics { let y = y_de(d.range.start.line, d.range.start.col); let color = match d.severity { Severity::Error => palette.diag_error, Severity::Warning => palette.diag_warning, Severity::Information => palette.diag_info, Severity::Hint => palette.diag_hint, }; marcas.push(((y / MARCA_H) as i32, prioridad(d.severity), color)); } for (inicio, _) in match_ranges { let (l, c) = state.buffer.offset_to_pos(*inicio); marcas.push(((y_de(l, c) / MARCA_H) as i32, 2, palette.bg_match)); } if marcas.is_empty() { // Sin nada que marcar queda sólo la rayita del caret, y para eso no vale // la pena una capa: la posición ya la dice el pulgar de la barra. return None; } // Ordenar por cubeta y, dentro de cada una, por prioridad descendente: // `dedup_by_key` se queda con el primero de cada grupo, o sea el más grave. marcas.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1))); marcas.dedup_by_key(|m| m.0); let mut hijos: Vec> = Vec::with_capacity(marcas.len() + 1); for (cubeta, _, color) in marcas { hijos.push( View::new(Style { position: Position::Absolute, inset: Rect { left: length(MARCA_PAD), top: length(cubeta as f32 * MARCA_H), right: length(MARCA_PAD), bottom: auto(), }, size: Size { width: auto(), height: length(MARCA_H) }, ..Default::default() }) .fill(color), ); } // La rayita del caret, encima de las marcas: dónde estoy parado dentro del // mapa. Es lo que convierte la franja en una brújula y no en una lista. let caret = state.cursor.caret; hijos.push( View::new(Style { position: Position::Absolute, inset: Rect { left: length(0.0_f32), top: length(y_de(caret.line, caret.col)), right: length(0.0_f32), bottom: auto(), }, size: Size { width: auto(), height: length(1.0_f32) }, ..Default::default() }) .fill(with_alpha(palette.caret, 0.8)), ); Some( View::new(Style { position: Position::Absolute, inset: Rect { left: auto(), top: length(0.0_f32), right: length(BARRA_PAD), bottom: length(0.0_f32), }, size: Size { width: length(CANAL_W), height: percent(1.0_f32) }, ..Default::default() }) .aria_label("Marcas del archivo") .children(hijos), ) } /// La barra de posición sobre el borde derecho: dónde estás dentro del archivo. /// /// El editor no tenía ninguna — con el viewport contado afuera, ni siquiera se /// sabía qué fracción del documento se estaba viendo. La geometría del pulgar /// sale de `llimphi_widget_scroll::thumb_geometry`, la misma que usan las listas /// del repo, así que se mueve igual que todo lo demás. /// /// Devuelve `None` cuando el documento entra completo (no hay nada que indicar) /// o cuando el panel todavía no se midió. Todavía **no se arrastra**: eso pide /// compartir el canal con el *error stripe* —las marcas de diagnostics y de /// find sobre la misma franja—, que es una pieza de la etapa siguiente, y una /// barra que se arrastra hoy habría que rehacerla ahí. fn barra_de_scroll( vp: &Viewport, disp: &crate::geometria::Disposicion, on_scroll: Option Option + Send + Sync>>, ) -> Option> { if vp.alto_px <= 0.0 { return None; } let contenido = disp.alto_total(); if contenido <= vp.alto_px { return None; } let paleta = llimphi_widget_scroll::ScrollPalette::default(); let (alto_thumb, y_thumb, px_por_px) = llimphi_widget_scroll::thumb_geometry( vp.desplazamiento.y_documento(disp), contenido, vp.alto_px, ); if alto_thumb <= 0.0 { return None; } let thumb = View::new(Style { position: Position::Absolute, inset: Rect { left: length((CANAL_W - BARRA_W) * 0.5), top: length(y_thumb), right: auto(), bottom: auto(), }, size: Size { width: length(BARRA_W), height: length(alto_thumb) }, ..Default::default() }) .fill(with_alpha(paleta.thumb, paleta.thumb_idle_alpha)) .radius((BARRA_W * 0.5) as f64); let canal = View::new(Style { position: Position::Absolute, inset: Rect { left: auto(), top: length(0.0_f32), right: length(BARRA_PAD), bottom: length(0.0_f32), }, size: Size { width: length(CANAL_W), height: percent(1.0_f32) }, ..Default::default() }) .children(vec![thumb]); let Some(cb) = on_scroll else { // Sin callback la barra es sólo un indicador: se dibuja, no se agarra. return Some(canal); }; let cb_drag = cb.clone(); let alto_vp = vp.alto_px; Some( canal // El delta del arrastre viene en píxeles de PANTALLA; `px_por_px` es // el factor que los pasa a píxeles de DOCUMENTO. Sin él, un archivo // largo se recorrería a la velocidad del dedo y el pulgar se // despegaría del cursor a los pocos píxeles. .draggable_at(move |fase, _dx, dy, _lx, _ly| match fase { llimphi_compositor::DragPhase::Move => (cb_drag)(dy * px_por_px), llimphi_compositor::DragPhase::End => None, }) // Click en el canal: salta una página hacia donde se clickeó, como // cualquier barra de escritorio. Sobre el pulgar devuelve 0 — ese // click es el comienzo del arrastre. .on_click_at(move |_x, y, _w, _h| { let salto = llimphi_widget_scroll::track_page_jump(y, y_thumb, alto_thumb, alto_vp); if salto == 0.0 { None } else { (cb)(salto) } }) .cursor(llimphi_compositor::Cursor::Default), ) } /// **La barra de abajo: el eje horizontal**, hermana de [`barra_de_scroll`]. /// /// Aparece sólo cuando hace falta —sin ajuste de línea y con una línea más /// ancha que el panel—, porque una barra que no se puede mover es ruido. El /// ancho de contenido lo mide el llamador sobre las filas VISIBLES (ver /// `ancho_contenido`), así que el largo del pulgar se ajusta mientras uno /// navega: es el precio de no barrer el archivo entero por cuadro. fn barra_de_scroll_h( vp: &Viewport, x_px: f32, ancho_contenido: f32, on_scroll_x: Option Option + Send + Sync>>, ) -> Option> { if vp.ancho_px <= 0.0 || ancho_contenido <= vp.ancho_px { return None; } let paleta = llimphi_widget_scroll::ScrollPalette::default(); let (ancho_thumb, x_thumb, px_por_px) = llimphi_widget_scroll::thumb_geometry(x_px, ancho_contenido, vp.ancho_px); if ancho_thumb <= 0.0 { return None; } let thumb = View::new(Style { position: Position::Absolute, inset: Rect { left: length(x_thumb), top: length((CANAL_W - BARRA_W) * 0.5), right: auto(), bottom: auto(), }, size: Size { width: length(ancho_thumb), height: length(BARRA_W) }, ..Default::default() }) .fill(with_alpha(paleta.thumb, paleta.thumb_idle_alpha)) .radius((BARRA_W * 0.5) as f64); let canal = View::new(Style { position: Position::Absolute, inset: Rect { left: length(0.0_f32), top: auto(), right: length(CANAL_W), bottom: length(BARRA_PAD), }, size: Size { width: auto(), height: length(CANAL_W) }, ..Default::default() }) .children(vec![thumb]); let Some(cb) = on_scroll_x else { return Some(canal); }; let cb_drag = cb.clone(); let ancho_vp = vp.ancho_px; Some( canal .draggable_at(move |fase, dx, _dy, _lx, _ly| match fase { llimphi_compositor::DragPhase::Move => (cb_drag)(dx * px_por_px), llimphi_compositor::DragPhase::End => None, }) .on_click_at(move |x, _y, _w, _h| { let salto = llimphi_widget_scroll::track_page_jump(x, x_thumb, ancho_thumb, ancho_vp); if salto == 0.0 { None } else { (cb)(salto) } }) .cursor(llimphi_compositor::Cursor::Default), ) } /// Las filas que el render está materializando, y la traducción a líneas. /// /// Reemplaza al par `(scroll, end_line)` que llevaban todas las funciones de /// dibujo. La diferencia no es cosmética: ese par daba por sentado que la fila /// local `i` es la línea `scroll + i`, y con un pliegue en el medio eso deja de /// ser cierto. Toda pregunta de «¿dónde pinto esta línea?» pasa por acá, así que /// el plegado no necesitó tocar ninguna de las veinte funciones que pintan. pub(crate) struct Ventana<'a> { disp: &'a crate::geometria::Disposicion, primera_fila: usize, filas: usize, } impl<'a> Ventana<'a> { pub(crate) fn nueva( disp: &'a crate::geometria::Disposicion, primera_fila: usize, filas: usize, ) -> Self { Self { disp, primera_fila, filas } } /// Cuántas filas se están dibujando. pub(crate) fn filas(&self) -> usize { self.filas } /// La línea lógica que va en la fila local `i`. pub(crate) fn linea_de(&self, local: usize) -> usize { self.disp.linea_de_fila(self.primera_fila + local).linea } /// La fila local `i` completa: su línea **y su tramo de columnas**. Sin /// ajuste de línea el tramo es la línea entera; con ajuste es el pedazo que /// le toca a este renglón. pub(crate) fn fila_de(&self, local: usize) -> Fila { self.disp.linea_de_fila(self.primera_fila + local) } /// Dónde pintar la posición `(linea, col)`: la fila local y la columna /// **rebasada a esa fila**. `None` si no se ve. pub(crate) fn ubicar(&self, linea: usize, col: usize) -> Option<(usize, usize)> { if self.disp.esta_oculta(linea) { return None; } let fila = self.disp.fila_de_pos(linea, col); let local = fila.checked_sub(self.primera_fila)?; if local >= self.filas { return None; } let f = self.disp.linea_de_fila(fila); Some((local, col.saturating_sub(f.col_inicio))) } /// El rango `[col_a, col_b)` de una línea, repartido en las filas visibles /// que lo cubren y con las columnas rebasadas a cada fila. /// /// Es la costura que le permitió al ajuste de línea no tocar a la selección, /// los matches del find ni los diagnostics: los tres pintaban un rectángulo /// por línea, y ahora piden acá los rectángulos que les corresponden. Sin /// ajuste devuelve exactamente uno, con las mismas columnas de antes. pub(crate) fn tramos(&self, linea: usize, col_a: usize, col_b: usize) -> Vec { let mut out = Vec::new(); if col_b <= col_a || self.disp.esta_oculta(linea) { return out; } let mut fila = self.disp.fila_de_pos(linea, col_a); let ultima = self.disp.fila_de_pos(linea, col_b.saturating_sub(1)); loop { let f = self.disp.linea_de_fila(fila); if f.linea != linea { break; } let tope = f.col_fin.unwrap_or(usize::MAX); let a = col_a.max(f.col_inicio); let b = col_b.min(tope); if b > a { if let Some(local) = fila.checked_sub(self.primera_fila) { if local < self.filas { out.push(TramoFila { local, col_a: a - f.col_inicio, col_b: b - f.col_inicio, fin_de_linea: f.es_ultima(), }); } } } if fila >= ultima { break; } fila += 1; } out } /// `(fila local, línea)` de todo lo que hay que dibujar, en orden. pub(crate) fn recorrer(&self) -> impl Iterator + '_ { (0..self.filas).map(|i| (i, self.linea_de(i))) } /// `(fila local, fila)` de todo lo que hay que dibujar, en orden. pub(crate) fn recorrer_filas(&self) -> impl Iterator + '_ { (0..self.filas).map(|i| (i, self.fila_de(i))) } /// Primera y última línea lógica a la vista — para acotar los barridos que /// vienen de rangos (selección, diagnostics, matches) sin recorrer el /// archivo entero. pub(crate) fn rango_lineas(&self) -> (usize, usize) { if self.filas == 0 { return (0, 0); } (self.linea_de(0), self.linea_de(self.filas - 1)) } } /// Un pedazo de un rango de columnas que cae en una fila visual concreta. /// Ver [`Ventana::tramos`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct TramoFila { /// Fila local (relativa a la primera materializada). pub local: usize, /// Columna de arranque, **rebasada a la fila**. pub col_a: usize, /// Columna de fin (exclusiva), rebasada a la fila. pub col_b: usize, /// `true` si esta fila llega al final de la línea lógica — la única que /// tiene derecho a pintar el chorrito que marca el salto de renglón. pub fin_de_linea: bool, } /// Las ayudas de lectura que se pintan **debajo** del texto: las rayitas de /// sangría y el margen de columna. Vacío por omisión — las firmas viejas del /// widget no las piden y siguen dibujando exactamente lo mismo. #[derive(Debug, Clone, Copy, Default)] pub(crate) struct Ayudas { pub guias: bool, pub margen: Option, /// Cada cuántas columnas cae un nivel de sangría. pub indent: usize, } /// Cuánto correr la capa de contenido y qué ancho darle. Ver /// [`text_editor_view_viewport`]. #[derive(Debug, Clone, Copy, Default)] pub(crate) struct Desplazamiento2D { pub x: f32, pub y: f32, /// `0.0` = la capa toma el 100 % del padre (el camino de las firmas viejas, /// que no tienen scroll horizontal). pub ancho_capa: f32, } /// Como [`text_editor_view_highlighted`] + `match_ranges` para pintar /// las ocurrencias de un find activo. Cada par `(char_start, char_end)` /// es un rango de chars globales del buffer. pub fn text_editor_view_full( state: &EditorState, palette: &EditorPalette, metrics: EditorMetrics, visible_lines: usize, language: Language, match_ranges: &[(usize, usize)], on_pointer: impl Fn(PointerEvent) -> Option + Send + Sync + Clone + 'static, ) -> View { let caret = state.cursor.caret; let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark()); let visible = visible_lines.max(1).min(200); let line_count = state.line_count(); let scroll = state.scroll_offset.min(line_count.saturating_sub(1)); let end_line = (scroll + visible).min(line_count); let height = (end_line - scroll) as f32 * metrics.line_height; // El camino viejo no conoce pliegues: su ventana es contigua, y la // disposición sin pliegues es exactamente la identidad de siempre. let disp_plana = crate::geometria::Disposicion::para(line_count, metrics); let ventana = Ventana::nueva(&disp_plana, scroll, end_line.saturating_sub(scroll)); // Memoizado por `edit_seq` — sólo reparseamos cuando el buffer // realmente cambió o cambia el `Language`. let spans = state.highlighted_spans(language); let gutter = build_gutter(state, &ventana, caret.line, metrics, palette, 0.0, None); let content = build_content( state, palette, metrics, height, &ventana, spans, &syntax, match_ranges, None, None, Ayudas::default(), Desplazamiento2D::default(), None, None, on_pointer, ); // Llena el alto disponible (para verse como UN campo continuo: se puede // clickear/tipear debajo de la última línea), pero nunca colapsa por debajo // del alto del contenido — `min_size` es el piso si el contenedor es // chico/indefinido. El caret y los clicks debajo del texto aterrizan en la // última línea (los clampea `set_caret_at`). View::new(Style { flex_direction: FlexDirection::Row, flex_grow: 1.0, size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, min_size: Size { width: auto(), height: length(height) }, ..Default::default() }) .fill(palette.bg) .clip(true) // Editor de texto multilínea: los lectores cambian de modo de navegación // con este rol (por línea/palabra) en vez de leerlo como un bloque. No // mandamos el contenido en `value` a propósito: puede ser un archivo // entero, y el buffer se navega por el árbol, no por el nombre. .role(llimphi_compositor::Role::MultilineTextInput) .aria_label("Editor de texto") .children(vec![gutter, content]) } /// Como [`text_editor_view`] pero el caller provee el color de cada tramo de /// cada línea (`line_color_runs[n]` = `(byte_start, byte_end, Color)` de la /// línea `n`), en vez de derivarlo de un `Language`. Para outputs con coloreo /// semántico propio (un shell que tinta `ls`, paths, urls, números…) sobre el /// mismo editor read-only (numeración + selección + copiar). pub fn text_editor_view_colored( state: &EditorState, palette: &EditorPalette, metrics: EditorMetrics, visible_lines: usize, line_color_runs: &[Vec<(usize, usize, Color)>], on_pointer: impl Fn(PointerEvent) -> Option + Send + Sync + Clone + 'static, ) -> View { let caret = state.cursor.caret; let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark()); // Variante embebida: el contenedor de afuera (el panel de output del shell) // hace el scroll y reserva alto para TODAS las líneas, así que las pintamos // completas (cap alto = red de seguridad de wgpu, ver `EMBEDDED_LINE_CAP`). let visible = visible_lines.max(1).min(EMBEDDED_LINE_CAP); let line_count = state.line_count(); let scroll = state.scroll_offset.min(line_count.saturating_sub(1)); let end_line = (scroll + visible).min(line_count); let height = (end_line - scroll) as f32 * metrics.line_height; let disp_plana = crate::geometria::Disposicion::para(line_count, metrics); let ventana = Ventana::nueva(&disp_plana, scroll, end_line.saturating_sub(scroll)); let gutter = build_gutter(state, &ventana, caret.line, metrics, palette, 0.0, None); let content = build_content( state, palette, metrics, height, &ventana, Vec::new(), &syntax, &[], Some(line_color_runs), None, Ayudas::default(), Desplazamiento2D::default(), None, None, on_pointer, ); View::new(Style { flex_direction: FlexDirection::Row, size: Size { width: percent(1.0_f32), height: length(height) }, ..Default::default() }) .fill(palette.bg) .clip(true) // Editor de texto multilínea: los lectores cambian de modo de navegación // con este rol (por línea/palabra) en vez de leerlo como un bloque. No // mandamos el contenido en `value` a propósito: puede ser un archivo // entero, y el buffer se navega por el árbol, no por el nombre. .role(llimphi_compositor::Role::MultilineTextInput) .aria_label("Editor de texto") .children(vec![gutter, content]) } fn build_gutter( state: &EditorState, v: &Ventana<'_>, active_line: usize, metrics: EditorMetrics, palette: &EditorPalette, // Píxeles a correr la columna hacia arriba: la parte de la primera fila que // quedó por encima del borde. Cero por el camino viejo (scroll por línea // entera), negativo con scroll fino. recorte_y: f32, // Qué `Msg` emitir al clickear la flecha de un pliegue, con la línea de la // cabecera. `None` = sin flechas (el camino viejo, y los hosts que no // implementaron el plegado). on_pliegue: Option Option + Send + Sync>>, ) -> View { let mut children: Vec> = Vec::with_capacity(v.filas()); for (local, fila) in v.recorrer_filas() { let n = fila.linea; // Con ajuste de línea, las filas de continuación no llevan número: // repetir el mismo tres renglones seguidos dice menos que dejarlos en // blanco, y es lo que hace cualquier editor. if !fila.es_primera() { continue; } // Las líneas-guarda son separadores estructurales entre zonas // de texto: ni se numeran ni se pueden escribir. El espacio // se preserva (la línea sigue existiendo), pero el gutter las // saltea — visualmente la numeración "rompe" en cada zona. // Si `guard_lines` está vacío, este check es siempre `false` // y la numeración cubre todas las líneas (modo IDE clásico). if state.is_guard_line(n) { continue; } let color = if n == active_line { palette.fg_line_number_active } else { palette.fg_line_number }; let y = text_y(local, metrics); match metrics.gutter_style { GutterStyle::Numbers => { let label = (n + 1).to_string(); // Número alineado a la derecha con un respiro de `GUTTER_PAD_R` // hasta el texto (antes 4 px → se pegaba y el clip lo cortaba) y // un margen izquierdo para que no toque el borde de la pantalla. children.push( View::new(Style { position: Position::Absolute, inset: Rect { left: length(GUTTER_PAD_L), top: length(y), right: length(GUTTER_PAD_R), bottom: auto(), }, size: Size { width: length( (metrics.gutter_width - GUTTER_PAD_L - GUTTER_PAD_R).max(1.0), ), height: length(metrics.line_height), }, align_items: Some(AlignItems::CENTER), ..Default::default() }) .text_aligned(label, metrics.font_size * 0.85, color, Alignment::End) .mono(), ); } GutterStyle::None => {} GutterStyle::Phantom => { // Tick fantasma — un segmento horizontal corto centrado // verticalmente en la línea, con la opacidad bajada. // La línea activa queda un pelín más visible. let alpha = if n == active_line { 0.35 } else { 0.12 }; let tick_w = (metrics.gutter_width * 0.5).max(3.0); let tick_h = 1.0_f32; let tick_y = y + (metrics.line_height - tick_h) * 0.5; let tick_x = (metrics.gutter_width - tick_w) * 0.5; children.push( View::new(Style { position: Position::Absolute, inset: Rect { left: length(tick_x), top: length(tick_y), right: auto(), bottom: auto(), }, size: Size { width: length(tick_w), height: length(tick_h), }, ..Default::default() }) .fill(with_alpha(color, alpha)), ); } } } // La flecha de plegado, encima de todo lo demás: sólo en las líneas que // encabezan un bloque, y sólo si el host dijo qué hacer al clickearla. if let Some(cb) = on_pliegue { for (local, fila) in v.recorrer_filas() { let n = fila.linea; if !fila.es_primera() { continue; } // Una pregunta por línea VISIBLE, y cada una mira sólo hasta la // próxima línea con texto. Pedir la lista completa de bloques del // archivo acá costaría el documento entero por cuadro. if !state.hay_plegable_en(n) { continue; } let plegado = state.esta_plegado(n); let cb = cb.clone(); children.push( View::new(Style { position: Position::Absolute, inset: Rect { left: length(0.0_f32), top: length(text_y(local, metrics)), right: length(0.0_f32), bottom: auto(), }, size: Size { width: percent(1.0_f32), height: length(metrics.line_height), }, align_items: Some(AlignItems::CENTER), padding: Rect { left: length(0.0_f32), right: length(1.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, ..Default::default() }) // El blanco de click es la fila **entera** del gutter y no el // glifo: un triángulo de seis píxeles se falla, y el número de // línea no compite por ese click porque no hace nada. .text_aligned( if plegado { "\u{25b8}" } else { "\u{25be}" }.to_string(), metrics.font_size * 0.8, if plegado { palette.fg_line_number_active } else { palette.fg_line_number }, Alignment::End, ) .cursor(llimphi_compositor::Cursor::Default) // El texto visible es el triángulo, así que sin nombre el lector // dice «▾» y ninguna señalética puede apuntar la fila que pliega. .role(llimphi_compositor::Role::Button) .aria_label(alloc::format!( "{} el bloque de la línea {}", if plegado { "Desplegar" } else { "Plegar" }, n + 1 )) .aria_expanded(!plegado) .on_click_at(move |_x, _y, _w, _h| (cb)(n)), ); } } // En modo Phantom el gutter es un sliver: no aplicamos `fill` — // se mezcla con el fondo del editor. El gutter "está sin estar". let bg = match metrics.gutter_style { GutterStyle::Numbers => palette.bg_gutter, GutterStyle::Phantom | GutterStyle::None => palette.bg, }; View::new(Style { size: Size { width: length(metrics.gutter_width), height: percent(1.0_f32), }, ..Default::default() }) .fill(bg) .clip(true) .children(vec![capa_desplazada(children, 0.0, recorte_y, 0.0)]) } /// Envuelve los hijos en una capa corrida `(dx, dy)` píxeles. /// /// Es donde se aplica **todo** el desplazamiento: el fino sub-fila y el /// horizontal. Aplicarlo acá y no en cada `text_x`/`text_y` es lo que deja /// intactas las veinte funciones de dibujo — siguen posicionando relativo a la /// primera fila materializada, que es lo único que saben. /// /// `ancho` en `0.0` deja la capa al 100 % del padre. fn capa_desplazada( children: Vec>, dx: f32, dy: f32, ancho: f32, ) -> View { View::new(Style { position: Position::Absolute, inset: Rect { left: length(dx), top: length(dy), right: auto(), bottom: auto(), }, size: Size { width: if ancho > 0.0 { length(ancho) } else { percent(1.0_f32) }, height: percent(1.0_f32), }, ..Default::default() }) .children(children) } /// Devuelve `c` con la opacidad multiplicada por `alpha` (clamp 0..1). fn with_alpha(c: Color, alpha: f32) -> Color { let rgba = c.to_rgba8(); let a = ((alpha.clamp(0.0, 1.0)) * (rgba.a as f32)) as u8; Color::from_rgba8(rgba.r, rgba.g, rgba.b, a) } #[allow(clippy::too_many_arguments)] fn build_content( state: &EditorState, palette: &EditorPalette, metrics: EditorMetrics, height: f32, v: &Ventana<'_>, spans_per_line: Vec>, syntax: &SyntaxPalette, match_ranges: &[(usize, usize)], // Override de color por línea: `color_runs[n]` son `(byte_start, byte_end, // Color)` para la línea `n` del buffer (índice absoluto). Cuando es `Some`, // gana sobre el syntax highlight — para callers que colorean por semántica // propia (un shell que tinta `ls`, paths, urls…). `None` = highlight normal. color_runs: Option<&[Vec<(usize, usize, Color)>]>, // Spans rich-text por línea (color/fondo/fuente/tamaño/peso/itálica/…). // Cuando es `Some`, ganan sobre el syntax highlight — la vía de pluma para // estilar zonas y selección de prosa. `None` = comportamiento previo. styled_per_line: Option<&[Vec]>, // Las rayitas de sangría y el margen de columna, que van debajo de todo. ayudas: Ayudas, // Desplazamiento fino + horizontal de la capa de contenido. desp: Desplazamiento2D, // Dónde publicar el tamaño del área de contenido, si el caller quiere // medirla. El rect que se publica es el del área de texto (sin el gutter), // que es contra la que hay que contar filas. medida: Option<&TamanoVisible>, // El botón derecho sobre el texto, en coordenadas de ventana. `None` = el // editor no lo contesta y sube al ancestro que lo haga. on_menu: Option Option + Send + Sync>>, on_pointer: impl Fn(PointerEvent) -> Option + Send + Sync + Clone + 'static, ) -> View { let caret = state.cursor.caret; let mut children: Vec> = Vec::new(); // 0.a) Las ayudas de lectura: las rayitas de sangría y el margen. Van al // fondo de todo — debajo del renglón activo, de la selección y del // texto—, porque son una guía y no un resaltado. children.extend(guias_y_margen(state, palette, metrics, v, ayudas)); // 0) Tintes por línea — la capa más baja, debajo de todo el resto. // Pinta un rect del ancho completo del área de contenido por // cada línea con tinte asignado. El caller elige el alpha — el // widget no lo modula. Si la línea cae fuera de viewport o no // tiene tinte, no se pinta nada. for (local, n) in v.recorrer() { if let Some(Some(c)) = state.line_tints.get(n) { children.push(line_tint(local, *c, metrics)); } } // 1) Fondo del renglón activo — sólo el del primary cursor, y el renglón // donde de verdad está el caret: con ajuste de línea, una línea larga // ocupa varios y pintarlos todos convierte el resalte en una mancha. if let Some((local, _)) = v.ubicar(caret.line, caret.col) { children.push(line_highlight(local, metrics, palette)); } // 1b) Highlight de matches del find. for (s, e) in match_ranges { children.extend(match_rects(state, *s, *e, v, metrics, palette)); } // 1c) Fondos de los spans estilados (resaltado). Por debajo de la // selección y del texto, igual que los tintes — un rect por span // con `bg`. Char-cols → x/w vía `char_width` (mono). if let Some(styled) = styled_per_line { for (local, f) in v.recorrer_filas() { if let Some(line_spans) = styled.get(f.linea) { children.extend(styled_bg_rects(local, &styled_de_fila(line_spans, f), metrics)); } } } // 2) Selección — por cada cursor que tenga selección. for c in state.all_cursors() { if c.has_selection() { children.extend(selection_rects_for_cursor(state, c, v, metrics, palette)); } } // 2b) Bracket pair bajo el primary cursor — si visible. if let Some((a, b)) = crate::bracket::find_bracket_pair(&state.buffer, &state.cursor) { if let Some((local, col)) = v.ubicar(a.line, a.col) { children.push(bracket_highlight(crate::cursor::Pos::new(local, col), metrics, palette)); } if let Some((local, col)) = v.ubicar(b.line, b.col) { children.push(bracket_highlight(crate::cursor::Pos::new(local, col), metrics, palette)); } } // 3) Texto — sólo las líneas en viewport. // Si `phantom_guard_lines` está activo, cada guarda recibe un // divisor fantasma (segmento horizontal con baja opacidad) // atravesando su centro — sin texto, sólo un susurro visual. for (local_line, f) in v.recorrer_filas() { let n = f.linea; let completa = state.buffer.line(n); // El tramo de la fila: la línea entera sin ajuste, el pedazo que le toca // con ajuste. Los estilos se recortan y se rebasan al tramo — un span // que arranca en la columna 200 tiene que pintarse en la 8 del cuarto // renglón, no perderse. let text = texto_de_fila(&completa, f).into_owned(); if metrics.phantom_guard_lines && state.is_guard_line(n) { if f.es_primera() { children.push(phantom_guard_divider(local_line, metrics, palette)); } continue; } if let Some(line_spans) = styled_per_line.and_then(|s| s.get(n)).filter(|s| !s.is_empty()) { let propios = styled_de_fila(line_spans, f); children.push(line_text_styled(local_line, &text, &propios, metrics, palette)); } else if let Some(runs) = color_runs.and_then(|cr| cr.get(n)) { let propios = runs_de_fila(runs, &completa, f); children.push(line_text_color_runs(local_line, &text, &propios, metrics, palette)); } else if let Some(line_spans) = spans_per_line.get(n) { let propios = spans_de_fila(line_spans, f); children.push(line_text_tokens(local_line, &text, &propios, metrics, palette, syntax)); } else { children.push(line_text_plain(local_line, text, metrics, palette)); } } // 3b) Diagnostics — subrayado bajo el rango, color por severity. for d in &state.diagnostics { children.extend(diagnostic_underline(d, v, metrics, palette)); } // 4) Caret — uno por cursor, sólo si visible. El caret del cursor // primario se corre detrás del preedit del IME en composición (el // texto compuesto se pinta desde `p.col`), para que quede al final // de lo que el usuario está tecleando. En la fase "apagada" del // parpadeo (`caret_on == false`) no se dibuja ningún caret. let preedit_cols = state.preedit.as_ref().map_or(0, |p| p.text.chars().count()); for c in state.all_cursors() { let p = c.caret; if state.caret_on { let is_primary = core::ptr::eq(c, &state.cursor); let col_doc = if is_primary { p.col + preedit_cols } else { p.col }; if let Some((fila, col)) = v.ubicar(p.line, col_doc) { children.push(caret_rect(crate::cursor::Pos::new(fila, col), metrics, palette)); } } } // 5) Preedit del IME — texto en composición pintado en el caret con // subrayado, todavía fuera del buffer. Sólo en el cursor primario y // si su línea está en viewport. (En mono el ancho es exacto; el // texto que sigue al caret puede solaparse mientras se compone — // transitorio y, en el caso típico de acentos, de un solo char.) if let Some(pre) = state.preedit.as_ref() { let p = state.cursor.caret; if let Some((local, col)) = v.ubicar(p.line, p.col) { children.extend(preedit_views(local, col, &pre.text, metrics, palette)); } } let click_cb = on_pointer.clone(); let drag_end_cb = on_pointer.clone(); let drag_cb = on_pointer; let vista = View::new(Style { flex_grow: 1.0, // Llena el alto del editor (campo continuo), con piso en el alto del // contenido para el modo embebido que scrollea (`_colored`). size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, min_size: Size { width: auto(), height: length(height) }, ..Default::default() }) .fill(palette.bg) .clip(true) // Cursor de texto (I-beam) sobre el área editable; fuera vuelve al normal. .cursor(llimphi_compositor::Cursor::Text) .on_click_at(move |x, y, _w, _h| click_cb(PointerEvent::Click { x, y })) .draggable_at(move |phase, dx, dy, lx, ly| match phase { llimphi_compositor::DragPhase::Move => drag_cb(PointerEvent::Drag { initial_x: lx, initial_y: ly, dx, dy, }), llimphi_compositor::DragPhase::End => drag_end_cb(PointerEvent::DragEnd), }) .children(vec![capa_desplazada(children, desp.x, desp.y, desp.ancho_capa)]); // El menú contextual se ancla con coords de VENTANA: el popup vive en el // overlay de la app, no adentro del editor. let vista = match on_menu { Some(cb) => vista.on_right_click_screen(move |x, y, _w, _h| cb(x, y)), None => vista, }; match medida { // Se mide en el pintado porque es el único momento en que taffy ya // repartió. El host lo lee al cuadro siguiente — el mismo cuadro de // latencia que paga cualquier lista con scroll del repo. Some(m) => { let m = m.clone(); vista.paint_with(move |_scene, _ts, rect| m.escribir(rect.w, rect.h)) } None => vista, } } /// Las rayitas de sangría y el margen de columna: las dos ayudas que se pintan /// **debajo** del texto. /// /// Las guías salen de la sangría de la línea, no de un análisis: cada `indent` /// columnas, una raya de 1 px, hasta donde arranca el texto. Una **línea en /// blanco toma la sangría de la anterior con texto** — si no, un renglón vacío /// en medio de una función corta todas las guías, que es justo donde más se las /// mira. Ese rastreo hacia atrás está acotado: es una ayuda visual, no vale /// recorrer el archivo por ella. fn guias_y_margen( state: &EditorState, palette: &EditorPalette, metrics: EditorMetrics, v: &Ventana<'_>, ayudas: Ayudas, ) -> Vec> { let mut out: Vec> = Vec::new(); let color = with_alpha(palette.fg_line_number, 0.28); if ayudas.guias && ayudas.indent > 0 { for (local, f) in v.recorrer_filas() { // Sólo la fila que arranca la línea: con ajuste de línea, las de // continuación no tienen sangría propia y repetirla ahí dibujaría // guías donde no hay bloque. if !f.es_primera() { continue; } // `1..niveles`, no `..=`: la raya del último nivel caería justo // debajo del primer carácter de la línea. Las que sirven son las de // adentro, las que dicen de qué bloque cuelga esto. let niveles = (sangria_efectiva(state, f.linea) / ayudas.indent).min(GUIAS_MAX); for nivel in 1..niveles { out.push(raya_vertical( text_x(nivel * ayudas.indent, metrics) - 1.0, text_y(local, metrics), metrics.line_height, color, )); } } } if let Some(col) = ayudas.margen { out.push( View::new(Style { position: Position::Absolute, inset: Rect { left: length(text_x(col, metrics)), top: length(0.0_f32), right: auto(), bottom: length(0.0_f32), }, size: Size { width: length(1.0_f32), height: percent(1.0_f32) }, ..Default::default() }) .fill(color), ); } out } /// Hasta cuántas guías por fila. Un archivo generado puede tener sangrías /// absurdas, y cada guía es un nodo por cuadro. const GUIAS_MAX: usize = 12; /// Cuántas líneas mirar hacia atrás para la sangría de una línea en blanco. const GUIAS_ATRAS: usize = 64; /// La sangría (en columnas) que le corresponde a una línea para dibujar guías. /// Una línea en blanco hereda la de la anterior con texto. fn sangria_efectiva(state: &EditorState, linea: usize) -> usize { let desde = linea.saturating_sub(GUIAS_ATRAS); for l in (desde..=linea).rev() { let texto = state.buffer.line(l); let mut cols = 0usize; for c in texto.chars() { match c { ' ' => cols += 1, '\t' => cols += crate::plegado::ANCHO_TAB - (cols % crate::plegado::ANCHO_TAB), '\n' | '\r' => break, _ => return cols, } } } 0 } fn raya_vertical(x: f32, y: f32, alto: f32, color: Color) -> View { View::new(Style { position: Position::Absolute, inset: Rect { left: length(x.max(0.0)), top: length(y), right: auto(), bottom: auto() }, size: Size { width: length(1.0_f32), height: length(alto) }, ..Default::default() }) .fill(color) } /// Rect de tinte para una línea. Cubre el ancho completo y el alto /// exacto de la línea, pintado al color literal pasado (el caller /// elige el alpha). Posición absoluta dentro del área de contenido. fn line_tint( line: usize, color: Color, metrics: EditorMetrics, ) -> View { View::new(Style { position: Position::Absolute, inset: Rect { left: length(0.0_f32), top: length(text_y(line, metrics)), right: length(0.0_f32), bottom: auto(), }, size: Size { width: percent(1.0_f32), height: length(metrics.line_height), }, ..Default::default() }) .fill(color) } fn line_highlight( line: usize, metrics: EditorMetrics, palette: &EditorPalette, ) -> View { View::new(Style { position: Position::Absolute, inset: Rect { left: length(0.0_f32), top: length(text_y(line, metrics)), right: length(0.0_f32), bottom: auto(), }, size: Size { width: percent(1.0_f32), height: length(metrics.line_height), }, ..Default::default() }) .fill(palette.bg_current_line) } /// Línea-fantasma para una guarda: un segmento horizontal con baja /// opacidad atravesando el centro vertical de la línea. Ancho /// limitado para que parezca un susurro y no una regla. Color derivado /// de `fg_line_number` que ya está pensado como "muted". fn phantom_guard_divider( line: usize, metrics: EditorMetrics, palette: &EditorPalette, ) -> View { let h = 1.0_f32; let y = text_y(line, metrics) + (metrics.line_height - h) * 0.5; // Largo visual del divisor — generoso pero no infinito. let w = 320.0_f32; View::new(Style { position: Position::Absolute, inset: Rect { left: length(8.0_f32), top: length(y), right: auto(), bottom: auto(), }, size: Size { width: length(w), height: length(h), }, ..Default::default() }) .fill(with_alpha(palette.fg_line_number, 0.18)) } /// Recorta el rango de columnas `[a, b)` al tramo de la fila y lo rebasa a /// ella. `None` si el rango no toca esta fila. fn recortar_cols(a: usize, b: usize, f: Fila) -> Option<(usize, usize)> { let fin = f.col_fin.unwrap_or(usize::MAX); let a2 = a.max(f.col_inicio); let b2 = b.min(fin); (b2 > a2).then(|| (a2 - f.col_inicio, b2 - f.col_inicio)) } /// Los spans de syntax highlight que caen en esta fila, rebasados a ella. /// Sin ajuste de línea no copia nada. fn spans_de_fila(spans: &[Span], f: Fila) -> Cow<'_, [Span]> { if f.es_primera() && f.es_ultima() { return Cow::Borrowed(spans); } Cow::Owned( spans .iter() .filter_map(|s| { recortar_cols(s.start_col, s.end_col, f) .map(|(a, b)| Span { start_col: a, end_col: b, kind: s.kind }) }) .collect(), ) } /// Ídem para los spans rich-text. fn styled_de_fila(spans: &[StyledSpan], f: Fila) -> Cow<'_, [StyledSpan]> { if f.es_primera() && f.es_ultima() { return Cow::Borrowed(spans); } Cow::Owned( spans .iter() .filter_map(|s| { let (a, b) = recortar_cols(s.start_col, s.end_col, f)?; Some(StyledSpan { start_col: a, end_col: b, ..s.clone() }) }) .collect(), ) } /// Ídem para los runs de color del caller, que vienen en **bytes** y no en /// columnas: se traducen los bordes de la fila a bytes una vez y se recorta /// contra ellos. fn runs_de_fila<'a>( runs: &'a [(usize, usize, Color)], linea: &str, f: Fila, ) -> Cow<'a, [(usize, usize, Color)]> { if f.es_primera() && f.es_ultima() { return Cow::Borrowed(runs); } let limpio = linea.trim_end_matches(['\n', '\r']); let b0 = crate::geometria::byte_de_columna(limpio, f.col_inicio); let b1 = f.col_fin.map_or(limpio.len(), |c| crate::geometria::byte_de_columna(limpio, c)); Cow::Owned( runs.iter() .filter_map(|(a, b, c)| { let a2 = (*a).max(b0); let b2 = (*b).min(b1); (b2 > a2).then(|| (a2 - b0, b2 - b0, *c)) }) .collect(), ) } fn line_text_plain( line: usize, text: String, metrics: EditorMetrics, palette: &EditorPalette, ) -> View { View::new(Style { position: Position::Absolute, inset: Rect { left: length(PAD_X), top: length(text_y(line, metrics)), right: auto(), bottom: auto(), }, size: Size { width: length(2000.0_f32), height: length(metrics.line_height), }, align_items: Some(AlignItems::CENTER), ..Default::default() }) .text_aligned(text, metrics.font_size, palette.fg_text, Alignment::Start) .mono() } /// Renderiza una línea como secuencia de Views absolutos posicionados, /// cada uno con el color de su span. El posicionamiento horizontal usa /// `char_width` (mono); para fuentes proporcionales habría que medir /// cada token con parley (TODO). fn line_text_tokens( line: usize, text: &str, spans: &[Span], metrics: EditorMetrics, palette: &EditorPalette, syntax: &SyntaxPalette, ) -> View { // char-col → byte-offset: parley rangea por bytes, los spans por chars. let mut byte_at: Vec = Vec::with_capacity(text.len() + 1); let mut acc = 0usize; byte_at.push(0); for ch in text.chars() { acc += ch.len_utf8(); byte_at.push(acc); } let nchars = byte_at.len() - 1; // Un run de color por span no-Other (el default_color cubre el resto). let mut runs: Vec<(usize, usize, Color)> = Vec::with_capacity(spans.len()); for span in spans { if span.start_col >= nchars || matches!(span.kind, TokenKind::Other) { continue; } let end = span.end_col.min(nchars); if end <= span.start_col { continue; } runs.push((byte_at[span.start_col], byte_at[end], syntax.color(span.kind))); } // Una sola línea shapeada de una vez, multicolor, en lugar de un nodo // (+ layout parley) por token. El `+4` de gutter va en el inset del nodo. View::new(Style { position: Position::Absolute, inset: Rect { left: length(PAD_X), top: length(text_y(line, metrics)), right: auto(), bottom: auto(), }, size: Size { width: length(2000.0_f32), height: length(metrics.line_height) }, ..Default::default() }) .text_runs( text.to_string(), metrics.font_size, palette.fg_text, runs, Alignment::Start, ) .mono() } /// Como [`line_text_tokens`] pero con `(byte_start, byte_end, Color)` /// explícitos provistos por el caller (coloreo semántico propio, p. ej. un /// shell que tinta `ls`/paths/urls). El resto del texto va en `fg_text`. fn line_text_color_runs( line: usize, text: &str, runs: &[(usize, usize, Color)], metrics: EditorMetrics, palette: &EditorPalette, ) -> View { View::new(Style { position: Position::Absolute, inset: Rect { left: length(PAD_X), top: length(text_y(line, metrics)), right: auto(), bottom: auto(), }, size: Size { width: length(2000.0_f32), height: length(metrics.line_height) }, ..Default::default() }) .text_runs( text.to_string(), metrics.font_size, palette.fg_text, runs.to_vec(), Alignment::Start, ) .mono() } /// Renderiza una línea con spans rich-text ([`StyledSpan`]): construye los /// [`llimphi_text::TextSpan`] (offsets en byte, vía el mapa char→byte) y los /// pasa a `text_spans`. El fondo de cada span lo pinta [`styled_bg_rects`] /// aparte. El texto fuera de todo span va en `palette.fg_text` / /// `metrics.font_size`. Mantiene `.mono()` para que el posicionamiento por /// `char_width` (caret/selección) siga cuadrando. fn line_text_styled( line: usize, text: &str, spans: &[StyledSpan], metrics: EditorMetrics, palette: &EditorPalette, ) -> View { // char-col → byte-offset (parley rangea por bytes; los spans, por chars). let mut byte_at: Vec = Vec::with_capacity(text.len() + 1); let mut acc = 0usize; byte_at.push(0); for ch in text.chars() { acc += ch.len_utf8(); byte_at.push(acc); } let nchars = byte_at.len() - 1; let mut ts: Vec = Vec::with_capacity(spans.len()); for s in spans { if s.start_col >= nchars { continue; } let end = s.end_col.min(nchars); if end <= s.start_col { continue; } let style = llimphi_compositor::llimphi_text::TextSpanStyle { size_px: s.size_px, weight: s.weight, italic: s.italic, font_family: s.font_family.clone(), color: s.fg, underline: s.underline, strikethrough: s.strikethrough, }; ts.push(llimphi_compositor::llimphi_text::TextSpan::new( byte_at[s.start_col], byte_at[end], style, )); } View::new(Style { position: Position::Absolute, inset: Rect { left: length(PAD_X), top: length(text_y(line, metrics)), right: auto(), bottom: auto(), }, size: Size { width: length(2000.0_f32), height: length(metrics.line_height) }, ..Default::default() }) .text_spans( text.to_string(), metrics.font_size, palette.fg_text, ts, Alignment::Start, ) .mono() } /// Rects de fondo (resaltado) de los spans de una línea con `bg` definido. /// Char-cols → x/w por `char_width` (mono), mismo origen que el texto. fn styled_bg_rects( local_line: usize, spans: &[StyledSpan], metrics: EditorMetrics, ) -> Vec> { let mut out: Vec> = Vec::new(); for s in spans { let Some(bg) = s.bg else { continue }; if s.end_col <= s.start_col { continue; } let x = text_x(s.start_col, metrics); let w = (s.end_col - s.start_col) as f32 * metrics.char_width; out.push( View::new(Style { position: Position::Absolute, inset: Rect { left: length(x), top: length(text_y(local_line, metrics)), right: auto(), bottom: auto(), }, size: Size { width: length(w), height: length(metrics.line_height) }, ..Default::default() }) .fill(bg), ); } out } fn caret_rect( caret: Pos, metrics: EditorMetrics, palette: &EditorPalette, ) -> View { let x = text_x(caret.col, metrics); let y = text_y(caret.line, metrics); View::new(Style { position: Position::Absolute, inset: Rect { left: length(x), top: length(y + 2.0), right: auto(), bottom: auto(), }, size: Size { width: length(2.0_f32), height: length(metrics.line_height - 4.0) }, ..Default::default() }) .fill(palette.caret) } /// Pinta el texto en composición del IME en `(local_line, col)`: el texto /// provisional + un subrayado debajo que lo marca como no-confirmado. /// Devuelve los dos Views (texto y subrayado). Posición en coords del /// área de contenido (mismo origen que [`line_text_plain`]). fn preedit_views( local_line: usize, col: usize, text: &str, metrics: EditorMetrics, palette: &EditorPalette, ) -> Vec> { let x = text_x(col, metrics); let y = text_y(local_line, metrics); let w = (text.chars().count() as f32 * metrics.char_width).max(metrics.char_width); vec![ // Texto provisional, en el color de texto normal. View::new(Style { position: Position::Absolute, inset: Rect { left: length(x), top: length(y), right: auto(), bottom: auto(), }, size: Size { width: length(w), height: length(metrics.line_height) }, align_items: Some(AlignItems::CENTER), ..Default::default() }) .text_aligned(text.to_string(), metrics.font_size, palette.fg_text, Alignment::Start) .mono(), // Subrayado: una línea fina en el color del caret bajo el texto. View::new(Style { position: Position::Absolute, inset: Rect { left: length(x), top: length(y + metrics.line_height - 2.0), right: auto(), bottom: auto(), }, size: Size { width: length(w), height: length(1.5_f32) }, ..Default::default() }) .fill(palette.caret), ] } fn bracket_highlight( pos: Pos, metrics: EditorMetrics, palette: &EditorPalette, ) -> View { let x = text_x(pos.col, metrics); let y = text_y(pos.line, metrics); View::new(Style { position: Position::Absolute, inset: Rect { left: length(x), top: length(y), right: auto(), bottom: auto(), }, size: Size { width: length(metrics.char_width), height: length(metrics.line_height) }, ..Default::default() }) .fill(palette.bg_bracket_pair) } fn diagnostic_underline( d: &Diagnostic, v: &Ventana<'_>, metrics: EditorMetrics, palette: &EditorPalette, ) -> Vec> { let color = match d.severity { Severity::Error => palette.diag_error, Severity::Warning => palette.diag_warning, Severity::Information => palette.diag_info, Severity::Hint => palette.diag_hint, }; let mut out: Vec> = Vec::new(); let (vis_primera, vis_ultima) = v.rango_lineas(); let first = d.range.start.line.max(vis_primera); let last = d.range.end.line.min(vis_ultima); if first > last { return out; } for line in first..=last { let col_start = if line == d.range.start.line { d.range.start.col } else { 0 }; let col_end = if line == d.range.end.line { d.range.end.col } else { // Fin de línea — extendemos 1 char extra para visualizar el wrap. col_start + 1 }; if col_end <= col_start { continue; } // Subrayado de 1.5 px al pie de cada fila que el rango toca. for t in v.tramos(line, col_start, col_end) { let w = (t.col_b - t.col_a) as f32 * metrics.char_width; out.push( View::new(Style { position: Position::Absolute, inset: Rect { left: length(text_x(t.col_a, metrics)), top: length(text_y(t.local, metrics) + metrics.line_height - 2.0), right: auto(), bottom: auto(), }, size: Size { width: length(w), height: length(1.5_f32) }, ..Default::default() }) .fill(color), ); } } out } fn match_rects( state: &EditorState, start_off: usize, end_off: usize, v: &Ventana<'_>, metrics: EditorMetrics, palette: &EditorPalette, ) -> Vec> { if start_off == end_off { return vec![]; } let (start_line, start_col) = state.buffer.offset_to_pos(start_off); let (end_line, end_col) = state.buffer.offset_to_pos(end_off); let mut out: Vec> = Vec::new(); let (vis_primera, vis_ultima) = v.rango_lineas(); let first = start_line.max(vis_primera); let last = end_line.min(vis_ultima); if first > last { return out; } for line in first..=last { let line_len = state.buffer.line_len_chars(line); let col_start = if line == start_line { start_col } else { 0 }; let col_end = if line == end_line { end_col } else { line_len }; if col_end <= col_start { continue; } for t in v.tramos(line, col_start, col_end) { let x = text_x(t.col_a, metrics); let w = (t.col_b - t.col_a) as f32 * metrics.char_width; out.push( View::new(Style { position: Position::Absolute, inset: Rect { left: length(x), top: length(text_y(t.local, metrics)), right: auto(), bottom: auto(), }, size: Size { width: length(w), height: length(metrics.line_height) }, ..Default::default() }) .fill(palette.bg_match), ); } } out } fn selection_rects_for_cursor( state: &EditorState, cursor: &crate::cursor::Cursor, v: &Ventana<'_>, metrics: EditorMetrics, palette: &EditorPalette, ) -> Vec> { let (start_off, end_off) = cursor.selection_range(&state.buffer); if start_off == end_off { return vec![]; } let (start_line, start_col) = state.buffer.offset_to_pos(start_off); let (end_line, end_col) = state.buffer.offset_to_pos(end_off); let mut out: Vec> = Vec::new(); let (vis_primera, vis_ultima) = v.rango_lineas(); let first = start_line.max(vis_primera); let last = end_line.min(vis_ultima); if first > last { return out; } for line in first..=last { let line_len = state.buffer.line_len_chars(line); let col_start = if line == start_line { start_col } else { 0 }; let col_end = if line == end_line { end_col } else { line_len }; // El chorrito de un char que marca «acá sigue en el renglón de abajo» // sólo lo pinta la fila que **termina** la línea lógica: con ajuste, las // filas del medio no cierran ningún salto de línea. for t in v.tramos(line, col_start, col_end.max(col_start + 1)) { let vacio = col_end <= col_start; // La juntura entre dos párrafos, pintada hasta el margen: ver // `EditorMetrics::seleccion_hasta_el_margen`. Va antes del cálculo // del ancho porque acá no hay ancho que calcular — el rect se estira // entre los dos bordes y taffy lo resuelve. if vacio && line < end_line && metrics.seleccion_hasta_el_margen { out.push( View::new(Style { position: Position::Absolute, inset: Rect { left: length(text_x(0, metrics)), top: length(text_y(t.local, metrics)), right: length(0.0_f32), bottom: auto(), }, size: Size { width: auto(), height: length(metrics.line_height) }, ..Default::default() }) .fill(palette.bg_selection), ); continue; } let extra = if line < end_line && t.fin_de_linea { 1.0 } else { 0.0 }; let ancho_cols = if vacio { 0.0 } else { (t.col_b - t.col_a) as f32 }; let w = (ancho_cols + extra) * metrics.char_width; if w <= 0.0 { continue; } out.push( View::new(Style { position: Position::Absolute, inset: Rect { left: length(text_x(t.col_a, metrics)), top: length(text_y(t.local, metrics)), right: auto(), bottom: auto(), }, size: Size { width: length(w), height: length(metrics.line_height) }, ..Default::default() }) .fill(palette.bg_selection), ); } } out } /// Redondeo half-away-from-zero sin `std` — `f32::round` es un método de la /// stdlib, no una operación del lenguaje (misma razón que el `ceil_f32` del /// compositor; NUNCA `FloatFuncs` de kurbo, es una trampa de cfg). #[inline] fn redondear(x: f32) -> f32 { if x >= 0.0 { (x + 0.5) as i64 as f32 } else { (x - 0.5) as i64 as f32 } } #[cfg(test)] mod tests { use super::*; use crate::geometria::{Desplazamiento, Disposicion}; /// Cuenta los nodos del árbol de `View` — el número que de verdad importa: /// es lo que se le manda a taffy y a wgpu por cuadro. fn nodos(v: &View) -> usize { 1 + v.children.iter().map(nodos).sum::() } fn estado_de(lineas: usize) -> EditorState { let mut s = EditorState::new(); let mut t = String::new(); for i in 0..lineas { t.push_str("fn linea_"); t.push_str(&i.to_string()); t.push_str("() { let x = 1; }\n"); } s.set_text(&t); s } fn render(state: &EditorState, vp: &Viewport) -> View<()> { text_editor_view_viewport( state, &EditorPalette::default(), vp, OpcionesRender::default(), |_| None, ) } /// **La juntura entre dos párrafos, seleccionada, es un bloque y no un /// hilo** (`seleccion_hasta_el_margen`). /// /// Con el default —el chorrito de un carácter que marca el salto de línea— /// seleccionar tres párrafos de un `.pluma` daba tres franjas sueltas: cada /// juntura es una línea en blanco. Medido sobre el volcado: la selección se /// cortaba en cinco tramos y ahora es uno solo. #[test] fn la_linea_en_blanco_del_medio_se_selecciona_hasta_el_margen() { // "uno" / "" / "dos" — dos párrafos y su juntura. let mut st = EditorState::new(); st.set_text("uno\n\ndos\n"); st.cursor.caret = crate::cursor::Pos::new(0, 0); st.cursor.anchor = None; st.extend_selection_to(2, 3); let ancho = |m: EditorMetrics| -> f32 { let _ = m; let vp = Viewport::medido(800.0, 400.0, m); let v: View<()> = text_editor_view_viewport( &st, &EditorPalette::default(), &vp, OpcionesRender::default(), |_| None, ); // El rect de la línea 1 (la vacía): el que arranca en su `y`. let y = text_y(1, m); let mut ancho = 0.0_f32; // `auto` se distingue de un largo por el flag del propio valor // (taffy los empaqueta en un `CompactLength`, no en un enum). fn buscar(v: &View<()>, y: f32, sel: Color, out: &mut f32) { let top = v.style.inset.top.into_raw(); // Sólo los rects de SELECCIÓN: a esa misma `y` hay un tinte de // línea, el renglón activo y la capa de contenido. if v.fill == Some(sel) && !top.is_auto() && (top.value() - y).abs() < 0.01 { let w = v.style.size.width; if w.is_auto() && !v.style.inset.right.into_raw().is_auto() { *out = f32::INFINITY; // estirado entre los dos bordes } else if !w.is_auto() { *out = out.max(w.value()); } } for h in &v.children { buscar(h, y, sel, out); } } buscar(&v, y, EditorPalette::default().bg_selection, &mut ancho); ancho }; let base = EditorMetrics::for_font_size(13.0); let chorrito = ancho(base); assert!( chorrito > 0.0 && chorrito <= base.char_width * 1.5, "el default sigue siendo el chorrito de un carácter: {chorrito}" ); assert!( ancho(base.con_seleccion_hasta_el_margen(true)).is_infinite(), "con la perilla puesta, la juntura se pinta hasta el margen" ); } #[test] fn el_render_no_crece_con_el_tamano_del_archivo() { let metrics = EditorMetrics::for_font_size(13.0); let vp = Viewport::medido(800.0, 400.0, metrics); let chico = nodos(&render(&estado_de(1_000), &vp)); let enorme = nodos(&render(&estado_de(100_000), &vp)); // Ésta es la propiedad entera de la etapa: cien veces más archivo, el // mismo árbol. Antes el host pasaba `visible_lines` y el widget lo // capaba a 200 o 512 porque wgpu rechazaba el bind group — el tope era // el parche al síntoma de no virtualizar. assert_eq!(chico, enorme, "el árbol cambió con el tamaño del archivo"); // Y es chico: ~23 filas de contenido + ~23 de gutter + andamiaje. assert!(enorme < 120, "árbol de {enorme} nodos para 23 filas visibles"); } #[test] fn el_render_crece_con_el_alto_del_panel_y_no_con_una_constante() { let metrics = EditorMetrics::for_font_size(13.0); let state = estado_de(10_000); let bajo = nodos(&render(&state, &Viewport::medido(800.0, 400.0, metrics))); let alto = nodos(&render(&state, &Viewport::medido(800.0, 1400.0, metrics))); // Un panel 3,5× más alto materializa más filas. Con la constante del // host, los dos daban exactamente lo mismo: 40 líneas. assert!(alto > bajo * 2, "panel alto: {alto} nodos vs panel bajo: {bajo}"); } #[test] fn el_camino_viejo_sigue_capado_y_el_nuevo_no_lo_necesita() { let metrics = EditorMetrics::for_font_size(13.0); let state = estado_de(10_000); // Un host que pide de más por el camino viejo: el cap lo corta en 200. let viejo: View<()> = text_editor_view_full( &state, &EditorPalette::default(), metrics, 5_000, Language::Plain, &[], |_| None, ); // Por el camino nuevo, el mismo alto de panel decide, sin tope. let nuevo = render(&state, &Viewport::medido(800.0, 400.0, metrics)); assert!( nodos(&viejo) > nodos(&nuevo) * 5, "viejo {} nodos vs nuevo {}", nodos(&viejo), nodos(&nuevo) ); } #[test] fn la_barra_aparece_solo_cuando_hay_a_donde_ir() { let metrics = EditorMetrics::for_font_size(13.0); let vp = Viewport::medido(800.0, 400.0, metrics); // 10 líneas entran en 400 px: nada que indicar. let corto = Disposicion::para(10, metrics); assert!(barra_de_scroll::<()>(&vp, &corto, None).is_none()); // 10.000 no entran: aparece. let largo = Disposicion::para(10_000, metrics); assert!(barra_de_scroll::<()>(&vp, &largo, None).is_some()); // Y el árbol la lleva: un nodo más de canal, con su pulgar adentro. let con = render(&estado_de(10_000), &vp); assert!(con.children.iter().any(|c| c.children.len() == 1 && c.children[0].children.is_empty())); } #[test] fn la_barra_se_arrastra_y_mueve_el_documento() { let metrics = EditorMetrics::for_font_size(13.0); let d = Disposicion::para(10_000, metrics); let vp = Viewport::medido(800.0, 400.0, metrics); let cb: Arc Option + Send + Sync> = Arc::new(Some); let canal = barra_de_scroll(&vp, &d, Some(cb)).expect("hay barra"); // Arrastrar el pulgar 10 px de PANTALLA tiene que mover muchos más // píxeles de DOCUMENTO: 10.000 líneas comprimidas en 400 px de canal. let arrastre = llimphi_compositor::raro!(canal, drag_at).expect("se arrastra"); let salto = arrastre(llimphi_compositor::DragPhase::Move, 0.0, 10.0, 0.0, 0.0) .expect("el arrastre emite"); assert!(salto > 100.0, "arrastrar 10 px movió sólo {salto} px de documento"); // Y el factor es el que corresponde: recorrer el canal entero tiene que // recorrer el documento entero, ni más ni menos. let (alto_thumb, _, _) = llimphi_widget_scroll::thumb_geometry(0.0, d.alto_total(), 400.0); let recorrido = 400.0 - alto_thumb; let total = arrastre(llimphi_compositor::DragPhase::Move, 0.0, recorrido, 0.0, 0.0) .expect("emite"); assert!( (total - vp.max_y(&d)).abs() < d.alto_de(0), "recorrer el canal dio {total} y el documento mide {}", vp.max_y(&d) ); } #[test] fn el_click_en_el_canal_salta_una_pagina_y_sobre_el_pulgar_no() { let metrics = EditorMetrics::for_font_size(13.0); let d = Disposicion::para(10_000, metrics); let vp = Viewport::medido(800.0, 400.0, metrics); let cb: Arc Option + Send + Sync> = Arc::new(Some); let canal = barra_de_scroll(&vp, &d, Some(cb)).expect("hay barra"); let click = llimphi_compositor::raro!(canal, on_click_at).expect("se clickea"); // Con el scroll arriba del todo, el pulgar está pegado al tope: un click // bien abajo baja una página. let abajo = click(0.0, 390.0, CANAL_W, 400.0).expect("emite"); assert!(abajo > 0.0, "el click de abajo dio {abajo}"); // Y sobre el pulgar no emite nada: ese click empieza un arrastre. assert!(click(0.0, 1.0, CANAL_W, 400.0).is_none()); } #[test] fn la_rueda_la_atiende_el_editor_cuando_hay_a_quien_avisarle() { let metrics = EditorMetrics::for_font_size(13.0); let state = estado_de(10_000); let vp = Viewport::medido(800.0, 400.0, metrics); // Sin `on_scroll` el editor no toca la rueda: cae al `on_wheel` del host, // que es como venía funcionando. let sordo: View = text_editor_view_viewport( &state, &EditorPalette::default(), &vp, OpcionesRender::default(), |_| None, ); assert!(llimphi_compositor::raro!(sordo, on_scroll).is_none()); // Con `on_scroll`, la rueda la consume el editor: una muesca son tres // líneas, en píxeles. let cb: Arc Option + Send + Sync> = Arc::new(Some); let vivo: View = text_editor_view_viewport( &state, &EditorPalette::default(), &vp, OpcionesRender { on_scroll: Some(cb), ..Default::default() }, |_| None, ); let rueda = llimphi_compositor::raro!(vivo, on_scroll).expect("atiende la rueda"); let px = rueda(0.0, 1.0).expect("emite"); assert!((px - 3.0 * metrics.line_height).abs() < 0.01, "una muesca dio {px} px"); // Un delta cero no genera Msg — no hay por qué repintar. assert!(rueda(0.0, 0.0).is_none()); } #[test] fn el_editor_topado_le_suelta_la_rueda_al_ancestro() { let metrics = EditorMetrics::for_font_size(13.0); let state = estado_de(10_000); let cb: Arc Option + Send + Sync> = Arc::new(Some); let arriba = Viewport::medido(800.0, 400.0, metrics); let vista: View = text_editor_view_viewport( &state, &EditorPalette::default(), &arriba, OpcionesRender { on_scroll: Some(cb.clone()), ..Default::default() }, |_| None, ); let rueda = llimphi_compositor::raro!(vista, on_scroll).expect("atiende"); // En el tope, hacia arriba no consume; hacia abajo sí. assert!(rueda(0.0, -1.0).is_none()); assert!(rueda(0.0, 1.0).is_some()); // Y al final del documento, al revés. let d = Disposicion::para(state.line_count(), metrics); let fondo = arriba.con_desplazamiento(Desplazamiento::desde_y( arriba.max_y(&d), 0.0, &d, )); let vista: View = text_editor_view_viewport( &state, &EditorPalette::default(), &fondo, OpcionesRender { on_scroll: Some(cb), ..Default::default() }, |_| None, ); let rueda = llimphi_compositor::raro!(vista, on_scroll).expect("atiende"); assert!(rueda(0.0, 1.0).is_none()); assert!(rueda(0.0, -1.0).is_some()); } #[test] fn sin_medir_no_hay_barra() { let metrics = EditorMetrics::for_font_size(13.0); let vp = Viewport::medido(0.0, 0.0, metrics); let d = Disposicion::para(10_000, metrics); assert!(barra_de_scroll::<()>(&vp, &d, None).is_none()); } #[test] fn el_pulgar_baja_al_scrollear() { let metrics = EditorMetrics::for_font_size(13.0); let d = Disposicion::para(10_000, metrics); let arriba = Viewport::medido(800.0, 400.0, metrics); let abajo = arriba.con_desplazamiento(Desplazamiento::en_fila(9_000)); let (_, y0, _) = llimphi_widget_scroll::thumb_geometry( arriba.desplazamiento.y_documento(&d), d.alto_total(), 400.0, ); let (_, y1, _) = llimphi_widget_scroll::thumb_geometry( abajo.desplazamiento.y_documento(&d), d.alto_total(), 400.0, ); assert_eq!(y0, 0.0); assert!(y1 > y0); } // --------------------------------------------------------------- // El plegado, visto desde el render. // --------------------------------------------------------------- /// Un buffer con estructura: tres funciones con cuerpo indentado. fn estado_con_bloques(bloques: usize) -> EditorState { let mut s = EditorState::new(); let mut t = String::new(); for i in 0..bloques { t.push_str(&alloc::format!("fn f{i}() {{\n")); for j in 0..5 { t.push_str(&alloc::format!(" paso_{j}();\n")); } t.push_str("}\n"); } s.set_text(&t); s } #[test] fn plegar_saca_las_lineas_del_arbol_de_render() { let metrics = EditorMetrics::for_font_size(13.0); let vp = Viewport::medido(800.0, 400.0, metrics); let mut state = estado_con_bloques(3); let antes = nodos(&render(&state, &vp)); // La primera función encabeza en la línea 0 y su cuerpo son 5 líneas. assert!(state.alternar_pliegue(0), "la línea 0 tenía que ser plegable"); let despues = nodos(&render(&state, &vp)); // El árbol se achica: las líneas ocultas ya no generan ni número de // gutter ni texto. Es el mismo render de la etapa 1 sin tocar una sola // función de dibujo — lo único que cambió es el mapa. assert!(despues < antes, "plegar no sacó nodos: {antes} → {despues}"); } #[test] fn con_un_pliegue_el_render_empieza_en_la_linea_correcta() { let metrics = EditorMetrics::for_font_size(13.0); // Un panel de una sola fila, para poder mirar exactamente qué línea cae // en el tope. let vp = Viewport::medido(800.0, 20.0, metrics); let mut state = estado_con_bloques(3); state.alternar_pliegue(0); let d = state.disposicion(metrics); // Fila 0 = la cabecera; fila 1 = la llave de cierre (línea 6), NO la // línea 1, que está adentro del pliegue. assert_eq!(d.linea_de_fila(0).linea, 0); assert_eq!(d.linea_de_fila(1).linea, 6); assert_eq!(d.linea_de_fila(2).linea, 7); // la segunda función } #[test] fn el_click_cae_en_la_linea_que_se_ve_con_el_bloque_plegado() { let metrics = EditorMetrics::for_font_size(13.0); let mut state = estado_con_bloques(3); state.alternar_pliegue(0); let vp = Viewport::medido(800.0, 400.0, metrics); // Clickear en la segunda fila visible tiene que dar la línea 6 (la // llave), no la 1. Sin esto, plegar dejaría el caret siempre corrido. let (linea, _) = state.pos_en(&vp, PAD_X, PAD_Y + metrics.line_height + 2.0); assert_eq!(linea, 6); } #[test] fn el_gutter_dibuja_flecha_solo_donde_hay_algo_que_plegar() { let metrics = EditorMetrics::for_font_size(13.0); let vp = Viewport::medido(800.0, 400.0, metrics); let state = estado_con_bloques(3); let sin: View = text_editor_view_viewport( &state, &EditorPalette::default(), &vp, OpcionesRender::default(), |_| None, ); let cb: Arc Option + Send + Sync> = Arc::new(Some); let con: View = text_editor_view_viewport( &state, &EditorPalette::default(), &vp, OpcionesRender { on_pliegue: Some(cb), ..Default::default() }, |_| None, ); // Tres bloques plegables = tres flechas de más en el gutter. Sin // callback no se dibuja ninguna: un botón que no hace nada es peor que // no tenerlo. assert_eq!(nodos(&con), nodos(&sin) + 3); } #[test] fn el_pliegue_se_deshace_solo_si_el_bloque_deja_de_existir() { let metrics = EditorMetrics::for_font_size(13.0); let mut state = estado_con_bloques(2); let lineas = state.line_count(); state.alternar_pliegue(0); // El cuerpo son las cinco líneas indentadas: la llave de cierre vuelve // a la sangría de la firma, así que cierra el bloque y queda visible. assert_eq!(state.disposicion(metrics).filas(), lineas - 5); // El usuario borra el cuerpo de la primera función: la cabecera deja de // encabezar nada. El pliegue no puede seguir escondiendo las líneas de // otro, así que no produce tramo. state.set_text("fn f0() {}\nfn f1() {\n paso();\n}\n"); let d = state.disposicion(metrics); assert!(d.pliegues().is_empty(), "{:?}", d.pliegues()); assert_eq!(d.filas(), state.line_count()); } #[test] fn revelar_abre_lo_que_haga_falta_para_ver_una_linea() { let mut state = estado_con_bloques(3); state.alternar_pliegue(0); assert!(state.esta_plegado(0)); // Saltar a la línea 3, que está adentro del pliegue: se abre. Aterrizar // en un pliegue y no ver el caret es peor que no saltar. state.revelar(3); assert!(!state.esta_plegado(0)); // Y revelar algo de afuera no toca nada. state.alternar_pliegue(0); state.revelar(8); assert!(state.esta_plegado(0)); } #[test] fn plegar_todo_toma_los_de_afuera_y_no_los_anidados() { let mut state = EditorState::new(); state.set_text("impl X {\n fn a() {\n uno();\n }\n}\nfn b() {\n dos();\n}\n"); state.plegar_todo(); // Dos bloques externos: el `impl` y `fn b`. El `fn a` está adentro del // primero, y plegarlo además no cambiaría nada de lo que se ve. assert_eq!(state.plegados(), &[0, 5]); } #[test] fn plegar_donde_esta_el_caret_lo_sube_a_la_cabecera_y_no_se_deshace() { let mut state = estado_con_bloques(2); state.set_caret_at(3, 4); // adentro del cuerpo de la primera función state.alternar_pliegue(0); // El pliegue queda hecho — plegar no puede desarmarse solo — y el caret // sube a la cabecera, que es lo único visible de ese bloque. assert!(state.esta_plegado(0)); assert_eq!(state.cursor.caret.line, 0); } #[test] fn saltar_adentro_de_un_pliegue_lo_abre() { let mut state = estado_con_bloques(2); state.alternar_pliegue(0); // Un salto pone el caret y después revela: al revés que plegar. state.set_caret_at(3, 0); state.revelar_caret(); assert!(!state.esta_plegado(0)); assert_eq!(state.cursor.caret.line, 3); } #[test] fn el_scroll_fino_corre_la_capa_y_no_las_lineas() { let metrics = EditorMetrics::for_font_size(13.0); let state = estado_de(1_000); let vp = Viewport::medido(800.0, 400.0, metrics); let quieto = nodos(&render(&state, &vp)); let fino = vp.con_desplazamiento(Desplazamiento { fila_ancla: 10, fino_px: 7.0, x_px: 0.0, }); // Desplazarse medio renglón no agrega ni saca nodos: el corrimiento lo // absorbe la capa contenedora, no las veinte funciones de dibujo. assert_eq!(nodos(&render(&state, &fino)), quieto); } #[test] fn con_el_panel_sin_medir_todavia_igual_se_pinta_algo() { let metrics = EditorMetrics::for_font_size(13.0); let state = estado_de(1_000); // El primer cuadro: `TamanoVisible` todavía no publicó nada. let vp = Viewport::medido(0.0, 0.0, metrics); let d = Disposicion::para(state.line_count(), metrics); assert_eq!(vp.rango_visible(&d).cuantas(), crate::geometria::FILAS_SIN_MEDIDA + 2); assert!(nodos(&render(&state, &vp)) > 10); } // --------------------------------------------------------------- // El ajuste de línea, visto desde el render. // --------------------------------------------------------------- /// Un buffer de prosa: `lineas` párrafos largos, del tipo que no entra en /// el panel y hoy se va a la derecha. fn estado_de_prosa(lineas: usize, palabras: usize) -> EditorState { let mut s = EditorState::new(); let mut t = String::new(); for _ in 0..lineas { for j in 0..palabras { t.push_str(&alloc::format!("palabra{j} ")); } t.push('\n'); } s.set_text(&t); s } /// El estado con el ajuste ya armado contra un panel de `ancho` px. fn ajustado(mut s: EditorState, ancho: f32, metrics: EditorMetrics) -> EditorState { s.ajuste_de_linea = true; s.preparar_ajuste(ancho, metrics); s } #[test] fn sin_ajuste_una_linea_larga_sigue_siendo_un_solo_renglon() { // El brazo de control: el mismo buffer, sin la perilla puesta, se // comporta exactamente como antes. let metrics = EditorMetrics::for_font_size(13.0); let s = estado_de_prosa(3, 40); assert_eq!(s.disposicion(metrics).filas(), 4); // 3 párrafos + la vacía final } #[test] fn el_ajuste_reparte_la_linea_sin_perder_ni_repetir_un_caracter() { let metrics = EditorMetrics::for_font_size(13.0); let s = ajustado(estado_de_prosa(1, 60), 400.0, metrics); let d = s.disposicion(metrics); assert!(d.ajustada()); // 400 px de panel a 7,8 px por char son 49 columnas: un párrafo de ~540 // caracteres no entra en un renglón ni en dos. assert!(d.filas() > 8, "{} filas", d.filas()); // Y la propiedad que importa: las filas, pegadas en orden, son la línea // entera. Ni un char de menos (texto que desaparece) ni uno de más // (texto duplicado en el corte). let linea = s.buffer.line(0); let junto: String = (0..d.filas()) .filter(|f| d.linea_de_fila(*f).linea == 0) .map(|f| texto_de_fila(&linea, d.linea_de_fila(f)).into_owned()) .collect(); assert_eq!(junto, linea.trim_end_matches('\n')); } #[test] fn el_gutter_numera_la_linea_una_sola_vez_aunque_ocupe_diez_renglones() { let metrics = EditorMetrics::for_font_size(13.0); let s = ajustado(estado_de_prosa(1, 60), 400.0, metrics); let d = s.disposicion(metrics); let v = Ventana::nueva(&d, 0, d.filas()); let numeradas = v.recorrer_filas().filter(|(_, f)| f.es_primera()).count(); // Dos líneas lógicas (el párrafo y la vacía del final), diez y pico de // filas: dos números. assert_eq!(numeradas, 2); assert!(v.filas() > numeradas); } #[test] fn el_caret_al_final_de_la_linea_ajustada_se_pinta_en_su_renglon() { let metrics = EditorMetrics::for_font_size(13.0); let s = ajustado(estado_de_prosa(1, 60), 400.0, metrics); let d = s.disposicion(metrics); let v = Ventana::nueva(&d, 0, d.filas()); let fin = s.buffer.line_len_chars(0).saturating_sub(1); let (local, col) = v.ubicar(0, fin).expect("el final se ve"); // Está varios renglones más abajo que el principio de la línea... assert!(local > 5, "el caret quedó en el renglón {local}"); // ...y su columna está rebasada a ese renglón, no es la del documento. assert!(col < 60, "columna {col} sin rebasar"); assert_eq!(v.fila_de(local).col_inicio + col, fin); } #[test] fn la_seleccion_de_una_linea_ajustada_se_parte_en_un_rectangulo_por_renglon() { let metrics = EditorMetrics::for_font_size(13.0); let s = ajustado(estado_de_prosa(1, 60), 400.0, metrics); let d = s.disposicion(metrics); let v = Ventana::nueva(&d, 0, d.filas()); let fin = s.buffer.line_len_chars(0).saturating_sub(1); let tramos = v.tramos(0, 0, fin); assert!(tramos.len() > 5, "{} tramos", tramos.len()); // Uno por renglón, en orden, y ninguno se sale de su fila. for (i, t) in tramos.iter().enumerate() { assert_eq!(t.local, i); assert!(t.col_b > t.col_a); } // El chorrito de fin de línea es de la última y de ninguna otra. assert_eq!(tramos.iter().filter(|t| t.fin_de_linea).count(), 1); assert!(tramos.last().expect("hay tramos").fin_de_linea); } #[test] fn sin_ajuste_un_rango_de_una_linea_da_exactamente_el_rectangulo_de_antes() { let metrics = EditorMetrics::for_font_size(13.0); let s = estado_de(100); let d = s.disposicion(metrics); let v = Ventana::nueva(&d, 0, 20); let t = v.tramos(3, 2, 9); assert_eq!(t.len(), 1); assert_eq!((t[0].local, t[0].col_a, t[0].col_b, t[0].fin_de_linea), (3, 2, 9, true)); } #[test] fn con_ajuste_el_render_sigue_sin_crecer_con_el_archivo() { // La virtualización no se pierde por ajustar: el mapa crece con el // archivo, el árbol de `View` no. let metrics = EditorMetrics::for_font_size(13.0); let vp = Viewport::medido(400.0, 400.0, metrics); let chico = nodos(&render(&ajustado(estado_de_prosa(50, 40), 400.0, metrics), &vp)); let grande = nodos(&render(&ajustado(estado_de_prosa(5_000, 40), 400.0, metrics), &vp)); assert_eq!(chico, grande); } #[test] fn el_ajuste_se_rearma_cuando_cambia_el_ancho_y_no_cuando_no() { let metrics = EditorMetrics::for_font_size(13.0); let mut s = ajustado(estado_de_prosa(1, 60), 400.0, metrics); let filas_angosto = s.disposicion(metrics).filas(); // El mismo ancho no rearma nada — y sobre todo no cambia el mapa. s.preparar_ajuste(400.0, metrics); assert_eq!(s.disposicion(metrics).filas(), filas_angosto); // Un panel del doble de ancho reparte la misma línea en la mitad de // renglones. Ésta es la mitad que se rompe si el mapa no se invalida. s.preparar_ajuste(900.0, metrics); let filas_ancho = s.disposicion(metrics).filas(); assert!(filas_ancho < filas_angosto, "{filas_ancho} vs {filas_angosto}"); } #[test] fn apagar_el_ajuste_devuelve_el_documento_a_una_fila_por_linea() { let metrics = EditorMetrics::for_font_size(13.0); let mut s = ajustado(estado_de_prosa(3, 60), 400.0, metrics); assert!(s.disposicion(metrics).filas() > 4); assert!(!s.alternar_ajuste()); assert_eq!(s.disposicion(metrics).filas(), 4); } #[test] fn editar_una_linea_ajustada_rearma_el_mapa() { // Sin invalidar por `edit_seq`, el mapa viejo sigue cortando donde el // texto ya no está — y eso no se ve como un error, se ve como texto // pintado en el renglón equivocado. let metrics = EditorMetrics::for_font_size(13.0); let mut s = ajustado(estado_de_prosa(1, 60), 400.0, metrics); let antes = s.disposicion(metrics).filas(); s.set_text("corto\n"); s.preparar_ajuste(400.0, metrics); let d = s.disposicion(metrics); assert!(!d.ajustada(), "el mapa viejo sobrevivió a la edición"); assert!(d.filas() < antes); } // --------------------------------------------------------------- // El contexto pegado arriba. // --------------------------------------------------------------- fn render_con( state: &EditorState, vp: &Viewport, pegajosas: usize, on_pegajosa: Option Option + Send + Sync>>, ) -> View { text_editor_view_viewport( state, &EditorPalette::default(), vp, OpcionesRender { pegajosas, on_pegajosa, ..Default::default() }, |_| None, ) } #[test] fn el_contexto_son_los_ancestros_del_renglon_de_arriba() { let mut s = EditorState::new(); s.set_text(&estructura()); // Parado en el cuerpo del `if` de la segunda función. assert_eq!(s.lineas_pegajosas(4, 4), alloc::vec![0, 1, 2, 3]); // Y en la primera línea no hay nada arriba que pegar. assert!(s.lineas_pegajosas(0, 4).is_empty()); } /// `mod` → `impl` → `fn` → `if` → cuerpo, cada uno un nivel más adentro. fn estructura() -> String { alloc::string::String::from( "mod uno {\n impl Dos {\n fn tres() {\n if x {\n hacer();\n }\n }\n }\n}\n", ) } #[test] fn cuando_no_entran_todos_se_quedan_los_de_adentro() { // El `fn` en el que estás importa más que el `mod` de arriba de todo. let mut s = EditorState::new(); s.set_text(&estructura()); assert_eq!(s.lineas_pegajosas(4, 2), alloc::vec![2, 3]); assert!(s.lineas_pegajosas(4, 0).is_empty()); } #[test] fn sin_pegajosas_el_arbol_es_exactamente_el_de_antes() { // El brazo de control: la opción en 0 no agrega un solo nodo. Es lo que // deja tranquilos a los otros ocho consumidores del widget, que no la // pasan. let metrics = EditorMetrics::for_font_size(13.0); let mut s = EditorState::new(); s.set_text(&estructura().repeat(40)); let vp = Viewport::medido(800.0, 400.0, metrics) .con_desplazamiento(Desplazamiento::en_fila(100)); let sin = nodos(&render_con::<()>(&s, &vp, 0, None)); let con = nodos(&render_con::<()>(&s, &vp, 4, None)); assert_eq!(sin, nodos(&render(&s, &vp))); assert!(con > sin, "la banda no agregó nada: {con} vs {sin}"); } #[test] fn la_banda_lleva_a_la_linea_que_se_clickeo() { let metrics = EditorMetrics::for_font_size(13.0); let mut s = EditorState::new(); s.set_text(&estructura().repeat(40)); let vp = Viewport::medido(800.0, 400.0, metrics) .con_desplazamiento(Desplazamiento::en_fila(13)); let cb: Arc Option + Send + Sync> = Arc::new(Some); let vista = render_con(&s, &vp, 4, Some(cb)); // La banda es el hijo absoluto que lleva el callback de click. let banda = vista .children .iter() .find(|c| { c.semantics.as_ref().and_then(|s| s.label.as_deref()) == Some("Contexto") }) .expect("hay banda"); let click = llimphi_compositor::raro!(banda, on_click_at).expect("se clickea"); // Clickear la segunda fila de la banda lleva a su línea, no a la primera. let esperadas = s.lineas_pegajosas(13, 4); assert!(esperadas.len() >= 2, "{esperadas:?}"); assert_eq!(click(0.0, metrics.line_height * 1.5, 800.0, 40.0), Some(esperadas[1])); // Y más abajo del último encabezado se clampea al último, no se va del // vector. assert_eq!(click(0.0, 9_999.0, 800.0, 40.0), esperadas.last().copied()); } #[test] fn la_banda_no_aparece_arriba_de_todo() { let metrics = EditorMetrics::for_font_size(13.0); let mut s = EditorState::new(); s.set_text(&estructura().repeat(40)); let vp = Viewport::medido(800.0, 400.0, metrics); // Sin scrollear, el renglón de arriba es la línea 0: no hay ancestros. let sin_scroll = nodos(&render_con::<()>(&s, &vp, 4, None)); assert_eq!(sin_scroll, nodos(&render_con::<()>(&s, &vp, 0, None))); } // --------------------------------------------------------------- // La franja de marcas del borde derecho. // --------------------------------------------------------------- fn franja<'a, Msg: Clone + 'static>(v: &'a View) -> Option<&'a View> { v.children .iter() .find(|c| c.semantics.as_ref().and_then(|s| s.label.as_deref()) == Some("Marcas del archivo")) } fn diag(linea: usize, sev: Severity) -> Diagnostic { Diagnostic { range: crate::diagnostics::DiagnosticRange { start: crate::cursor::Pos::new(linea, 0), end: crate::cursor::Pos::new(linea, 3), }, severity: sev, message: alloc::string::String::from("x"), source: None, } } #[test] fn sin_diagnostics_ni_matches_no_hay_franja() { // Para decir «estoy acá» ya está el pulgar de la barra: una capa más // que no marca nada es un nodo por cuadro a cambio de nada. let metrics = EditorMetrics::for_font_size(13.0); let vp = Viewport::medido(800.0, 400.0, metrics); assert!(franja(&render(&estado_de(1_000), &vp)).is_none()); } #[test] fn la_franja_marca_cada_diagnostico_y_el_caret() { let metrics = EditorMetrics::for_font_size(13.0); let vp = Viewport::medido(800.0, 400.0, metrics); let mut s = estado_de(1_000); // `set_text` deja el caret al final del buffer; para que la rayita del // caret tenga un lugar predecible, lo mandamos arriba. s.set_caret_at(0, 0); s.set_diagnostics(alloc::vec![ diag(0, Severity::Error), diag(400, Severity::Warning), diag(900, Severity::Hint), ]); let vista = render(&s, &vp); let f = franja(&vista).expect("hay franja"); // Tres marcas + la rayita del caret. assert_eq!(f.children.len(), 4); // Y caen donde corresponde: la fracción del archivo por el alto del // panel, cuantizada a la cubeta de 2 px. Mil una filas en 400 px: la // línea 400 cae en 158, la 900 en 358. assert_eq!(f.children[0].style.inset.top, length(0.0_f32)); assert_eq!(f.children[1].style.inset.top, length(158.0_f32)); assert_eq!(f.children[2].style.inset.top, length(358.0_f32)); // Y la rayita del caret, que está en la línea 0. assert_eq!(f.children[3].style.inset.top, length(0.0_f32)); } #[test] fn la_franja_funde_las_marcas_que_caen_en_el_mismo_pixel() { // Sin esto, un archivo con miles de advertencias produce miles de nodos // por cuadro — exactamente el costo que la etapa 1 sacó del render. Una // franja no puede mostrar más marcas que píxeles tiene. let metrics = EditorMetrics::for_font_size(13.0); let vp = Viewport::medido(800.0, 400.0, metrics); let mut s = estado_de(5_000); let todos: Vec = (0..5_000).map(|l| diag(l, Severity::Warning)).collect(); s.set_diagnostics(todos); let vista = render(&s, &vp); let f = franja(&vista).expect("hay franja"); // 400 px de panel con marcas de 2 px: 200 cubetas como mucho, más el // caret. Cinco mil diagnósticos NO son cinco mil nodos. assert!(f.children.len() <= 201, "{} marcas", f.children.len()); assert!(f.children.len() > 100, "se fundió de más: {}", f.children.len()); } #[test] fn en_el_mismo_pixel_gana_el_mas_grave() { // Un error no se puede perder detrás de una advertencia que cayó en el // mismo renglón de la franja. let metrics = EditorMetrics::for_font_size(13.0); let vp = Viewport::medido(800.0, 400.0, metrics); let paleta = EditorPalette::default(); let mut s = estado_de(10_000); // Diez mil filas en 400 px son cincuenta líneas por cubeta: dos líneas // contiguas caen sí o sí en la misma. s.set_diagnostics(alloc::vec![diag(5_010, Severity::Warning), diag(5_011, Severity::Error)]); let vista = render(&s, &vp); let f = franja(&vista).expect("hay franja"); assert_eq!(f.children.len(), 2, "no se fundieron"); // la marca + el caret assert_eq!(f.children[0].fill, Some(paleta.diag_error)); } #[test] fn la_barra_se_pinta_encima_de_la_franja_y_sigue_siendo_la_que_se_agarra() { // Comparten el canal derecho. Si la franja quedara arriba, se comería // el arrastre del pulgar. let metrics = EditorMetrics::for_font_size(13.0); let vp = Viewport::medido(800.0, 400.0, metrics); let mut s = estado_de(10_000); s.set_diagnostics(alloc::vec![diag(5_000, Severity::Error)]); let cb: Arc Option + Send + Sync> = Arc::new(Some); let vista = text_editor_view_viewport( &s, &EditorPalette::default(), &vp, OpcionesRender { on_scroll: Some(cb), ..Default::default() }, |_| None, ); let i_franja = vista .children .iter() .position(|c| c.semantics.as_ref().and_then(|s| s.label.as_deref()) == Some("Marcas del archivo")) .expect("hay franja"); // `rposition` y no `position`: el área de texto también se arrastra // (es la selección con el mouse), y es el primer hijo con `drag_at`. let i_barra = vista .children .iter() .rposition(|c| llimphi_compositor::raro!(c, drag_at).is_some()) .expect("hay barra"); assert!(i_franja < i_barra, "la franja tapa la barra"); } // --------------------------------------------------------------- // Las ayudas de lectura: guías de sangría y margen de columna. // --------------------------------------------------------------- fn con_ayudas( state: &EditorState, vp: &Viewport, guias: bool, margen: Option, ) -> View { text_editor_view_viewport( state, &EditorPalette::default(), vp, OpcionesRender { guias_de_sangria: guias, margen_columna: margen, ..Default::default() }, |_| None, ) } fn estado_sangrado() -> EditorState { let mut s = EditorState::new(); // Sangrías de 0, 2, 4, 6 y 8 columnas, con un renglón vacío adentro. s.set_text("a\n b\n c\n\n d\n e\n"); s } #[test] fn sin_ayudas_el_arbol_es_exactamente_el_de_antes() { // El brazo de control: las dos opciones apagadas no agregan un nodo. Es // lo que deja tranquilos a los otros consumidores del widget, que no // las pasan. let metrics = EditorMetrics::for_font_size(13.0); let vp = Viewport::medido(800.0, 400.0, metrics); let s = estado_sangrado(); assert_eq!(nodos(&con_ayudas::<()>(&s, &vp, false, None)), nodos(&render(&s, &vp))); } #[test] fn hay_una_guia_por_nivel_de_adentro_y_ninguna_bajo_el_texto() { // Con sangría de 2 (el default), una línea a 4 columnas tiene UNA guía // —la de la columna 2—: la del nivel 4 caería justo debajo de su primer // carácter, que es una raya y no una guía. let metrics = EditorMetrics::for_font_size(13.0); let vp = Viewport::medido(800.0, 400.0, metrics); let s = estado_sangrado(); let con = nodos(&con_ayudas::<()>(&s, &vp, true, None)); let sin = nodos(&con_ayudas::<()>(&s, &vp, false, None)); // Siete renglones (el último es el vacío del final del archivo), con // sangrías 0, 2, 4, (vacía→4), 6, 8 y (vacía→8): 0+0+1+1+2+3+3. assert_eq!(con - sin, 10, "guías dibujadas: {}", con - sin); } #[test] fn una_linea_en_blanco_hereda_la_sangria_de_la_anterior() { // Sin esto, un renglón vacío en medio de una función corta todas las // guías — justo donde más se las mira. let mut s = EditorState::new(); s.set_text("a\n b\n\n c\n"); assert_eq!(sangria_efectiva(&s, 2), 6); // Y arriba de todo, sin nada atrás, no hereda nada. assert_eq!(sangria_efectiva(&s, 0), 0); } #[test] fn el_margen_es_una_sola_raya_para_todo_el_panel() { let metrics = EditorMetrics::for_font_size(13.0); let vp = Viewport::medido(800.0, 400.0, metrics); let s = estado_de(1_000); let con = nodos(&con_ayudas::<()>(&s, &vp, false, Some(100))); let sin = nodos(&con_ayudas::<()>(&s, &vp, false, None)); // Una raya, no una por renglón: el costo del margen no crece con el // alto del panel. assert_eq!(con - sin, 1); } #[test] fn las_guias_no_crecen_con_el_archivo() { // Mismo contrato que todo el resto del render: el costo depende del // alto del panel, no del tamaño del archivo. let metrics = EditorMetrics::for_font_size(13.0); let vp = Viewport::medido(800.0, 400.0, metrics); let mut chico = EditorState::new(); chico.set_text(&" hondo();\n".repeat(100)); let mut enorme = EditorState::new(); enorme.set_text(&" hondo();\n".repeat(50_000)); assert_eq!( nodos(&con_ayudas::<()>(&chico, &vp, true, Some(100))), nodos(&con_ayudas::<()>(&enorme, &vp, true, Some(100))) ); } }