tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ main widgets/text-editor/src/view.rs Rust · 3559 lines · 141.7 KB History Who touched what raw

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

1//! Render del editor. Layout: gutter izquierdo (line numbers) + área
2//! principal (texto + selección como rects + caret bloque). El scroll
3//! vertical es implícito por viewport — el caller decide cuántas líneas
4//! caben en el `height` que pasa.
5//!
6//! Limitaciones del PMV de render:
7//! - **Char width fijo** — asume fuente monoespaciada y un ancho de
8//!   carácter en píxeles fijo. Para CJK / proportional el caret y la
9//!   selección se desalinean. Para texto ASCII monoespaciado es exacto.
10//! - **Selección multilínea** se pinta como un rect por línea afectada
11//!   (sin "rio" continuo); estilo Sublime Text / antiguo, lectura clara.
12//! - **Sin syntax highlight todavía** — eso vive en su propio bloque y
13//!   requiere `llimphi-text` rich (Vec<Run>); aquí cada línea va
14//!   monocolor `fg_text`.
15
16use alloc::borrow::Cow;
17use alloc::string::{String, ToString};
18use alloc::vec;
19use alloc::sync::Arc;
20use alloc::vec::Vec;
21
22use llimphi_compositor::llimphi_layout::taffy::{
23    prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style},
24    AlignItems,
25};
26use llimphi_compositor::peniko::Color;
27use llimphi_compositor::llimphi_text::Alignment;
28use llimphi_compositor::View;
29
30use crate::cursor::Pos;
31use crate::diagnostics::{Diagnostic, Severity};
32use crate::geometria::{texto_de_fila, Fila, Monoespaciada, Viewport};
33use llimphi_widget_scroll::TamanoVisible;
34use crate::highlight::{Language, Span, SyntaxPalette, TokenKind};
35use crate::state::EditorState;
36
37/// Tope de líneas que la variante embebida (`text_editor_view_colored`)
38/// renderiza de una. La virtualización del editor-de-archivos capa a 200 para
39/// no generar miles de Views (wgpu rechaza el bind group); pero la variante
40/// embebida deja el scroll al contenedor de afuera y necesita pintar TODAS sus
41/// líneas (si no, la mitad de abajo queda sin pintar = negro al anclar el panel
42/// al fondo). Este tope es sólo la red de seguridad de wgpu — el caller acota el
43/// total real (el shell, por su `MAX_VISIBLE = 400`). Probado: ~400 líneas
44/// renderizan sin que wgpu rechace nada (el render plano viejo ya lo hacía).
45pub const EMBEDDED_LINE_CAP: usize = 512;
46
47/// Paleta del editor. Defaults dark.
48#[derive(Debug, Clone, Copy)]
49pub struct EditorPalette {
50    pub bg: Color,
51    pub bg_gutter: Color,
52    pub bg_selection: Color,
53    pub bg_current_line: Color,
54    pub fg_text: Color,
55    pub fg_line_number: Color,
56    pub fg_line_number_active: Color,
57    pub caret: Color,
58    /// Fondo del bracket bajo el cursor + su par. Un acento sutil.
59    pub bg_bracket_pair: Color,
60    /// Fondo de cada match del find activo.
61    pub bg_match: Color,
62    /// Subrayado de diagnostic — Error.
63    pub diag_error: Color,
64    /// Subrayado de diagnostic — Warning.
65    pub diag_warning: Color,
66    /// Subrayado de diagnostic — Information.
67    pub diag_info: Color,
68    /// Subrayado de diagnostic — Hint.
69    pub diag_hint: Color,
70}
71
72impl Default for EditorPalette {
73    fn default() -> Self {
74        Self::from_theme(&llimphi_theme::Theme::dark())
75    }
76}
77
78impl EditorPalette {
79    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
80        // Reutilizamos slots del theme; los que no existen como semánticos
81        // se derivan con `mix`/transparencia conceptual.
82        Self {
83            bg: t.bg_input,
84            bg_gutter: t.bg_panel,
85            bg_selection: t.bg_selected,
86            bg_current_line: t.bg_panel_alt,
87            fg_text: t.fg_text,
88            fg_line_number: t.fg_muted,
89            fg_line_number_active: t.fg_text,
90            caret: t.accent,
91            bg_bracket_pair: t.bg_button_hover,
92            bg_match: t.bg_button_hover,
93            diag_error: t.fg_destructive,
94            diag_warning: Color::from_rgb8(229, 192, 123),
95            diag_info: Color::from_rgb8(97, 175, 239),
96            diag_hint: t.fg_muted,
97        }
98    }
99}
100
101/// Cómo renderizar la columna izquierda del editor.
102///
103/// - [`GutterStyle::Numbers`] es el comportamiento clásico de IDE:
104///   "1", "2", "3"… alineados a la derecha del gutter.
105/// - [`GutterStyle::Phantom`] suprime los números y dibuja en su lugar
106///   un tick **muy sutil** por línea (un pequeño segmento horizontal
107///   con baja opacidad). Sirve para prosa narrativa donde el número de
108///   línea es ruido — la línea sigue estando, pero "fingiendo no
109///   estar". El gutter en este modo se acorta a un sliver fino.
110#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
111pub enum GutterStyle {
112    #[default]
113    Numbers,
114    Phantom,
115    /// Sin gutter: ancho 0, nada dibujado. Para vistas tipo terminal donde
116    /// numerar líneas es ruido.
117    None,
118}
119
120/// Métricas del editor — todo derivado del `font_size`. Cambiar la
121/// fuente requiere recalcular `char_width` empíricamente para la mono
122/// que use llimphi-text; los valores aquí son razonables para
123/// `font_size = 12` con la mono default de parley.
124#[derive(Debug, Clone, Copy, PartialEq)]
125pub struct EditorMetrics {
126    pub font_size: f32,
127    /// Alto de cada línea en píxeles (font_size * line_height_ratio).
128    pub line_height: f32,
129    /// Ancho promedio de un char (mono). Si la fuente no es mono, esto
130    /// es sólo una aproximación.
131    pub char_width: f32,
132    /// Ancho del gutter (incluye padding interno).
133    pub gutter_width: f32,
134    /// Cómo se pinta el gutter. Default [`GutterStyle::Numbers`] — el
135    /// comportamiento clásico se conserva para callers existentes.
136    pub gutter_style: GutterStyle,
137    /// **Una línea vacía en el medio de la selección se pinta hasta el margen**
138    /// en vez de con el chorrito de un carácter que marca el salto de línea.
139    ///
140    /// En un editor de código el chorrito es lo correcto: las líneas en blanco
141    /// son pocas y se leen como lo que son. En uno de **prosa** cada párrafo
142    /// está separado por una línea en blanco, así que seleccionar cuatro
143    /// párrafos pintaba cuatro franjas sueltas con un hilito entre medio — el
144    /// operador lo reportó el 2026-09-14 como «sólo se selecciona un
145    /// interlineado, las líneas impares». Opt-in: el default es el chorrito.
146    pub seleccion_hasta_el_margen: bool,
147    /// Si `true`, cada línea **guarda** (índices en
148    /// `EditorState::guard_lines`) recibe un segmento horizontal con
149    /// baja opacidad atravesando su centro — un divisor fantasma que
150    /// sugiere "aquí termina un bloque" sin gritar. Sin guardas, esto
151    /// no hace nada visible. Default `false`: comportamiento IDE
152    /// clásico.
153    pub phantom_guard_lines: bool,
154}
155
156impl Default for EditorMetrics {
157    fn default() -> Self {
158        Self::for_font_size(12.0)
159    }
160}
161
162impl EditorMetrics {
163    pub const fn for_font_size(font_size: f32) -> Self {
164        Self {
165            font_size,
166            line_height: font_size * 1.4,
167            char_width: font_size * 0.6,
168            gutter_width: font_size * 3.5,
169            gutter_style: GutterStyle::Numbers,
170            phantom_guard_lines: false,
171            seleccion_hasta_el_margen: false,
172        }
173    }
174
175    /// Variante "prosa": gutter fantasma (ticks sutiles, sin números) +
176    /// divisores fantasma en cada guarda. Ancho del gutter reducido a
177    /// un sliver porque ya no necesita acomodar dígitos.
178    ///
179    /// Pensado para editores narrativos tipo `cuerpo_ide` donde el
180    /// número de línea es ruido y las junctions están marcadas como
181    /// guardas.
182    pub const fn prosa(font_size: f32) -> Self {
183        Self {
184            font_size,
185            line_height: font_size * 1.4,
186            char_width: font_size * 0.6,
187            gutter_width: font_size * 1.0,
188            gutter_style: GutterStyle::Phantom,
189            phantom_guard_lines: true,
190            seleccion_hasta_el_margen: false,
191        }
192    }
193
194    /// Variante "terminal": sin gutter (ancho 0, sin números ni ticks) — para
195    /// pintar la salida de un PTY como texto plano donde numerar es ruido.
196    pub const fn terminal(font_size: f32) -> Self {
197        Self {
198            font_size,
199            line_height: font_size * 1.4,
200            char_width: font_size * 0.6,
201            gutter_width: 0.0,
202            gutter_style: GutterStyle::None,
203            phantom_guard_lines: false,
204            seleccion_hasta_el_margen: false,
205        }
206    }
207
208    /// Ver [`Self::seleccion_hasta_el_margen`]: la selección de una línea vacía
209    /// intermedia se lee como un bloque y no como un hilo.
210    pub const fn con_seleccion_hasta_el_margen(mut self, on: bool) -> Self {
211        self.seleccion_hasta_el_margen = on;
212        self
213    }
214
215    /// Convierte coords locales del **área de contenido** (no del gutter)
216    /// a `(line, col)` absolutas en el buffer. `local_x` se mide desde el
217    /// borde izquierdo del área de texto (sin el padding interno de 4 px);
218    /// `local_y` desde la primera línea visible.
219    ///
220    /// Devuelve coordenadas siempre dentro del buffer — el caller
221    /// generalmente las pasa a `EditorState::set_caret_at` que clampea
222    /// `col` al ancho real de la línea.
223    pub fn screen_to_pos(self, local_x: f32, local_y: f32, scroll_offset: usize) -> (usize, usize) {
224        let line_local = ((local_y - PAD_Y).max(0.0) / self.line_height) as usize;
225        let col = redondear(((local_x - PAD_X).max(0.0)) / self.char_width) as usize;
226        (scroll_offset + line_local, col)
227    }
228}
229
230/// Sangría izquierda del área de texto (px): aire entre el gutter y el
231/// primer carácter. El caret, la selección, el preedit y `screen_to_pos`
232/// usan la misma constante para no desalinearse.
233/// Sangría del texto respecto del borde del panel. Público porque el tope del
234/// scroll horizontal lo calcula el host (ver `ancho_del_texto` en pluma) y
235/// tiene que sumar el mismo margen que el dibujo.
236pub const PAD_X: f32 = 10.0;
237/// Aire vertical arriba de la primera línea (px). El gutter lo respeta para
238/// que los números queden a la par del texto.
239///
240/// **Pública** desde el 2026-09-14: quien pinta ENCIMA del editor —un overlay,
241/// una banda que tiene que empezar donde empieza el renglón— necesita el mismo
242/// origen que el texto, y sin esto lo adivina. Pluma la adivinó en cero y sus
243/// bandas salían 7 px arriba del tinte de la línea con la que tenían que
244/// alinearse.
245pub const PAD_Y: f32 = 7.0;
246/// Margen izquierdo del número de línea (px) — separa del borde del panel.
247const GUTTER_PAD_L: f32 = 6.0;
248/// Respiro entre el número de línea y el texto (px).
249const GUTTER_PAD_R: f32 = 8.0;
250
251/// X (px, dentro del área de contenido) del carácter en la columna `col`.
252#[inline]
253fn text_x(col: usize, m: EditorMetrics) -> f32 {
254    PAD_X + col as f32 * m.char_width
255}
256/// Y (px) del tope de la línea `local_line` (relativa al viewport).
257#[inline]
258fn text_y(local_line: usize, m: EditorMetrics) -> f32 {
259    PAD_Y + local_line as f32 * m.line_height
260}
261
262/// Render principal sin syntax highlight — todas las líneas visibles
263/// en `palette.fg_text`. `visible_lines` es cuántas líneas mostrar como
264/// máximo en el viewport.
265///
266/// `on_pointer` se invoca con el evento del mouse dentro del área de
267/// texto (no del gutter): el caller decide cómo mover el caret /
268/// extender selección. Ver [`PointerEvent`].
269pub fn text_editor_view<Msg: Clone + 'static>(
270    state: &EditorState,
271    palette: &EditorPalette,
272    metrics: EditorMetrics,
273    visible_lines: usize,
274    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
275) -> View<Msg> {
276    text_editor_view_highlighted(
277        state,
278        palette,
279        metrics,
280        visible_lines,
281        Language::Plain,
282        on_pointer,
283    )
284}
285
286/// Evento de mouse que el view envía al caller dentro del área de texto.
287/// El caller convierte `(x, y)` con [`EditorMetrics::screen_to_pos`] y
288/// aplica `set_caret_at` (Click) o `extend_selection_to` (Drag).
289///
290/// `Drag` entrega `initial` (pos del press inicial, constante durante el
291/// drag) + `delta` (delta desde el evento anterior). El caller debe
292/// acumular el delta — el view no mantiene state. Patrón típico:
293/// `accum += (dx, dy); actual = (initial_x + accum.0, initial_y + accum.1)`.
294#[derive(Debug, Clone, Copy)]
295pub enum PointerEvent {
296    Click { x: f32, y: f32 },
297    /// Un paso del arrastre. `initial_*` es **dónde se apretó** (fijo durante
298    /// todo el gesto) y `dx`/`dy` son el delta **de este paso**, no el
299    /// acumulado — es la convención de `draggable_at` en toda la casa. El
300    /// consumidor acumula: `pos = initial + Σ delta`.
301    Drag { initial_x: f32, initial_y: f32, dx: f32, dy: f32 },
302    /// **Se soltó el botón.** Cierra el gesto: es donde el consumidor pone a
303    /// cero su acumulador. Sin esto no había forma de saber que un arrastre
304    /// terminó, y el siguiente arrancaba con el recorrido del anterior encima.
305    DragEnd,
306}
307
308/// Override de estilo de texto sobre un rango de **columnas-char**
309/// `[start_col, end_col)` de una línea. A diferencia de [`Span`] (que sólo
310/// lleva una `TokenKind` → color), un `StyledSpan` lleva el set completo de
311/// propiedades rich-text que el caller quiera pisar: color de glifo, color de
312/// fondo (resaltado), familia, tamaño, peso, itálica, subrayado y tachado.
313/// Cada campo es opcional — `None` hereda del default del editor.
314///
315/// El widget traduce internamente las columnas-char a offsets de byte (que es
316/// lo que consume `text_spans`/parley) y pinta el `bg` como rect por debajo
317/// del texto (parley no lleva fondo por span). Pensado para editores de prosa
318/// que estilan zonas o la selección (pluma multilienzo); no se combina con el
319/// syntax highlight por `Language` — es una vía de pintado paralela.
320#[derive(Debug, Clone, Default, PartialEq)]
321pub struct StyledSpan {
322    pub start_col: usize,
323    pub end_col: usize,
324    pub fg: Option<Color>,
325    pub bg: Option<Color>,
326    pub font_family: Option<String>,
327    pub size_px: Option<f32>,
328    pub weight: Option<f32>,
329    pub italic: Option<bool>,
330    pub underline: Option<bool>,
331    pub strikethrough: Option<bool>,
332}
333
334/// Como [`text_editor_view_highlighted`] pero el caller provee, por línea, un
335/// conjunto de [`StyledSpan`] con estilo rich-text completo (color/fondo/
336/// fuente/tamaño/peso/itálica/subrayado/tachado). `styled_per_line[n]` son los
337/// spans de la línea `n` del buffer (índice absoluto). Las líneas sin spans se
338/// pintan planas en `palette.fg_text`. Va por encima del syntax highlight: si
339/// pasas spans, mandan ellos (el caso de pluma, que es prosa sin lenguaje).
340pub fn text_editor_view_styled<Msg: Clone + 'static>(
341    state: &EditorState,
342    palette: &EditorPalette,
343    metrics: EditorMetrics,
344    visible_lines: usize,
345    styled_per_line: &[Vec<StyledSpan>],
346    match_ranges: &[(usize, usize)],
347    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
348) -> View<Msg> {
349    let caret = state.cursor.caret;
350    let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark());
351
352    let visible = visible_lines.max(1).min(200);
353    let line_count = state.line_count();
354    let scroll = state.scroll_offset.min(line_count.saturating_sub(1));
355    let end_line = (scroll + visible).min(line_count);
356    let height = (end_line - scroll) as f32 * metrics.line_height;
357    // El camino viejo no conoce pliegues: su ventana es contigua, y la
358    // disposición sin pliegues es exactamente la identidad de siempre.
359    let disp_plana = crate::geometria::Disposicion::para(line_count, metrics);
360    let ventana = Ventana::nueva(&disp_plana, scroll, end_line.saturating_sub(scroll));
361
362    let gutter = build_gutter(state, &ventana, caret.line, metrics, palette, 0.0, None);
363    let content = build_content(
364        state,
365        palette,
366        metrics,
367        height,
368        &ventana,
369        Vec::new(),
370        &syntax,
371        match_ranges,
372        None,
373        Some(styled_per_line),
374        Ayudas::default(),
375        Desplazamiento2D::default(),
376        None,
377        None,
378        on_pointer,
379    );
380
381    View::new(Style {
382        flex_direction: FlexDirection::Row,
383        flex_grow: 1.0,
384        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
385        min_size: Size { width: auto(), height: length(height) },
386        ..Default::default()
387    })
388    .fill(palette.bg)
389    .clip(true)
390    // Editor de texto multilínea: los lectores cambian de modo de navegación
391    // con este rol (por línea/palabra) en vez de leerlo como un bloque. No
392    // mandamos el contenido en `value` a propósito: puede ser un archivo
393    // entero, y el buffer se navega por el árbol, no por el nombre.
394    .role(llimphi_compositor::Role::MultilineTextInput)
395    .aria_label("Editor de texto")
396    .children(vec![gutter, content])
397}
398
399/// Render con syntax highlight + **viewport scrolling**: sólo se renderizan
400/// las líneas en `[state.scroll_offset, scroll_offset + visible_lines)`.
401///
402/// `visible_lines` es cuántas líneas máximo dibujamos por frame; el caller
403/// se asegura de tener un container con altura ≥ `visible_lines * line_height`
404/// o aplica clip propio. Para archivos grandes (1000+ líneas), el cap es
405/// crítico — sin él generaríamos miles de Views y wgpu rechazaría el bind
406/// group por `max_*_buffer_binding_size`.
407///
408/// Recomendación para el caller: tras cada edición, llamar a
409/// [`EditorState::ensure_caret_visible`] con el mismo `visible_lines` para
410/// que el viewport siga al caret.
411pub fn text_editor_view_highlighted<Msg: Clone + 'static>(
412    state: &EditorState,
413    palette: &EditorPalette,
414    metrics: EditorMetrics,
415    visible_lines: usize,
416    language: Language,
417    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
418) -> View<Msg> {
419    text_editor_view_full(
420        state,
421        palette,
422        metrics,
423        visible_lines,
424        language,
425        &[],
426        on_pointer,
427    )
428}
429
430/// Qué pintar además del texto, y dónde publicar la medida.
431///
432/// Existe para que [`text_editor_view_viewport`] no tenga ocho argumentos
433/// sueltos: todo lo opcional entra por acá con `Default`.
434pub struct OpcionesRender<'a, Msg> {
435    /// Lenguaje para el resaltado. `Plain` = texto monocolor.
436    pub language: Language,
437    /// Ocurrencias del find activo a resaltar `(char_start, char_end)`.
438    pub match_ranges: &'a [(usize, usize)],
439    /// **Spans rich-text por línea** (color, fondo, fuente, tamaño, peso,
440    /// itálica): la vía de pluma para estilar zonas y selección de prosa.
441    /// Cuando es `Some`, ganan sobre el resaltado de sintaxis.
442    ///
443    /// Existía sólo en el camino viejo ([`text_editor_view_styled`]), y eso
444    /// dejaba a una app de prosa eligiendo entre **estilos** y **ajuste de
445    /// línea** — porque el ajuste vive en la disposición, que sólo conoce este
446    /// camino. Pluma tenía que quedarse con los estilos, así que su texto no
447    /// ajustaba: un párrafo largo se cortaba contra el borde de la columna.
448    pub styled: Option<&'a [Vec<StyledSpan>]>,
449    /// Dónde publicar el tamaño real del área de contenido. **Pasalo**: sin
450    /// medida el viewport no sabe cuántas filas entran y cae en
451    /// [`FILAS_SIN_MEDIDA`](crate::geometria::FILAS_SIN_MEDIDA), que es un piso
452    /// para el primer cuadro y no un tamaño.
453    pub medida: Option<&'a TamanoVisible>,
454    /// Qué `Msg` emitir para desplazar el documento, con el delta **en píxeles**
455    /// del documento. Lo usan la barra de posición (arrastre del pulgar y salto
456    /// de página al clickear el canal) y la rueda cuando el cursor está sobre el
457    /// editor.
458    ///
459    /// Sin esto la barra se dibuja pero no se agarra, y la rueda depende de que
460    /// el host la rutee por su `on_wheel` global — que es lo que hacían todos
461    /// los consumidores, y significa que el editor no scrollea si vive adentro
462    /// de un contenedor que atiende la rueda antes que él.
463    pub on_scroll: Option<Arc<dyn Fn(f32) -> Option<Msg> + Send + Sync>>,
464    /// Lo mismo que [`Self::on_scroll`] pero en el eje **horizontal**: delta en
465    /// píxeles, positivo hacia la derecha. Lo usan la barra de abajo y la rueda
466    /// lateral (el `dx` del trackpad, y el que el compositor sintetiza cuando
467    /// se gira con Shift).
468    ///
469    /// Sin esto el eje horizontal existe en la geometría y no se puede mover:
470    /// apagar el ajuste de línea deja el texto cortado contra el borde y sin
471    /// ninguna forma de llegar al resto. Lo pagó pluma hasta el 2026-09-16.
472    pub on_scroll_x: Option<Arc<dyn Fn(f32) -> Option<Msg> + Send + Sync>>,
473    /// **El botón derecho sobre el texto**, con coordenadas de VENTANA (las que
474    /// quiere un menú para anclarse donde está el puntero).
475    ///
476    /// Sin esto el right-click no lo contesta nadie dentro del editor y lo
477    /// termina atendiendo la raíz de la app — que funciona para un editor solo,
478    /// pero en un multilienzo abre el menú sobre el lienzo ACTIVO aunque uno
479    /// haya apuntado a la columna de al lado.
480    pub on_menu: Option<Arc<dyn Fn(f32, f32) -> Option<Msg> + Send + Sync>>,
481    /// Qué `Msg` emitir al clickear la flecha de plegado del gutter, con la
482    /// línea que encabeza el bloque. Sin esto no se dibujan flechas: un botón
483    /// que no hace nada es peor que no tenerlo.
484    pub on_pliegue: Option<Arc<dyn Fn(usize) -> Option<Msg> + Send + Sync>>,
485    /// Cuántas líneas de **contexto** pegar arriba de todo: el `impl`, el `fn`,
486    /// la clave del YAML de la que es lo que se está mirando. `0` = ninguna.
487    ///
488    /// Se quedan las de **adentro** cuando hay más ancestros que lugar: el `fn`
489    /// en el que estás importa más que el `mod` de arriba de todo.
490    pub pegajosas: usize,
491    /// Qué `Msg` emitir al clickear una línea pegajosa, con su número de línea.
492    /// Sin esto la banda se dibuja pero no lleva a ningún lado.
493    pub on_pegajosa: Option<Arc<dyn Fn(usize) -> Option<Msg> + Send + Sync>>,
494    /// Rayitas verticales en cada nivel de sangría. Ayudan a ver de qué bloque
495    /// es una línea sin contar espacios con el dedo.
496    pub guias_de_sangria: bool,
497    /// Columna donde pintar el **margen**: la raya vertical que marca hasta
498    /// dónde conviene que llegue una línea. `None` = sin margen.
499    pub margen_columna: Option<usize>,
500}
501
502impl<Msg> Default for OpcionesRender<'_, Msg> {
503    // A mano y no por `derive`: `Language` no implementa `Default` (vive en el
504    // núcleo agnóstico y no le corresponde elegir un lenguaje por omisión), y
505    // `derive` además exigiría `Msg: Default`, que no viene al caso.
506    fn default() -> Self {
507        Self {
508            language: Language::Plain,
509            match_ranges: &[],
510            styled: None,
511            medida: None,
512            on_scroll: None,
513            on_scroll_x: None,
514            on_menu: None,
515            on_pliegue: None,
516            pegajosas: 0,
517            on_pegajosa: None,
518            guias_de_sangria: false,
519            margen_columna: None,
520        }
521    }
522}
523
524/// El render con **viewport medido**: la variante que hay que usar.
525///
526/// A diferencia de las funciones que le siguen —que reciben `visible_lines` y
527/// asumen que el host contó bien—, ésta se dibuja contra un [`Viewport`] en
528/// píxeles y virtualiza por rango: sólo materializa las filas que caen adentro,
529/// más una arriba y otra abajo para que el borde no quede vacío al scrollear
530/// entre dos filas. El costo del render pasa a depender del **alto del panel** y
531/// no del tamaño del archivo — que es lo que vuelve innecesarios los topes de
532/// 200 y 512 líneas que había que poner porque wgpu rechazaba el bind group.
533///
534/// El desplazamiento fino (sub-fila) y el horizontal **no** se aplican línea por
535/// línea: se aplican una vez, a la capa que contiene a todos los hijos. Por eso
536/// las veinte funciones de dibujo siguen posicionando en coordenadas locales a
537/// la primera fila materializada, sin enterarse de que existe un scroll.
538pub fn text_editor_view_viewport<Msg: Clone + 'static>(
539    state: &EditorState,
540    palette: &EditorPalette,
541    vp: &Viewport,
542    opciones: OpcionesRender<'_, Msg>,
543    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
544) -> View<Msg> {
545    let metrics = vp.metrics;
546    let caret = state.cursor.caret;
547    let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark());
548
549    let disp = state.disposicion(metrics);
550    let rango = vp.rango_visible(&disp);
551    // La ventana habla de FILAS y traduce a líneas. Antes esto era
552    // `scroll..end_line`, un rango de líneas consecutivas — que con un pliegue
553    // en el medio pinta las líneas escondidas y corre todo lo de abajo.
554    let ventana = Ventana::nueva(&disp, rango.primera, rango.cuantas());
555
556    // Ancho del contenido: se mide sobre lo visible y no sobre el archivo
557    // entero. Un barrido completo por cuadro es justo el costo que este camino
558    // evita; el único efecto es que el tope del scroll horizontal se ajusta a
559    // medida que se navega.
560    let medidor = Monoespaciada::de(metrics);
561    // Con ajuste de línea no hay contenido a la derecha por definición: cada
562    // fila entra en el panel. Medir la línea más larga ahí daría un ancho de
563    // scroll que no existe.
564    let ancho_texto = if disp.ajustada() {
565        0.0
566    } else {
567        crate::geometria::ancho_contenido(
568            ventana.recorrer().map(|(_, n)| state.buffer.line(n)),
569            &medidor,
570        )
571    };
572    // La capa interna cubre el contenido o el viewport, lo que sea más ancho:
573    // si sólo cubriera el viewport, al desplazarse en X los fondos de línea
574    // (renglón activo, tintes, selección) dejarían una franja muerta a la
575    // derecha. Es una capa absoluta, así que su ancho no empuja al padre — el
576    // panel se sigue midiendo solo.
577    let ancho_capa = (ancho_texto + PAD_X * 2.0).max(vp.ancho_px);
578
579    let spans = state.highlighted_spans(opciones.language);
580
581    // Se arma antes que el contenido porque los dos leen los mismos spans y el
582    // contenido se los queda.
583    let banda = banda_pegajosa(
584        state,
585        palette,
586        metrics,
587        &ventana,
588        opciones.pegajosas,
589        &spans,
590        &syntax,
591        -vp.desplazamiento.x_px,
592        opciones.on_pegajosa.clone(),
593    );
594
595    let gutter = build_gutter(
596        state,
597        &ventana,
598        caret.line,
599        metrics,
600        palette,
601        rango.recorte_px,
602        opciones.on_pliegue.clone(),
603    );
604    let content = build_content(
605        state,
606        palette,
607        metrics,
608        // Sin piso de alto. El `min_size` del camino viejo existe para el modo
609        // embebido, que reserva alto para todas las líneas porque scrollea
610        // desde afuera; acá el editor llena el panel y punto. Ponerle como piso
611        // el alto que midió el cuadro anterior sería peor que inútil: el nodo
612        // pediría no achicarse nunca por debajo de su propio tamaño previo, y
613        // achicar la ventana dejaría de encoger el editor.
614        0.0,
615        &ventana,
616        spans,
617        &syntax,
618        opciones.match_ranges,
619        None,
620        opciones.styled,
621        Ayudas {
622            guias: opciones.guias_de_sangria,
623            margen: opciones.margen_columna,
624            indent: state.options.indent_size.max(1),
625        },
626        Desplazamiento2D {
627            x: -vp.desplazamiento.x_px,
628            y: rango.recorte_px,
629            ancho_capa,
630        },
631        opciones.medida,
632        opciones.on_menu.clone(),
633        on_pointer,
634    );
635
636    let mut hijos = vec![gutter, content];
637    // La banda de contexto va DESPUÉS del contenido —lo tapa— y ANTES de la
638    // barra, que se pinta encima de todo.
639    if let Some(banda) = banda {
640        hijos.push(banda);
641    }
642    // La franja de marcas va debajo de la barra: comparten el canal derecho, y
643    // la barra tiene que quedar arriba para seguir recibiendo el arrastre.
644    if let Some(franja) =
645        franja_de_marcas(state, palette, vp, &disp, opciones.match_ranges)
646    {
647        hijos.push(franja);
648    }
649    if let Some(barra) = barra_de_scroll(vp, &disp, opciones.on_scroll.clone()) {
650        hijos.push(barra);
651    }
652    // El eje horizontal sólo existe sin ajuste de línea: con ajuste, `ancho_texto`
653    // es 0 por definición y la barra no se dibuja.
654    if let Some(barra) = barra_de_scroll_h(
655        vp,
656        vp.desplazamiento.x_px,
657        ancho_texto + PAD_X * 2.0,
658        opciones.on_scroll_x.clone(),
659    ) {
660        hijos.push(barra);
661    }
662
663    let raiz = View::new(Style {
664        flex_direction: FlexDirection::Row,
665        flex_grow: 1.0,
666        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
667        ..Default::default()
668    })
669    .fill(palette.bg)
670    .clip(true)
671    .role(llimphi_compositor::Role::MultilineTextInput)
672    .aria_label("Editor de texto")
673    .children(hijos);
674
675    match opciones.on_scroll {
676        // La rueda la atiende el **editor**, no el `on_wheel` global del host.
677        // Es la diferencia entre «scrollea si la app se acordó de rutearla» y
678        // «scrollea porque el cursor está encima»: un editor metido adentro de
679        // un contenedor que ya atiende la rueda no llegaba a verla nunca.
680        //
681        // `on_scroll` entrega el delta en LÍNEAS —la unidad del runtime, la
682        // misma que consume `llimphi-widget-scroll`— y acá se convierte a los
683        // píxeles con los que trabaja la geometría. Tres líneas por muesca es el
684        // paso de siempre.
685        Some(cb) => {
686            let y_actual = vp.desplazamiento.y_documento(&disp);
687            let max_y = vp.max_y(&disp);
688            // La rueda LATERAL (el `dx` del trackpad, y el que el compositor
689            // sintetiza al girar con Shift) va al eje horizontal. Se descartaba:
690            // `on_scroll` sólo miraba `dy`, así que sin ajuste de línea el texto
691            // quedaba cortado contra el borde y no había gesto que lo moviera.
692            let cb_x = opciones.on_scroll_x.clone();
693            let ancho_char = metrics.char_width;
694            raiz.on_scroll(move |dx, dy| {
695                if dy == 0.0 && dx != 0.0 {
696                    let cb_x = cb_x.as_ref()?;
697                    return (cb_x)(dx * 3.0 * ancho_char);
698                }
699                let px = dy * 3.0 * metrics.line_height;
700                if px == 0.0 {
701                    return None;
702                }
703                // Topado hacia ese lado: devolvemos `None` para que el runtime
704                // le pase el evento al ancestro scrollable más cercano. Es el
705                // contrato del scroll anidado, y sin esto un editor que llegó a
706                // su fin se traga la rueda y el panel que lo contiene no se
707                // mueve nunca.
708                if (px < 0.0 && y_actual <= 0.0) || (px > 0.0 && y_actual >= max_y) {
709                    return None;
710                }
711                (cb)(px)
712            })
713        }
714        None => raiz,
715    }
716}
717
718/// Hasta cuántas líneas mirar hacia arriba buscando la cadena de contexto. Es
719/// el caso patológico —un archivo enorme donde nada llega a la columna cero—:
720/// se corta y se dibuja lo que se encontró, que es un encabezado de menos y
721/// nunca uno equivocado.
722pub const TOPE_PEGAJOSAS: usize = 2_000;
723
724/// El contexto pegado arriba del editor: de qué `impl`, de qué `fn`, de qué
725/// clave del YAML es lo que se está mirando.
726///
727/// Es una capa **absoluta encima del contenido**, no una banda que lo empuja:
728/// si empujara, su alto cambiaría al scrollear y el texto daría saltos. La
729/// contra es que tapa los primeros renglones — por eso el caret se encuadra con
730/// `Viewport::margen_filas` puesto en la cantidad de pegajosas, que es
731/// exactamente el aire que hace falta.
732///
733/// Las líneas salen de `plegado::cadena_de_bloques`, la misma regla de sangría
734/// que decide qué se pliega. No hay una segunda definición de «bloque» dando
735/// vueltas.
736#[allow(clippy::too_many_arguments)]
737fn banda_pegajosa<Msg: Clone + 'static>(
738    state: &EditorState,
739    palette: &EditorPalette,
740    metrics: EditorMetrics,
741    v: &Ventana<'_>,
742    max: usize,
743    spans: &[Vec<Span>],
744    syntax: &SyntaxPalette,
745    x_off: f32,
746    on_click: Option<Arc<dyn Fn(usize) -> Option<Msg> + Send + Sync>>,
747) -> Option<View<Msg>> {
748    if max == 0 || v.filas() == 0 {
749        return None;
750    }
751    let lineas = state.lineas_pegajosas(v.linea_de(0), max);
752    if lineas.is_empty() {
753        return None;
754    }
755    let alto = lineas.len() as f32 * metrics.line_height;
756
757    let mut numeros: Vec<View<Msg>> = Vec::with_capacity(lineas.len());
758    let mut textos: Vec<View<Msg>> = Vec::with_capacity(lineas.len());
759    for (i, n) in lineas.iter().copied().enumerate() {
760        if matches!(metrics.gutter_style, GutterStyle::Numbers) {
761            numeros.push(
762                View::new(Style {
763                    position: Position::Absolute,
764                    inset: Rect {
765                        left: length(GUTTER_PAD_L),
766                        top: length(text_y(i, metrics)),
767                        right: length(GUTTER_PAD_R),
768                        bottom: auto(),
769                    },
770                    size: Size {
771                        width: length((metrics.gutter_width - GUTTER_PAD_L - GUTTER_PAD_R).max(1.0)),
772                        height: length(metrics.line_height),
773                    },
774                    align_items: Some(AlignItems::CENTER),
775                    ..Default::default()
776                })
777                .text_aligned(
778                    (n + 1).to_string(),
779                    metrics.font_size * 0.85,
780                    palette.fg_line_number,
781                    Alignment::End,
782                )
783                .mono(),
784            );
785        }
786        let texto = state.buffer.line(n);
787        let texto = texto.trim_end_matches(['\n', '\r']).to_string();
788        textos.push(match spans.get(n) {
789            Some(sp) => line_text_tokens(i, &texto, sp, metrics, palette, syntax),
790            None => line_text_plain(i, texto, metrics, palette),
791        });
792    }
793
794    // El texto va en su propia capa, corrida por el gutter y por el scroll
795    // horizontal: las pegajosas acompañan el desplazamiento lateral, si no
796    // dirían una columna distinta de la que se está leyendo abajo.
797    let capa_texto = View::new(Style {
798        position: Position::Absolute,
799        inset: Rect {
800            left: length(metrics.gutter_width),
801            top: length(0.0_f32),
802            right: length(0.0_f32),
803            bottom: length(0.0_f32),
804        },
805        ..Default::default()
806    })
807    .clip(true)
808    .children(vec![capa_desplazada(textos, x_off, 0.0, 0.0)]);
809
810    // Una raya al pie que separa el contexto del texto de verdad. Sin ella, con
811    // el mismo fondo, no se sabe dónde termina lo pegado y empieza lo que se
812    // está editando.
813    let raya = View::new(Style {
814        position: Position::Absolute,
815        inset: Rect {
816            left: length(0.0_f32),
817            top: length(alto - 1.0),
818            right: length(0.0_f32),
819            bottom: auto(),
820        },
821        size: Size { width: percent(1.0_f32), height: length(1.0_f32) },
822        ..Default::default()
823    })
824    .fill(with_alpha(palette.fg_line_number, 0.35));
825
826    let mut hijos = numeros;
827    hijos.push(capa_texto);
828    hijos.push(raya);
829
830    // El blanco de click es la fila entera, como la flecha del pliegue. Cae en
831    // la línea que corresponde a la altura clickeada.
832    let banda = View::new(Style {
833        position: Position::Absolute,
834        inset: Rect {
835            left: length(0.0_f32),
836            top: length(0.0_f32),
837            right: length(0.0_f32),
838            bottom: auto(),
839        },
840        size: Size { width: percent(1.0_f32), height: length(alto) },
841        ..Default::default()
842    })
843    .fill(palette.bg_gutter)
844    .clip(true)
845    .aria_label("Contexto")
846    .children(hijos);
847
848    Some(match on_click {
849        Some(cb) => {
850            let lh = metrics.line_height;
851            banda
852                .cursor(llimphi_compositor::Cursor::Pointer)
853                .on_click_at(move |_x, y, _w, _h| {
854                    let i = ((y / lh) as usize).min(lineas.len().saturating_sub(1));
855                    (cb)(lineas[i])
856                })
857        }
858        None => banda,
859    })
860}
861
862/// Ancho del **pulgar**, en px.
863const BARRA_W: f32 = 6.0;
864/// Ancho del **canal**: el área que se puede agarrar. Más ancha que el pulgar a
865/// propósito — un blanco de 6 px se falla —, y es además la que recibe el click
866/// de salto de página.
867const CANAL_W: f32 = 14.0;
868/// Aire entre la barra y el borde derecho.
869const BARRA_PAD: f32 = 2.0;
870
871/// Alto de una marca de la franja, en px.
872const MARCA_H: f32 = 2.0;
873/// Aire a cada lado de la marca dentro del canal.
874const MARCA_PAD: f32 = 3.0;
875
876/// Prioridad de una marca cuando dos caen en el mismo píxel. Gana el número más
877/// alto: un error no se puede perder detrás de un match del find.
878fn prioridad(sev: Severity) -> u8 {
879    match sev {
880        Severity::Error => 4,
881        Severity::Warning => 3,
882        Severity::Information => 1,
883        Severity::Hint => 0,
884    }
885}
886
887/// El *error stripe*: el mapa del archivo entero sobre el borde derecho, con
888/// una marca por diagnóstico y una por match del find.
889///
890/// Es la respuesta a «¿hay un error más abajo?» sin scrollear, y comparte el
891/// canal con la barra de posición — por eso va **antes** que ella en la lista de
892/// hijos: la barra se pinta encima y sigue siendo la que recibe el arrastre.
893///
894/// **Las marcas se funden por píxel.** Sin eso, un archivo con diez mil
895/// advertencias produciría diez mil nodos por cuadro, que es exactamente el
896/// costo que la etapa 1 sacó del render. Una franja no puede mostrar más marcas
897/// que píxeles tiene, así que dos que caen en el mismo lugar son una sola y gana
898/// la más grave.
899fn franja_de_marcas<Msg: Clone + 'static>(
900    state: &EditorState,
901    palette: &EditorPalette,
902    vp: &Viewport,
903    disp: &crate::geometria::Disposicion,
904    match_ranges: &[(usize, usize)],
905) -> Option<View<Msg>> {
906    if vp.alto_px <= 0.0 {
907        return None;
908    }
909    let filas = disp.filas().max(1) as f32;
910    let alto = vp.alto_px;
911    let y_de = |linea: usize, col: usize| {
912        (disp.fila_de_pos(linea, col) as f32 / filas) * alto
913    };
914
915    // `(cubeta de píxeles, prioridad, color)`. La cubeta es del alto de la
916    // marca: dos marcas que se dibujarían encima son una sola.
917    let mut marcas: Vec<(i32, u8, Color)> = Vec::new();
918    for d in &state.diagnostics {
919        let y = y_de(d.range.start.line, d.range.start.col);
920        let color = match d.severity {
921            Severity::Error => palette.diag_error,
922            Severity::Warning => palette.diag_warning,
923            Severity::Information => palette.diag_info,
924            Severity::Hint => palette.diag_hint,
925        };
926        marcas.push(((y / MARCA_H) as i32, prioridad(d.severity), color));
927    }
928    for (inicio, _) in match_ranges {
929        let (l, c) = state.buffer.offset_to_pos(*inicio);
930        marcas.push(((y_de(l, c) / MARCA_H) as i32, 2, palette.bg_match));
931    }
932    if marcas.is_empty() {
933        // Sin nada que marcar queda sólo la rayita del caret, y para eso no vale
934        // la pena una capa: la posición ya la dice el pulgar de la barra.
935        return None;
936    }
937    // Ordenar por cubeta y, dentro de cada una, por prioridad descendente:
938    // `dedup_by_key` se queda con el primero de cada grupo, o sea el más grave.
939    marcas.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
940    marcas.dedup_by_key(|m| m.0);
941
942    let mut hijos: Vec<View<Msg>> = Vec::with_capacity(marcas.len() + 1);
943    for (cubeta, _, color) in marcas {
944        hijos.push(
945            View::new(Style {
946                position: Position::Absolute,
947                inset: Rect {
948                    left: length(MARCA_PAD),
949                    top: length(cubeta as f32 * MARCA_H),
950                    right: length(MARCA_PAD),
951                    bottom: auto(),
952                },
953                size: Size { width: auto(), height: length(MARCA_H) },
954                ..Default::default()
955            })
956            .fill(color),
957        );
958    }
959
960    // La rayita del caret, encima de las marcas: dónde estoy parado dentro del
961    // mapa. Es lo que convierte la franja en una brújula y no en una lista.
962    let caret = state.cursor.caret;
963    hijos.push(
964        View::new(Style {
965            position: Position::Absolute,
966            inset: Rect {
967                left: length(0.0_f32),
968                top: length(y_de(caret.line, caret.col)),
969                right: length(0.0_f32),
970                bottom: auto(),
971            },
972            size: Size { width: auto(), height: length(1.0_f32) },
973            ..Default::default()
974        })
975        .fill(with_alpha(palette.caret, 0.8)),
976    );
977
978    Some(
979        View::new(Style {
980            position: Position::Absolute,
981            inset: Rect {
982                left: auto(),
983                top: length(0.0_f32),
984                right: length(BARRA_PAD),
985                bottom: length(0.0_f32),
986            },
987            size: Size { width: length(CANAL_W), height: percent(1.0_f32) },
988            ..Default::default()
989        })
990        .aria_label("Marcas del archivo")
991        .children(hijos),
992    )
993}
994
995/// La barra de posición sobre el borde derecho: dónde estás dentro del archivo.
996///
997/// El editor no tenía ninguna — con el viewport contado afuera, ni siquiera se
998/// sabía qué fracción del documento se estaba viendo. La geometría del pulgar
999/// sale de `llimphi_widget_scroll::thumb_geometry`, la misma que usan las listas
1000/// del repo, así que se mueve igual que todo lo demás.
1001///
1002/// Devuelve `None` cuando el documento entra completo (no hay nada que indicar)
1003/// o cuando el panel todavía no se midió. Todavía **no se arrastra**: eso pide
1004/// compartir el canal con el *error stripe* —las marcas de diagnostics y de
1005/// find sobre la misma franja—, que es una pieza de la etapa siguiente, y una
1006/// barra que se arrastra hoy habría que rehacerla ahí.
1007fn barra_de_scroll<Msg: Clone + 'static>(
1008    vp: &Viewport,
1009    disp: &crate::geometria::Disposicion,
1010    on_scroll: Option<Arc<dyn Fn(f32) -> Option<Msg> + Send + Sync>>,
1011) -> Option<View<Msg>> {
1012    if vp.alto_px <= 0.0 {
1013        return None;
1014    }
1015    let contenido = disp.alto_total();
1016    if contenido <= vp.alto_px {
1017        return None;
1018    }
1019    let paleta = llimphi_widget_scroll::ScrollPalette::default();
1020    let (alto_thumb, y_thumb, px_por_px) = llimphi_widget_scroll::thumb_geometry(
1021        vp.desplazamiento.y_documento(disp),
1022        contenido,
1023        vp.alto_px,
1024    );
1025    if alto_thumb <= 0.0 {
1026        return None;
1027    }
1028    let thumb = View::new(Style {
1029        position: Position::Absolute,
1030        inset: Rect {
1031            left: length((CANAL_W - BARRA_W) * 0.5),
1032            top: length(y_thumb),
1033            right: auto(),
1034            bottom: auto(),
1035        },
1036        size: Size { width: length(BARRA_W), height: length(alto_thumb) },
1037        ..Default::default()
1038    })
1039    .fill(with_alpha(paleta.thumb, paleta.thumb_idle_alpha))
1040    .radius((BARRA_W * 0.5) as f64);
1041
1042    let canal = View::new(Style {
1043        position: Position::Absolute,
1044        inset: Rect {
1045            left: auto(),
1046            top: length(0.0_f32),
1047            right: length(BARRA_PAD),
1048            bottom: length(0.0_f32),
1049        },
1050        size: Size { width: length(CANAL_W), height: percent(1.0_f32) },
1051        ..Default::default()
1052    })
1053    .children(vec![thumb]);
1054
1055    let Some(cb) = on_scroll else {
1056        // Sin callback la barra es sólo un indicador: se dibuja, no se agarra.
1057        return Some(canal);
1058    };
1059    let cb_drag = cb.clone();
1060    let alto_vp = vp.alto_px;
1061    Some(
1062        canal
1063            // El delta del arrastre viene en píxeles de PANTALLA; `px_por_px` es
1064            // el factor que los pasa a píxeles de DOCUMENTO. Sin él, un archivo
1065            // largo se recorrería a la velocidad del dedo y el pulgar se
1066            // despegaría del cursor a los pocos píxeles.
1067            .draggable_at(move |fase, _dx, dy, _lx, _ly| match fase {
1068                llimphi_compositor::DragPhase::Move => (cb_drag)(dy * px_por_px),
1069                llimphi_compositor::DragPhase::End => None,
1070            })
1071            // Click en el canal: salta una página hacia donde se clickeó, como
1072            // cualquier barra de escritorio. Sobre el pulgar devuelve 0 — ese
1073            // click es el comienzo del arrastre.
1074            .on_click_at(move |_x, y, _w, _h| {
1075                let salto =
1076                    llimphi_widget_scroll::track_page_jump(y, y_thumb, alto_thumb, alto_vp);
1077                if salto == 0.0 {
1078                    None
1079                } else {
1080                    (cb)(salto)
1081                }
1082            })
1083            .cursor(llimphi_compositor::Cursor::Default),
1084    )
1085}
1086
1087/// **La barra de abajo: el eje horizontal**, hermana de [`barra_de_scroll`].
1088///
1089/// Aparece sólo cuando hace falta —sin ajuste de línea y con una línea más
1090/// ancha que el panel—, porque una barra que no se puede mover es ruido. El
1091/// ancho de contenido lo mide el llamador sobre las filas VISIBLES (ver
1092/// `ancho_contenido`), así que el largo del pulgar se ajusta mientras uno
1093/// navega: es el precio de no barrer el archivo entero por cuadro.
1094fn barra_de_scroll_h<Msg: Clone + 'static>(
1095    vp: &Viewport,
1096    x_px: f32,
1097    ancho_contenido: f32,
1098    on_scroll_x: Option<Arc<dyn Fn(f32) -> Option<Msg> + Send + Sync>>,
1099) -> Option<View<Msg>> {
1100    if vp.ancho_px <= 0.0 || ancho_contenido <= vp.ancho_px {
1101        return None;
1102    }
1103    let paleta = llimphi_widget_scroll::ScrollPalette::default();
1104    let (ancho_thumb, x_thumb, px_por_px) =
1105        llimphi_widget_scroll::thumb_geometry(x_px, ancho_contenido, vp.ancho_px);
1106    if ancho_thumb <= 0.0 {
1107        return None;
1108    }
1109    let thumb = View::new(Style {
1110        position: Position::Absolute,
1111        inset: Rect {
1112            left: length(x_thumb),
1113            top: length((CANAL_W - BARRA_W) * 0.5),
1114            right: auto(),
1115            bottom: auto(),
1116        },
1117        size: Size { width: length(ancho_thumb), height: length(BARRA_W) },
1118        ..Default::default()
1119    })
1120    .fill(with_alpha(paleta.thumb, paleta.thumb_idle_alpha))
1121    .radius((BARRA_W * 0.5) as f64);
1122
1123    let canal = View::new(Style {
1124        position: Position::Absolute,
1125        inset: Rect {
1126            left: length(0.0_f32),
1127            top: auto(),
1128            right: length(CANAL_W),
1129            bottom: length(BARRA_PAD),
1130        },
1131        size: Size { width: auto(), height: length(CANAL_W) },
1132        ..Default::default()
1133    })
1134    .children(vec![thumb]);
1135
1136    let Some(cb) = on_scroll_x else {
1137        return Some(canal);
1138    };
1139    let cb_drag = cb.clone();
1140    let ancho_vp = vp.ancho_px;
1141    Some(
1142        canal
1143            .draggable_at(move |fase, dx, _dy, _lx, _ly| match fase {
1144                llimphi_compositor::DragPhase::Move => (cb_drag)(dx * px_por_px),
1145                llimphi_compositor::DragPhase::End => None,
1146            })
1147            .on_click_at(move |x, _y, _w, _h| {
1148                let salto =
1149                    llimphi_widget_scroll::track_page_jump(x, x_thumb, ancho_thumb, ancho_vp);
1150                if salto == 0.0 {
1151                    None
1152                } else {
1153                    (cb)(salto)
1154                }
1155            })
1156            .cursor(llimphi_compositor::Cursor::Default),
1157    )
1158}
1159
1160/// Las filas que el render está materializando, y la traducción a líneas.
1161///
1162/// Reemplaza al par `(scroll, end_line)` que llevaban todas las funciones de
1163/// dibujo. La diferencia no es cosmética: ese par daba por sentado que la fila
1164/// local `i` es la línea `scroll + i`, y con un pliegue en el medio eso deja de
1165/// ser cierto. Toda pregunta de «¿dónde pinto esta línea?» pasa por acá, así que
1166/// el plegado no necesitó tocar ninguna de las veinte funciones que pintan.
1167pub(crate) struct Ventana<'a> {
1168    disp: &'a crate::geometria::Disposicion,
1169    primera_fila: usize,
1170    filas: usize,
1171}
1172
1173impl<'a> Ventana<'a> {
1174    pub(crate) fn nueva(
1175        disp: &'a crate::geometria::Disposicion,
1176        primera_fila: usize,
1177        filas: usize,
1178    ) -> Self {
1179        Self { disp, primera_fila, filas }
1180    }
1181
1182    /// Cuántas filas se están dibujando.
1183    pub(crate) fn filas(&self) -> usize {
1184        self.filas
1185    }
1186
1187    /// La línea lógica que va en la fila local `i`.
1188    pub(crate) fn linea_de(&self, local: usize) -> usize {
1189        self.disp.linea_de_fila(self.primera_fila + local).linea
1190    }
1191
1192    /// La fila local `i` completa: su línea **y su tramo de columnas**. Sin
1193    /// ajuste de línea el tramo es la línea entera; con ajuste es el pedazo que
1194    /// le toca a este renglón.
1195    pub(crate) fn fila_de(&self, local: usize) -> Fila {
1196        self.disp.linea_de_fila(self.primera_fila + local)
1197    }
1198
1199    /// Dónde pintar la posición `(linea, col)`: la fila local y la columna
1200    /// **rebasada a esa fila**. `None` si no se ve.
1201    pub(crate) fn ubicar(&self, linea: usize, col: usize) -> Option<(usize, usize)> {
1202        if self.disp.esta_oculta(linea) {
1203            return None;
1204        }
1205        let fila = self.disp.fila_de_pos(linea, col);
1206        let local = fila.checked_sub(self.primera_fila)?;
1207        if local >= self.filas {
1208            return None;
1209        }
1210        let f = self.disp.linea_de_fila(fila);
1211        Some((local, col.saturating_sub(f.col_inicio)))
1212    }
1213
1214    /// El rango `[col_a, col_b)` de una línea, repartido en las filas visibles
1215    /// que lo cubren y con las columnas rebasadas a cada fila.
1216    ///
1217    /// Es la costura que le permitió al ajuste de línea no tocar a la selección,
1218    /// los matches del find ni los diagnostics: los tres pintaban un rectángulo
1219    /// por línea, y ahora piden acá los rectángulos que les corresponden. Sin
1220    /// ajuste devuelve exactamente uno, con las mismas columnas de antes.
1221    pub(crate) fn tramos(&self, linea: usize, col_a: usize, col_b: usize) -> Vec<TramoFila> {
1222        let mut out = Vec::new();
1223        if col_b <= col_a || self.disp.esta_oculta(linea) {
1224            return out;
1225        }
1226        let mut fila = self.disp.fila_de_pos(linea, col_a);
1227        let ultima = self.disp.fila_de_pos(linea, col_b.saturating_sub(1));
1228        loop {
1229            let f = self.disp.linea_de_fila(fila);
1230            if f.linea != linea {
1231                break;
1232            }
1233            let tope = f.col_fin.unwrap_or(usize::MAX);
1234            let a = col_a.max(f.col_inicio);
1235            let b = col_b.min(tope);
1236            if b > a {
1237                if let Some(local) = fila.checked_sub(self.primera_fila) {
1238                    if local < self.filas {
1239                        out.push(TramoFila {
1240                            local,
1241                            col_a: a - f.col_inicio,
1242                            col_b: b - f.col_inicio,
1243                            fin_de_linea: f.es_ultima(),
1244                        });
1245                    }
1246                }
1247            }
1248            if fila >= ultima {
1249                break;
1250            }
1251            fila += 1;
1252        }
1253        out
1254    }
1255
1256    /// `(fila local, línea)` de todo lo que hay que dibujar, en orden.
1257    pub(crate) fn recorrer(&self) -> impl Iterator<Item = (usize, usize)> + '_ {
1258        (0..self.filas).map(|i| (i, self.linea_de(i)))
1259    }
1260
1261    /// `(fila local, fila)` de todo lo que hay que dibujar, en orden.
1262    pub(crate) fn recorrer_filas(&self) -> impl Iterator<Item = (usize, Fila)> + '_ {
1263        (0..self.filas).map(|i| (i, self.fila_de(i)))
1264    }
1265
1266    /// Primera y última línea lógica a la vista — para acotar los barridos que
1267    /// vienen de rangos (selección, diagnostics, matches) sin recorrer el
1268    /// archivo entero.
1269    pub(crate) fn rango_lineas(&self) -> (usize, usize) {
1270        if self.filas == 0 {
1271            return (0, 0);
1272        }
1273        (self.linea_de(0), self.linea_de(self.filas - 1))
1274    }
1275}
1276
1277/// Un pedazo de un rango de columnas que cae en una fila visual concreta.
1278/// Ver [`Ventana::tramos`].
1279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1280pub(crate) struct TramoFila {
1281    /// Fila local (relativa a la primera materializada).
1282    pub local: usize,
1283    /// Columna de arranque, **rebasada a la fila**.
1284    pub col_a: usize,
1285    /// Columna de fin (exclusiva), rebasada a la fila.
1286    pub col_b: usize,
1287    /// `true` si esta fila llega al final de la línea lógica — la única que
1288    /// tiene derecho a pintar el chorrito que marca el salto de renglón.
1289    pub fin_de_linea: bool,
1290}
1291
1292/// Las ayudas de lectura que se pintan **debajo** del texto: las rayitas de
1293/// sangría y el margen de columna. Vacío por omisión — las firmas viejas del
1294/// widget no las piden y siguen dibujando exactamente lo mismo.
1295#[derive(Debug, Clone, Copy, Default)]
1296pub(crate) struct Ayudas {
1297    pub guias: bool,
1298    pub margen: Option<usize>,
1299    /// Cada cuántas columnas cae un nivel de sangría.
1300    pub indent: usize,
1301}
1302
1303/// Cuánto correr la capa de contenido y qué ancho darle. Ver
1304/// [`text_editor_view_viewport`].
1305#[derive(Debug, Clone, Copy, Default)]
1306pub(crate) struct Desplazamiento2D {
1307    pub x: f32,
1308    pub y: f32,
1309    /// `0.0` = la capa toma el 100 % del padre (el camino de las firmas viejas,
1310    /// que no tienen scroll horizontal).
1311    pub ancho_capa: f32,
1312}
1313
1314/// Como [`text_editor_view_highlighted`] + `match_ranges` para pintar
1315/// las ocurrencias de un find activo. Cada par `(char_start, char_end)`
1316/// es un rango de chars globales del buffer.
1317pub fn text_editor_view_full<Msg: Clone + 'static>(
1318    state: &EditorState,
1319    palette: &EditorPalette,
1320    metrics: EditorMetrics,
1321    visible_lines: usize,
1322    language: Language,
1323    match_ranges: &[(usize, usize)],
1324    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
1325) -> View<Msg> {
1326    let caret = state.cursor.caret;
1327    let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark());
1328
1329    let visible = visible_lines.max(1).min(200);
1330    let line_count = state.line_count();
1331    let scroll = state.scroll_offset.min(line_count.saturating_sub(1));
1332    let end_line = (scroll + visible).min(line_count);
1333    let height = (end_line - scroll) as f32 * metrics.line_height;
1334    // El camino viejo no conoce pliegues: su ventana es contigua, y la
1335    // disposición sin pliegues es exactamente la identidad de siempre.
1336    let disp_plana = crate::geometria::Disposicion::para(line_count, metrics);
1337    let ventana = Ventana::nueva(&disp_plana, scroll, end_line.saturating_sub(scroll));
1338
1339    // Memoizado por `edit_seq` — sólo reparseamos cuando el buffer
1340    // realmente cambió o cambia el `Language`.
1341    let spans = state.highlighted_spans(language);
1342
1343    let gutter = build_gutter(state, &ventana, caret.line, metrics, palette, 0.0, None);
1344    let content = build_content(
1345        state,
1346        palette,
1347        metrics,
1348        height,
1349        &ventana,
1350        spans,
1351        &syntax,
1352        match_ranges,
1353        None,
1354        None,
1355        Ayudas::default(),
1356        Desplazamiento2D::default(),
1357        None,
1358        None,
1359        on_pointer,
1360    );
1361
1362    // Llena el alto disponible (para verse como UN campo continuo: se puede
1363    // clickear/tipear debajo de la última línea), pero nunca colapsa por debajo
1364    // del alto del contenido — `min_size` es el piso si el contenedor es
1365    // chico/indefinido. El caret y los clicks debajo del texto aterrizan en la
1366    // última línea (los clampea `set_caret_at`).
1367    View::new(Style {
1368        flex_direction: FlexDirection::Row,
1369        flex_grow: 1.0,
1370        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
1371        min_size: Size { width: auto(), height: length(height) },
1372        ..Default::default()
1373    })
1374    .fill(palette.bg)
1375    .clip(true)
1376    // Editor de texto multilínea: los lectores cambian de modo de navegación
1377    // con este rol (por línea/palabra) en vez de leerlo como un bloque. No
1378    // mandamos el contenido en `value` a propósito: puede ser un archivo
1379    // entero, y el buffer se navega por el árbol, no por el nombre.
1380    .role(llimphi_compositor::Role::MultilineTextInput)
1381    .aria_label("Editor de texto")
1382    .children(vec![gutter, content])
1383}
1384
1385/// Como [`text_editor_view`] pero el caller provee el color de cada tramo de
1386/// cada línea (`line_color_runs[n]` = `(byte_start, byte_end, Color)` de la
1387/// línea `n`), en vez de derivarlo de un `Language`. Para outputs con coloreo
1388/// semántico propio (un shell que tinta `ls`, paths, urls, números…) sobre el
1389/// mismo editor read-only (numeración + selección + copiar).
1390pub fn text_editor_view_colored<Msg: Clone + 'static>(
1391    state: &EditorState,
1392    palette: &EditorPalette,
1393    metrics: EditorMetrics,
1394    visible_lines: usize,
1395    line_color_runs: &[Vec<(usize, usize, Color)>],
1396    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
1397) -> View<Msg> {
1398    let caret = state.cursor.caret;
1399    let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark());
1400    // Variante embebida: el contenedor de afuera (el panel de output del shell)
1401    // hace el scroll y reserva alto para TODAS las líneas, así que las pintamos
1402    // completas (cap alto = red de seguridad de wgpu, ver `EMBEDDED_LINE_CAP`).
1403    let visible = visible_lines.max(1).min(EMBEDDED_LINE_CAP);
1404    let line_count = state.line_count();
1405    let scroll = state.scroll_offset.min(line_count.saturating_sub(1));
1406    let end_line = (scroll + visible).min(line_count);
1407    let height = (end_line - scroll) as f32 * metrics.line_height;
1408    let disp_plana = crate::geometria::Disposicion::para(line_count, metrics);
1409    let ventana = Ventana::nueva(&disp_plana, scroll, end_line.saturating_sub(scroll));
1410    let gutter = build_gutter(state, &ventana, caret.line, metrics, palette, 0.0, None);
1411    let content = build_content(
1412        state,
1413        palette,
1414        metrics,
1415        height,
1416        &ventana,
1417        Vec::new(),
1418        &syntax,
1419        &[],
1420        Some(line_color_runs),
1421        None,
1422        Ayudas::default(),
1423        Desplazamiento2D::default(),
1424        None,
1425        None,
1426        on_pointer,
1427    );
1428    View::new(Style {
1429        flex_direction: FlexDirection::Row,
1430        size: Size { width: percent(1.0_f32), height: length(height) },
1431        ..Default::default()
1432    })
1433    .fill(palette.bg)
1434    .clip(true)
1435    // Editor de texto multilínea: los lectores cambian de modo de navegación
1436    // con este rol (por línea/palabra) en vez de leerlo como un bloque. No
1437    // mandamos el contenido en `value` a propósito: puede ser un archivo
1438    // entero, y el buffer se navega por el árbol, no por el nombre.
1439    .role(llimphi_compositor::Role::MultilineTextInput)
1440    .aria_label("Editor de texto")
1441    .children(vec![gutter, content])
1442}
1443
1444fn build_gutter<Msg: Clone + 'static>(
1445    state: &EditorState,
1446    v: &Ventana<'_>,
1447    active_line: usize,
1448    metrics: EditorMetrics,
1449    palette: &EditorPalette,
1450    // Píxeles a correr la columna hacia arriba: la parte de la primera fila que
1451    // quedó por encima del borde. Cero por el camino viejo (scroll por línea
1452    // entera), negativo con scroll fino.
1453    recorte_y: f32,
1454    // Qué `Msg` emitir al clickear la flecha de un pliegue, con la línea de la
1455    // cabecera. `None` = sin flechas (el camino viejo, y los hosts que no
1456    // implementaron el plegado).
1457    on_pliegue: Option<Arc<dyn Fn(usize) -> Option<Msg> + Send + Sync>>,
1458) -> View<Msg> {
1459    let mut children: Vec<View<Msg>> = Vec::with_capacity(v.filas());
1460    for (local, fila) in v.recorrer_filas() {
1461        let n = fila.linea;
1462        // Con ajuste de línea, las filas de continuación no llevan número:
1463        // repetir el mismo tres renglones seguidos dice menos que dejarlos en
1464        // blanco, y es lo que hace cualquier editor.
1465        if !fila.es_primera() {
1466            continue;
1467        }
1468        // Las líneas-guarda son separadores estructurales entre zonas
1469        // de texto: ni se numeran ni se pueden escribir. El espacio
1470        // se preserva (la línea sigue existiendo), pero el gutter las
1471        // saltea — visualmente la numeración "rompe" en cada zona.
1472        // Si `guard_lines` está vacío, este check es siempre `false`
1473        // y la numeración cubre todas las líneas (modo IDE clásico).
1474        if state.is_guard_line(n) {
1475            continue;
1476        }
1477        let color = if n == active_line {
1478            palette.fg_line_number_active
1479        } else {
1480            palette.fg_line_number
1481        };
1482        let y = text_y(local, metrics);
1483        match metrics.gutter_style {
1484            GutterStyle::Numbers => {
1485                let label = (n + 1).to_string();
1486                // Número alineado a la derecha con un respiro de `GUTTER_PAD_R`
1487                // hasta el texto (antes 4 px → se pegaba y el clip lo cortaba) y
1488                // un margen izquierdo para que no toque el borde de la pantalla.
1489                children.push(
1490                    View::new(Style {
1491                        position: Position::Absolute,
1492                        inset: Rect {
1493                            left: length(GUTTER_PAD_L),
1494                            top: length(y),
1495                            right: length(GUTTER_PAD_R),
1496                            bottom: auto(),
1497                        },
1498                        size: Size {
1499                            width: length(
1500                                (metrics.gutter_width - GUTTER_PAD_L - GUTTER_PAD_R).max(1.0),
1501                            ),
1502                            height: length(metrics.line_height),
1503                        },
1504                        align_items: Some(AlignItems::CENTER),
1505                        ..Default::default()
1506                    })
1507                    .text_aligned(label, metrics.font_size * 0.85, color, Alignment::End)
1508                    .mono(),
1509                );
1510            }
1511            GutterStyle::None => {}
1512            GutterStyle::Phantom => {
1513                // Tick fantasma — un segmento horizontal corto centrado
1514                // verticalmente en la línea, con la opacidad bajada.
1515                // La línea activa queda un pelín más visible.
1516                let alpha = if n == active_line { 0.35 } else { 0.12 };
1517                let tick_w = (metrics.gutter_width * 0.5).max(3.0);
1518                let tick_h = 1.0_f32;
1519                let tick_y = y + (metrics.line_height - tick_h) * 0.5;
1520                let tick_x = (metrics.gutter_width - tick_w) * 0.5;
1521                children.push(
1522                    View::new(Style {
1523                        position: Position::Absolute,
1524                        inset: Rect {
1525                            left: length(tick_x),
1526                            top: length(tick_y),
1527                            right: auto(),
1528                            bottom: auto(),
1529                        },
1530                        size: Size {
1531                            width: length(tick_w),
1532                            height: length(tick_h),
1533                        },
1534                        ..Default::default()
1535                    })
1536                    .fill(with_alpha(color, alpha)),
1537                );
1538            }
1539        }
1540    }
1541
1542    // La flecha de plegado, encima de todo lo demás: sólo en las líneas que
1543    // encabezan un bloque, y sólo si el host dijo qué hacer al clickearla.
1544    if let Some(cb) = on_pliegue {
1545        for (local, fila) in v.recorrer_filas() {
1546            let n = fila.linea;
1547            if !fila.es_primera() {
1548                continue;
1549            }
1550            // Una pregunta por línea VISIBLE, y cada una mira sólo hasta la
1551            // próxima línea con texto. Pedir la lista completa de bloques del
1552            // archivo acá costaría el documento entero por cuadro.
1553            if !state.hay_plegable_en(n) {
1554                continue;
1555            }
1556            let plegado = state.esta_plegado(n);
1557            let cb = cb.clone();
1558            children.push(
1559                View::new(Style {
1560                    position: Position::Absolute,
1561                    inset: Rect {
1562                        left: length(0.0_f32),
1563                        top: length(text_y(local, metrics)),
1564                        right: length(0.0_f32),
1565                        bottom: auto(),
1566                    },
1567                    size: Size {
1568                        width: percent(1.0_f32),
1569                        height: length(metrics.line_height),
1570                    },
1571                    align_items: Some(AlignItems::CENTER),
1572                    padding: Rect {
1573                        left: length(0.0_f32),
1574                        right: length(1.0_f32),
1575                        top: length(0.0_f32),
1576                        bottom: length(0.0_f32),
1577                    },
1578                    ..Default::default()
1579                })
1580                // El blanco de click es la fila **entera** del gutter y no el
1581                // glifo: un triángulo de seis píxeles se falla, y el número de
1582                // línea no compite por ese click porque no hace nada.
1583                .text_aligned(
1584                    if plegado { "\u{25b8}" } else { "\u{25be}" }.to_string(),
1585                    metrics.font_size * 0.8,
1586                    if plegado { palette.fg_line_number_active } else { palette.fg_line_number },
1587                    Alignment::End,
1588                )
1589                .cursor(llimphi_compositor::Cursor::Default)
1590                // El texto visible es el triángulo, así que sin nombre el lector
1591                // dice «▾» y ninguna señalética puede apuntar la fila que pliega.
1592                .role(llimphi_compositor::Role::Button)
1593                .aria_label(alloc::format!(
1594                    "{} el bloque de la línea {}",
1595                    if plegado { "Desplegar" } else { "Plegar" },
1596                    n + 1
1597                ))
1598                .aria_expanded(!plegado)
1599                .on_click_at(move |_x, _y, _w, _h| (cb)(n)),
1600            );
1601        }
1602    }
1603
1604    // En modo Phantom el gutter es un sliver: no aplicamos `fill` —
1605    // se mezcla con el fondo del editor. El gutter "está sin estar".
1606    let bg = match metrics.gutter_style {
1607        GutterStyle::Numbers => palette.bg_gutter,
1608        GutterStyle::Phantom | GutterStyle::None => palette.bg,
1609    };
1610    View::new(Style {
1611        size: Size {
1612            width: length(metrics.gutter_width),
1613            height: percent(1.0_f32),
1614        },
1615        ..Default::default()
1616    })
1617    .fill(bg)
1618    .clip(true)
1619    .children(vec![capa_desplazada(children, 0.0, recorte_y, 0.0)])
1620}
1621
1622/// Envuelve los hijos en una capa corrida `(dx, dy)` píxeles.
1623///
1624/// Es donde se aplica **todo** el desplazamiento: el fino sub-fila y el
1625/// horizontal. Aplicarlo acá y no en cada `text_x`/`text_y` es lo que deja
1626/// intactas las veinte funciones de dibujo — siguen posicionando relativo a la
1627/// primera fila materializada, que es lo único que saben.
1628///
1629/// `ancho` en `0.0` deja la capa al 100 % del padre.
1630fn capa_desplazada<Msg: Clone + 'static>(
1631    children: Vec<View<Msg>>,
1632    dx: f32,
1633    dy: f32,
1634    ancho: f32,
1635) -> View<Msg> {
1636    View::new(Style {
1637        position: Position::Absolute,
1638        inset: Rect {
1639            left: length(dx),
1640            top: length(dy),
1641            right: auto(),
1642            bottom: auto(),
1643        },
1644        size: Size {
1645            width: if ancho > 0.0 { length(ancho) } else { percent(1.0_f32) },
1646            height: percent(1.0_f32),
1647        },
1648        ..Default::default()
1649    })
1650    .children(children)
1651}
1652
1653/// Devuelve `c` con la opacidad multiplicada por `alpha` (clamp 0..1).
1654fn with_alpha(c: Color, alpha: f32) -> Color {
1655    let rgba = c.to_rgba8();
1656    let a = ((alpha.clamp(0.0, 1.0)) * (rgba.a as f32)) as u8;
1657    Color::from_rgba8(rgba.r, rgba.g, rgba.b, a)
1658}
1659
1660#[allow(clippy::too_many_arguments)]
1661fn build_content<Msg: Clone + 'static>(
1662    state: &EditorState,
1663    palette: &EditorPalette,
1664    metrics: EditorMetrics,
1665    height: f32,
1666    v: &Ventana<'_>,
1667    spans_per_line: Vec<Vec<Span>>,
1668    syntax: &SyntaxPalette,
1669    match_ranges: &[(usize, usize)],
1670    // Override de color por línea: `color_runs[n]` son `(byte_start, byte_end,
1671    // Color)` para la línea `n` del buffer (índice absoluto). Cuando es `Some`,
1672    // gana sobre el syntax highlight — para callers que colorean por semántica
1673    // propia (un shell que tinta `ls`, paths, urls…). `None` = highlight normal.
1674    color_runs: Option<&[Vec<(usize, usize, Color)>]>,
1675    // Spans rich-text por línea (color/fondo/fuente/tamaño/peso/itálica/…).
1676    // Cuando es `Some`, ganan sobre el syntax highlight — la vía de pluma para
1677    // estilar zonas y selección de prosa. `None` = comportamiento previo.
1678    styled_per_line: Option<&[Vec<StyledSpan>]>,
1679    // Las rayitas de sangría y el margen de columna, que van debajo de todo.
1680    ayudas: Ayudas,
1681    // Desplazamiento fino + horizontal de la capa de contenido.
1682    desp: Desplazamiento2D,
1683    // Dónde publicar el tamaño del área de contenido, si el caller quiere
1684    // medirla. El rect que se publica es el del área de texto (sin el gutter),
1685    // que es contra la que hay que contar filas.
1686    medida: Option<&TamanoVisible>,
1687    // El botón derecho sobre el texto, en coordenadas de ventana. `None` = el
1688    // editor no lo contesta y sube al ancestro que lo haga.
1689    on_menu: Option<Arc<dyn Fn(f32, f32) -> Option<Msg> + Send + Sync>>,
1690    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
1691) -> View<Msg> {
1692    let caret = state.cursor.caret;
1693    let mut children: Vec<View<Msg>> = Vec::new();
1694
1695    // 0.a) Las ayudas de lectura: las rayitas de sangría y el margen. Van al
1696    //      fondo de todo — debajo del renglón activo, de la selección y del
1697    //      texto—, porque son una guía y no un resaltado.
1698    children.extend(guias_y_margen(state, palette, metrics, v, ayudas));
1699
1700    // 0) Tintes por línea — la capa más baja, debajo de todo el resto.
1701    //    Pinta un rect del ancho completo del área de contenido por
1702    //    cada línea con tinte asignado. El caller elige el alpha — el
1703    //    widget no lo modula. Si la línea cae fuera de viewport o no
1704    //    tiene tinte, no se pinta nada.
1705    for (local, n) in v.recorrer() {
1706        if let Some(Some(c)) = state.line_tints.get(n) {
1707            children.push(line_tint(local, *c, metrics));
1708        }
1709    }
1710
1711    // 1) Fondo del renglón activo — sólo el del primary cursor, y el renglón
1712    //    donde de verdad está el caret: con ajuste de línea, una línea larga
1713    //    ocupa varios y pintarlos todos convierte el resalte en una mancha.
1714    if let Some((local, _)) = v.ubicar(caret.line, caret.col) {
1715        children.push(line_highlight(local, metrics, palette));
1716    }
1717
1718    // 1b) Highlight de matches del find.
1719    for (s, e) in match_ranges {
1720        children.extend(match_rects(state, *s, *e, v, metrics, palette));
1721    }
1722
1723    // 1c) Fondos de los spans estilados (resaltado). Por debajo de la
1724    //     selección y del texto, igual que los tintes — un rect por span
1725    //     con `bg`. Char-cols → x/w vía `char_width` (mono).
1726    if let Some(styled) = styled_per_line {
1727        for (local, f) in v.recorrer_filas() {
1728            if let Some(line_spans) = styled.get(f.linea) {
1729                children.extend(styled_bg_rects(local, &styled_de_fila(line_spans, f), metrics));
1730            }
1731        }
1732    }
1733
1734    // 2) Selección — por cada cursor que tenga selección.
1735    for c in state.all_cursors() {
1736        if c.has_selection() {
1737            children.extend(selection_rects_for_cursor(state, c, v, metrics, palette));
1738        }
1739    }
1740
1741    // 2b) Bracket pair bajo el primary cursor — si visible.
1742    if let Some((a, b)) = crate::bracket::find_bracket_pair(&state.buffer, &state.cursor) {
1743        if let Some((local, col)) = v.ubicar(a.line, a.col) {
1744            children.push(bracket_highlight(crate::cursor::Pos::new(local, col), metrics, palette));
1745        }
1746        if let Some((local, col)) = v.ubicar(b.line, b.col) {
1747            children.push(bracket_highlight(crate::cursor::Pos::new(local, col), metrics, palette));
1748        }
1749    }
1750
1751    // 3) Texto — sólo las líneas en viewport.
1752    //    Si `phantom_guard_lines` está activo, cada guarda recibe un
1753    //    divisor fantasma (segmento horizontal con baja opacidad)
1754    //    atravesando su centro — sin texto, sólo un susurro visual.
1755    for (local_line, f) in v.recorrer_filas() {
1756        let n = f.linea;
1757        let completa = state.buffer.line(n);
1758        // El tramo de la fila: la línea entera sin ajuste, el pedazo que le toca
1759        // con ajuste. Los estilos se recortan y se rebasan al tramo — un span
1760        // que arranca en la columna 200 tiene que pintarse en la 8 del cuarto
1761        // renglón, no perderse.
1762        let text = texto_de_fila(&completa, f).into_owned();
1763        if metrics.phantom_guard_lines && state.is_guard_line(n) {
1764            if f.es_primera() {
1765                children.push(phantom_guard_divider(local_line, metrics, palette));
1766            }
1767            continue;
1768        }
1769        if let Some(line_spans) = styled_per_line.and_then(|s| s.get(n)).filter(|s| !s.is_empty()) {
1770            let propios = styled_de_fila(line_spans, f);
1771            children.push(line_text_styled(local_line, &text, &propios, metrics, palette));
1772        } else if let Some(runs) = color_runs.and_then(|cr| cr.get(n)) {
1773            let propios = runs_de_fila(runs, &completa, f);
1774            children.push(line_text_color_runs(local_line, &text, &propios, metrics, palette));
1775        } else if let Some(line_spans) = spans_per_line.get(n) {
1776            let propios = spans_de_fila(line_spans, f);
1777            children.push(line_text_tokens(local_line, &text, &propios, metrics, palette, syntax));
1778        } else {
1779            children.push(line_text_plain(local_line, text, metrics, palette));
1780        }
1781    }
1782
1783    // 3b) Diagnostics — subrayado bajo el rango, color por severity.
1784    for d in &state.diagnostics {
1785        children.extend(diagnostic_underline(d, v, metrics, palette));
1786    }
1787
1788    // 4) Caret — uno por cursor, sólo si visible. El caret del cursor
1789    //    primario se corre detrás del preedit del IME en composición (el
1790    //    texto compuesto se pinta desde `p.col`), para que quede al final
1791    //    de lo que el usuario está tecleando. En la fase "apagada" del
1792    //    parpadeo (`caret_on == false`) no se dibuja ningún caret.
1793    let preedit_cols = state.preedit.as_ref().map_or(0, |p| p.text.chars().count());
1794    for c in state.all_cursors() {
1795        let p = c.caret;
1796        if state.caret_on {
1797            let is_primary = core::ptr::eq(c, &state.cursor);
1798            let col_doc = if is_primary { p.col + preedit_cols } else { p.col };
1799            if let Some((fila, col)) = v.ubicar(p.line, col_doc) {
1800                children.push(caret_rect(crate::cursor::Pos::new(fila, col), metrics, palette));
1801            }
1802        }
1803    }
1804
1805    // 5) Preedit del IME — texto en composición pintado en el caret con
1806    //    subrayado, todavía fuera del buffer. Sólo en el cursor primario y
1807    //    si su línea está en viewport. (En mono el ancho es exacto; el
1808    //    texto que sigue al caret puede solaparse mientras se compone —
1809    //    transitorio y, en el caso típico de acentos, de un solo char.)
1810    if let Some(pre) = state.preedit.as_ref() {
1811        let p = state.cursor.caret;
1812        if let Some((local, col)) = v.ubicar(p.line, p.col) {
1813            children.extend(preedit_views(local, col, &pre.text, metrics, palette));
1814        }
1815    }
1816
1817    let click_cb = on_pointer.clone();
1818    let drag_end_cb = on_pointer.clone();
1819    let drag_cb = on_pointer;
1820    let vista = View::new(Style {
1821        flex_grow: 1.0,
1822        // Llena el alto del editor (campo continuo), con piso en el alto del
1823        // contenido para el modo embebido que scrollea (`_colored`).
1824        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
1825        min_size: Size { width: auto(), height: length(height) },
1826        ..Default::default()
1827    })
1828    .fill(palette.bg)
1829    .clip(true)
1830    // Cursor de texto (I-beam) sobre el área editable; fuera vuelve al normal.
1831    .cursor(llimphi_compositor::Cursor::Text)
1832    .on_click_at(move |x, y, _w, _h| click_cb(PointerEvent::Click { x, y }))
1833    .draggable_at(move |phase, dx, dy, lx, ly| match phase {
1834        llimphi_compositor::DragPhase::Move => drag_cb(PointerEvent::Drag {
1835            initial_x: lx,
1836            initial_y: ly,
1837            dx,
1838            dy,
1839        }),
1840        llimphi_compositor::DragPhase::End => drag_end_cb(PointerEvent::DragEnd),
1841    })
1842    .children(vec![capa_desplazada(children, desp.x, desp.y, desp.ancho_capa)]);
1843    // El menú contextual se ancla con coords de VENTANA: el popup vive en el
1844    // overlay de la app, no adentro del editor.
1845    let vista = match on_menu {
1846        Some(cb) => vista.on_right_click_screen(move |x, y, _w, _h| cb(x, y)),
1847        None => vista,
1848    };
1849    match medida {
1850        // Se mide en el pintado porque es el único momento en que taffy ya
1851        // repartió. El host lo lee al cuadro siguiente — el mismo cuadro de
1852        // latencia que paga cualquier lista con scroll del repo.
1853        Some(m) => {
1854            let m = m.clone();
1855            vista.paint_with(move |_scene, _ts, rect| m.escribir(rect.w, rect.h))
1856        }
1857        None => vista,
1858    }
1859}
1860
1861/// Las rayitas de sangría y el margen de columna: las dos ayudas que se pintan
1862/// **debajo** del texto.
1863///
1864/// Las guías salen de la sangría de la línea, no de un análisis: cada `indent`
1865/// columnas, una raya de 1 px, hasta donde arranca el texto. Una **línea en
1866/// blanco toma la sangría de la anterior con texto** — si no, un renglón vacío
1867/// en medio de una función corta todas las guías, que es justo donde más se las
1868/// mira. Ese rastreo hacia atrás está acotado: es una ayuda visual, no vale
1869/// recorrer el archivo por ella.
1870fn guias_y_margen<Msg: Clone + 'static>(
1871    state: &EditorState,
1872    palette: &EditorPalette,
1873    metrics: EditorMetrics,
1874    v: &Ventana<'_>,
1875    ayudas: Ayudas,
1876) -> Vec<View<Msg>> {
1877    let mut out: Vec<View<Msg>> = Vec::new();
1878    let color = with_alpha(palette.fg_line_number, 0.28);
1879
1880    if ayudas.guias && ayudas.indent > 0 {
1881        for (local, f) in v.recorrer_filas() {
1882            // Sólo la fila que arranca la línea: con ajuste de línea, las de
1883            // continuación no tienen sangría propia y repetirla ahí dibujaría
1884            // guías donde no hay bloque.
1885            if !f.es_primera() {
1886                continue;
1887            }
1888            // `1..niveles`, no `..=`: la raya del último nivel caería justo
1889            // debajo del primer carácter de la línea. Las que sirven son las de
1890            // adentro, las que dicen de qué bloque cuelga esto.
1891            let niveles = (sangria_efectiva(state, f.linea) / ayudas.indent).min(GUIAS_MAX);
1892            for nivel in 1..niveles {
1893                out.push(raya_vertical(
1894                    text_x(nivel * ayudas.indent, metrics) - 1.0,
1895                    text_y(local, metrics),
1896                    metrics.line_height,
1897                    color,
1898                ));
1899            }
1900        }
1901    }
1902
1903    if let Some(col) = ayudas.margen {
1904        out.push(
1905            View::new(Style {
1906                position: Position::Absolute,
1907                inset: Rect {
1908                    left: length(text_x(col, metrics)),
1909                    top: length(0.0_f32),
1910                    right: auto(),
1911                    bottom: length(0.0_f32),
1912                },
1913                size: Size { width: length(1.0_f32), height: percent(1.0_f32) },
1914                ..Default::default()
1915            })
1916            .fill(color),
1917        );
1918    }
1919    out
1920}
1921
1922/// Hasta cuántas guías por fila. Un archivo generado puede tener sangrías
1923/// absurdas, y cada guía es un nodo por cuadro.
1924const GUIAS_MAX: usize = 12;
1925/// Cuántas líneas mirar hacia atrás para la sangría de una línea en blanco.
1926const GUIAS_ATRAS: usize = 64;
1927
1928/// La sangría (en columnas) que le corresponde a una línea para dibujar guías.
1929/// Una línea en blanco hereda la de la anterior con texto.
1930fn sangria_efectiva(state: &EditorState, linea: usize) -> usize {
1931    let desde = linea.saturating_sub(GUIAS_ATRAS);
1932    for l in (desde..=linea).rev() {
1933        let texto = state.buffer.line(l);
1934        let mut cols = 0usize;
1935        for c in texto.chars() {
1936            match c {
1937                ' ' => cols += 1,
1938                '\t' => cols += crate::plegado::ANCHO_TAB - (cols % crate::plegado::ANCHO_TAB),
1939                '\n' | '\r' => break,
1940                _ => return cols,
1941            }
1942        }
1943    }
1944    0
1945}
1946
1947fn raya_vertical<Msg: Clone + 'static>(x: f32, y: f32, alto: f32, color: Color) -> View<Msg> {
1948    View::new(Style {
1949        position: Position::Absolute,
1950        inset: Rect { left: length(x.max(0.0)), top: length(y), right: auto(), bottom: auto() },
1951        size: Size { width: length(1.0_f32), height: length(alto) },
1952        ..Default::default()
1953    })
1954    .fill(color)
1955}
1956
1957/// Rect de tinte para una línea. Cubre el ancho completo y el alto
1958/// exacto de la línea, pintado al color literal pasado (el caller
1959/// elige el alpha). Posición absoluta dentro del área de contenido.
1960fn line_tint<Msg: Clone + 'static>(
1961    line: usize,
1962    color: Color,
1963    metrics: EditorMetrics,
1964) -> View<Msg> {
1965    View::new(Style {
1966        position: Position::Absolute,
1967        inset: Rect {
1968            left: length(0.0_f32),
1969            top: length(text_y(line, metrics)),
1970            right: length(0.0_f32),
1971            bottom: auto(),
1972        },
1973        size: Size {
1974            width: percent(1.0_f32),
1975            height: length(metrics.line_height),
1976        },
1977        ..Default::default()
1978    })
1979    .fill(color)
1980}
1981
1982fn line_highlight<Msg: Clone + 'static>(
1983    line: usize,
1984    metrics: EditorMetrics,
1985    palette: &EditorPalette,
1986) -> View<Msg> {
1987    View::new(Style {
1988        position: Position::Absolute,
1989        inset: Rect {
1990            left: length(0.0_f32),
1991            top: length(text_y(line, metrics)),
1992            right: length(0.0_f32),
1993            bottom: auto(),
1994        },
1995        size: Size {
1996            width: percent(1.0_f32),
1997            height: length(metrics.line_height),
1998        },
1999        ..Default::default()
2000    })
2001    .fill(palette.bg_current_line)
2002}
2003
2004/// Línea-fantasma para una guarda: un segmento horizontal con baja
2005/// opacidad atravesando el centro vertical de la línea. Ancho
2006/// limitado para que parezca un susurro y no una regla. Color derivado
2007/// de `fg_line_number` que ya está pensado como "muted".
2008fn phantom_guard_divider<Msg: Clone + 'static>(
2009    line: usize,
2010    metrics: EditorMetrics,
2011    palette: &EditorPalette,
2012) -> View<Msg> {
2013    let h = 1.0_f32;
2014    let y = text_y(line, metrics) + (metrics.line_height - h) * 0.5;
2015    // Largo visual del divisor — generoso pero no infinito.
2016    let w = 320.0_f32;
2017    View::new(Style {
2018        position: Position::Absolute,
2019        inset: Rect {
2020            left: length(8.0_f32),
2021            top: length(y),
2022            right: auto(),
2023            bottom: auto(),
2024        },
2025        size: Size {
2026            width: length(w),
2027            height: length(h),
2028        },
2029        ..Default::default()
2030    })
2031    .fill(with_alpha(palette.fg_line_number, 0.18))
2032}
2033
2034/// Recorta el rango de columnas `[a, b)` al tramo de la fila y lo rebasa a
2035/// ella. `None` si el rango no toca esta fila.
2036fn recortar_cols(a: usize, b: usize, f: Fila) -> Option<(usize, usize)> {
2037    let fin = f.col_fin.unwrap_or(usize::MAX);
2038    let a2 = a.max(f.col_inicio);
2039    let b2 = b.min(fin);
2040    (b2 > a2).then(|| (a2 - f.col_inicio, b2 - f.col_inicio))
2041}
2042
2043/// Los spans de syntax highlight que caen en esta fila, rebasados a ella.
2044/// Sin ajuste de línea no copia nada.
2045fn spans_de_fila(spans: &[Span], f: Fila) -> Cow<'_, [Span]> {
2046    if f.es_primera() && f.es_ultima() {
2047        return Cow::Borrowed(spans);
2048    }
2049    Cow::Owned(
2050        spans
2051            .iter()
2052            .filter_map(|s| {
2053                recortar_cols(s.start_col, s.end_col, f)
2054                    .map(|(a, b)| Span { start_col: a, end_col: b, kind: s.kind })
2055            })
2056            .collect(),
2057    )
2058}
2059
2060/// Ídem para los spans rich-text.
2061fn styled_de_fila(spans: &[StyledSpan], f: Fila) -> Cow<'_, [StyledSpan]> {
2062    if f.es_primera() && f.es_ultima() {
2063        return Cow::Borrowed(spans);
2064    }
2065    Cow::Owned(
2066        spans
2067            .iter()
2068            .filter_map(|s| {
2069                let (a, b) = recortar_cols(s.start_col, s.end_col, f)?;
2070                Some(StyledSpan { start_col: a, end_col: b, ..s.clone() })
2071            })
2072            .collect(),
2073    )
2074}
2075
2076/// Ídem para los runs de color del caller, que vienen en **bytes** y no en
2077/// columnas: se traducen los bordes de la fila a bytes una vez y se recorta
2078/// contra ellos.
2079fn runs_de_fila<'a>(
2080    runs: &'a [(usize, usize, Color)],
2081    linea: &str,
2082    f: Fila,
2083) -> Cow<'a, [(usize, usize, Color)]> {
2084    if f.es_primera() && f.es_ultima() {
2085        return Cow::Borrowed(runs);
2086    }
2087    let limpio = linea.trim_end_matches(['\n', '\r']);
2088    let b0 = crate::geometria::byte_de_columna(limpio, f.col_inicio);
2089    let b1 = f.col_fin.map_or(limpio.len(), |c| crate::geometria::byte_de_columna(limpio, c));
2090    Cow::Owned(
2091        runs.iter()
2092            .filter_map(|(a, b, c)| {
2093                let a2 = (*a).max(b0);
2094                let b2 = (*b).min(b1);
2095                (b2 > a2).then(|| (a2 - b0, b2 - b0, *c))
2096            })
2097            .collect(),
2098    )
2099}
2100
2101fn line_text_plain<Msg: Clone + 'static>(
2102    line: usize,
2103    text: String,
2104    metrics: EditorMetrics,
2105    palette: &EditorPalette,
2106) -> View<Msg> {
2107    View::new(Style {
2108        position: Position::Absolute,
2109        inset: Rect {
2110            left: length(PAD_X),
2111            top: length(text_y(line, metrics)),
2112            right: auto(),
2113            bottom: auto(),
2114        },
2115        size: Size {
2116            width: length(2000.0_f32),
2117            height: length(metrics.line_height),
2118        },
2119        align_items: Some(AlignItems::CENTER),
2120        ..Default::default()
2121    })
2122    .text_aligned(text, metrics.font_size, palette.fg_text, Alignment::Start)
2123    .mono()
2124}
2125
2126/// Renderiza una línea como secuencia de Views absolutos posicionados,
2127/// cada uno con el color de su span. El posicionamiento horizontal usa
2128/// `char_width` (mono); para fuentes proporcionales habría que medir
2129/// cada token con parley (TODO).
2130fn line_text_tokens<Msg: Clone + 'static>(
2131    line: usize,
2132    text: &str,
2133    spans: &[Span],
2134    metrics: EditorMetrics,
2135    palette: &EditorPalette,
2136    syntax: &SyntaxPalette,
2137) -> View<Msg> {
2138    // char-col → byte-offset: parley rangea por bytes, los spans por chars.
2139    let mut byte_at: Vec<usize> = Vec::with_capacity(text.len() + 1);
2140    let mut acc = 0usize;
2141    byte_at.push(0);
2142    for ch in text.chars() {
2143        acc += ch.len_utf8();
2144        byte_at.push(acc);
2145    }
2146    let nchars = byte_at.len() - 1;
2147
2148    // Un run de color por span no-Other (el default_color cubre el resto).
2149    let mut runs: Vec<(usize, usize, Color)> = Vec::with_capacity(spans.len());
2150    for span in spans {
2151        if span.start_col >= nchars || matches!(span.kind, TokenKind::Other) {
2152            continue;
2153        }
2154        let end = span.end_col.min(nchars);
2155        if end <= span.start_col {
2156            continue;
2157        }
2158        runs.push((byte_at[span.start_col], byte_at[end], syntax.color(span.kind)));
2159    }
2160
2161    // Una sola línea shapeada de una vez, multicolor, en lugar de un nodo
2162    // (+ layout parley) por token. El `+4` de gutter va en el inset del nodo.
2163    View::new(Style {
2164        position: Position::Absolute,
2165        inset: Rect {
2166            left: length(PAD_X),
2167            top: length(text_y(line, metrics)),
2168            right: auto(),
2169            bottom: auto(),
2170        },
2171        size: Size { width: length(2000.0_f32), height: length(metrics.line_height) },
2172        ..Default::default()
2173    })
2174    .text_runs(
2175        text.to_string(),
2176        metrics.font_size,
2177        palette.fg_text,
2178        runs,
2179        Alignment::Start,
2180    )
2181    .mono()
2182}
2183
2184/// Como [`line_text_tokens`] pero con `(byte_start, byte_end, Color)`
2185/// explícitos provistos por el caller (coloreo semántico propio, p. ej. un
2186/// shell que tinta `ls`/paths/urls). El resto del texto va en `fg_text`.
2187fn line_text_color_runs<Msg: Clone + 'static>(
2188    line: usize,
2189    text: &str,
2190    runs: &[(usize, usize, Color)],
2191    metrics: EditorMetrics,
2192    palette: &EditorPalette,
2193) -> View<Msg> {
2194    View::new(Style {
2195        position: Position::Absolute,
2196        inset: Rect {
2197            left: length(PAD_X),
2198            top: length(text_y(line, metrics)),
2199            right: auto(),
2200            bottom: auto(),
2201        },
2202        size: Size { width: length(2000.0_f32), height: length(metrics.line_height) },
2203        ..Default::default()
2204    })
2205    .text_runs(
2206        text.to_string(),
2207        metrics.font_size,
2208        palette.fg_text,
2209        runs.to_vec(),
2210        Alignment::Start,
2211    )
2212    .mono()
2213}
2214
2215/// Renderiza una línea con spans rich-text ([`StyledSpan`]): construye los
2216/// [`llimphi_text::TextSpan`] (offsets en byte, vía el mapa char→byte) y los
2217/// pasa a `text_spans`. El fondo de cada span lo pinta [`styled_bg_rects`]
2218/// aparte. El texto fuera de todo span va en `palette.fg_text` /
2219/// `metrics.font_size`. Mantiene `.mono()` para que el posicionamiento por
2220/// `char_width` (caret/selección) siga cuadrando.
2221fn line_text_styled<Msg: Clone + 'static>(
2222    line: usize,
2223    text: &str,
2224    spans: &[StyledSpan],
2225    metrics: EditorMetrics,
2226    palette: &EditorPalette,
2227) -> View<Msg> {
2228    // char-col → byte-offset (parley rangea por bytes; los spans, por chars).
2229    let mut byte_at: Vec<usize> = Vec::with_capacity(text.len() + 1);
2230    let mut acc = 0usize;
2231    byte_at.push(0);
2232    for ch in text.chars() {
2233        acc += ch.len_utf8();
2234        byte_at.push(acc);
2235    }
2236    let nchars = byte_at.len() - 1;
2237
2238    let mut ts: Vec<llimphi_compositor::llimphi_text::TextSpan> = Vec::with_capacity(spans.len());
2239    for s in spans {
2240        if s.start_col >= nchars {
2241            continue;
2242        }
2243        let end = s.end_col.min(nchars);
2244        if end <= s.start_col {
2245            continue;
2246        }
2247        let style = llimphi_compositor::llimphi_text::TextSpanStyle {
2248            size_px: s.size_px,
2249            weight: s.weight,
2250            italic: s.italic,
2251            font_family: s.font_family.clone(),
2252            color: s.fg,
2253            underline: s.underline,
2254            strikethrough: s.strikethrough,
2255        };
2256        ts.push(llimphi_compositor::llimphi_text::TextSpan::new(
2257            byte_at[s.start_col],
2258            byte_at[end],
2259            style,
2260        ));
2261    }
2262
2263    View::new(Style {
2264        position: Position::Absolute,
2265        inset: Rect {
2266            left: length(PAD_X),
2267            top: length(text_y(line, metrics)),
2268            right: auto(),
2269            bottom: auto(),
2270        },
2271        size: Size { width: length(2000.0_f32), height: length(metrics.line_height) },
2272        ..Default::default()
2273    })
2274    .text_spans(
2275        text.to_string(),
2276        metrics.font_size,
2277        palette.fg_text,
2278        ts,
2279        Alignment::Start,
2280    )
2281    .mono()
2282}
2283
2284/// Rects de fondo (resaltado) de los spans de una línea con `bg` definido.
2285/// Char-cols → x/w por `char_width` (mono), mismo origen que el texto.
2286fn styled_bg_rects<Msg: Clone + 'static>(
2287    local_line: usize,
2288    spans: &[StyledSpan],
2289    metrics: EditorMetrics,
2290) -> Vec<View<Msg>> {
2291    let mut out: Vec<View<Msg>> = Vec::new();
2292    for s in spans {
2293        let Some(bg) = s.bg else { continue };
2294        if s.end_col <= s.start_col {
2295            continue;
2296        }
2297        let x = text_x(s.start_col, metrics);
2298        let w = (s.end_col - s.start_col) as f32 * metrics.char_width;
2299        out.push(
2300            View::new(Style {
2301                position: Position::Absolute,
2302                inset: Rect {
2303                    left: length(x),
2304                    top: length(text_y(local_line, metrics)),
2305                    right: auto(),
2306                    bottom: auto(),
2307                },
2308                size: Size { width: length(w), height: length(metrics.line_height) },
2309                ..Default::default()
2310            })
2311            .fill(bg),
2312        );
2313    }
2314    out
2315}
2316
2317fn caret_rect<Msg: Clone + 'static>(
2318    caret: Pos,
2319    metrics: EditorMetrics,
2320    palette: &EditorPalette,
2321) -> View<Msg> {
2322    let x = text_x(caret.col, metrics);
2323    let y = text_y(caret.line, metrics);
2324    View::new(Style {
2325        position: Position::Absolute,
2326        inset: Rect {
2327            left: length(x),
2328            top: length(y + 2.0),
2329            right: auto(),
2330            bottom: auto(),
2331        },
2332        size: Size { width: length(2.0_f32), height: length(metrics.line_height - 4.0) },
2333        ..Default::default()
2334    })
2335    .fill(palette.caret)
2336}
2337
2338/// Pinta el texto en composición del IME en `(local_line, col)`: el texto
2339/// provisional + un subrayado debajo que lo marca como no-confirmado.
2340/// Devuelve los dos Views (texto y subrayado). Posición en coords del
2341/// área de contenido (mismo origen que [`line_text_plain`]).
2342fn preedit_views<Msg: Clone + 'static>(
2343    local_line: usize,
2344    col: usize,
2345    text: &str,
2346    metrics: EditorMetrics,
2347    palette: &EditorPalette,
2348) -> Vec<View<Msg>> {
2349    let x = text_x(col, metrics);
2350    let y = text_y(local_line, metrics);
2351    let w = (text.chars().count() as f32 * metrics.char_width).max(metrics.char_width);
2352    vec![
2353        // Texto provisional, en el color de texto normal.
2354        View::new(Style {
2355            position: Position::Absolute,
2356            inset: Rect {
2357                left: length(x),
2358                top: length(y),
2359                right: auto(),
2360                bottom: auto(),
2361            },
2362            size: Size { width: length(w), height: length(metrics.line_height) },
2363            align_items: Some(AlignItems::CENTER),
2364            ..Default::default()
2365        })
2366        .text_aligned(text.to_string(), metrics.font_size, palette.fg_text, Alignment::Start)
2367        .mono(),
2368        // Subrayado: una línea fina en el color del caret bajo el texto.
2369        View::new(Style {
2370            position: Position::Absolute,
2371            inset: Rect {
2372                left: length(x),
2373                top: length(y + metrics.line_height - 2.0),
2374                right: auto(),
2375                bottom: auto(),
2376            },
2377            size: Size { width: length(w), height: length(1.5_f32) },
2378            ..Default::default()
2379        })
2380        .fill(palette.caret),
2381    ]
2382}
2383
2384fn bracket_highlight<Msg: Clone + 'static>(
2385    pos: Pos,
2386    metrics: EditorMetrics,
2387    palette: &EditorPalette,
2388) -> View<Msg> {
2389    let x = text_x(pos.col, metrics);
2390    let y = text_y(pos.line, metrics);
2391    View::new(Style {
2392        position: Position::Absolute,
2393        inset: Rect {
2394            left: length(x),
2395            top: length(y),
2396            right: auto(),
2397            bottom: auto(),
2398        },
2399        size: Size { width: length(metrics.char_width), height: length(metrics.line_height) },
2400        ..Default::default()
2401    })
2402    .fill(palette.bg_bracket_pair)
2403}
2404
2405fn diagnostic_underline<Msg: Clone + 'static>(
2406    d: &Diagnostic,
2407    v: &Ventana<'_>,
2408    metrics: EditorMetrics,
2409    palette: &EditorPalette,
2410) -> Vec<View<Msg>> {
2411    let color = match d.severity {
2412        Severity::Error => palette.diag_error,
2413        Severity::Warning => palette.diag_warning,
2414        Severity::Information => palette.diag_info,
2415        Severity::Hint => palette.diag_hint,
2416    };
2417    let mut out: Vec<View<Msg>> = Vec::new();
2418    let (vis_primera, vis_ultima) = v.rango_lineas();
2419    let first = d.range.start.line.max(vis_primera);
2420    let last = d.range.end.line.min(vis_ultima);
2421    if first > last {
2422        return out;
2423    }
2424    for line in first..=last {
2425        let col_start = if line == d.range.start.line { d.range.start.col } else { 0 };
2426        let col_end = if line == d.range.end.line {
2427            d.range.end.col
2428        } else {
2429            // Fin de línea — extendemos 1 char extra para visualizar el wrap.
2430            col_start + 1
2431        };
2432        if col_end <= col_start {
2433            continue;
2434        }
2435        // Subrayado de 1.5 px al pie de cada fila que el rango toca.
2436        for t in v.tramos(line, col_start, col_end) {
2437            let w = (t.col_b - t.col_a) as f32 * metrics.char_width;
2438            out.push(
2439                View::new(Style {
2440                    position: Position::Absolute,
2441                    inset: Rect {
2442                        left: length(text_x(t.col_a, metrics)),
2443                        top: length(text_y(t.local, metrics) + metrics.line_height - 2.0),
2444                        right: auto(),
2445                        bottom: auto(),
2446                    },
2447                    size: Size { width: length(w), height: length(1.5_f32) },
2448                    ..Default::default()
2449                })
2450                .fill(color),
2451            );
2452        }
2453    }
2454    out
2455}
2456
2457fn match_rects<Msg: Clone + 'static>(
2458    state: &EditorState,
2459    start_off: usize,
2460    end_off: usize,
2461    v: &Ventana<'_>,
2462    metrics: EditorMetrics,
2463    palette: &EditorPalette,
2464) -> Vec<View<Msg>> {
2465    if start_off == end_off {
2466        return vec![];
2467    }
2468    let (start_line, start_col) = state.buffer.offset_to_pos(start_off);
2469    let (end_line, end_col) = state.buffer.offset_to_pos(end_off);
2470    let mut out: Vec<View<Msg>> = Vec::new();
2471    let (vis_primera, vis_ultima) = v.rango_lineas();
2472    let first = start_line.max(vis_primera);
2473    let last = end_line.min(vis_ultima);
2474    if first > last {
2475        return out;
2476    }
2477    for line in first..=last {
2478        let line_len = state.buffer.line_len_chars(line);
2479        let col_start = if line == start_line { start_col } else { 0 };
2480        let col_end = if line == end_line { end_col } else { line_len };
2481        if col_end <= col_start {
2482            continue;
2483        }
2484        for t in v.tramos(line, col_start, col_end) {
2485            let x = text_x(t.col_a, metrics);
2486            let w = (t.col_b - t.col_a) as f32 * metrics.char_width;
2487            out.push(
2488                View::new(Style {
2489                    position: Position::Absolute,
2490                    inset: Rect {
2491                        left: length(x),
2492                        top: length(text_y(t.local, metrics)),
2493                        right: auto(),
2494                        bottom: auto(),
2495                    },
2496                    size: Size { width: length(w), height: length(metrics.line_height) },
2497                    ..Default::default()
2498                })
2499                .fill(palette.bg_match),
2500            );
2501        }
2502    }
2503    out
2504}
2505
2506fn selection_rects_for_cursor<Msg: Clone + 'static>(
2507    state: &EditorState,
2508    cursor: &crate::cursor::Cursor,
2509    v: &Ventana<'_>,
2510    metrics: EditorMetrics,
2511    palette: &EditorPalette,
2512) -> Vec<View<Msg>> {
2513    let (start_off, end_off) = cursor.selection_range(&state.buffer);
2514    if start_off == end_off {
2515        return vec![];
2516    }
2517    let (start_line, start_col) = state.buffer.offset_to_pos(start_off);
2518    let (end_line, end_col) = state.buffer.offset_to_pos(end_off);
2519
2520    let mut out: Vec<View<Msg>> = Vec::new();
2521    let (vis_primera, vis_ultima) = v.rango_lineas();
2522    let first = start_line.max(vis_primera);
2523    let last = end_line.min(vis_ultima);
2524    if first > last {
2525        return out;
2526    }
2527    for line in first..=last {
2528        let line_len = state.buffer.line_len_chars(line);
2529        let col_start = if line == start_line { start_col } else { 0 };
2530        let col_end = if line == end_line { end_col } else { line_len };
2531        // El chorrito de un char que marca «acá sigue en el renglón de abajo»
2532        // sólo lo pinta la fila que **termina** la línea lógica: con ajuste, las
2533        // filas del medio no cierran ningún salto de línea.
2534        for t in v.tramos(line, col_start, col_end.max(col_start + 1)) {
2535            let vacio = col_end <= col_start;
2536            // La juntura entre dos párrafos, pintada hasta el margen: ver
2537            // `EditorMetrics::seleccion_hasta_el_margen`. Va antes del cálculo
2538            // del ancho porque acá no hay ancho que calcular — el rect se estira
2539            // entre los dos bordes y taffy lo resuelve.
2540            if vacio && line < end_line && metrics.seleccion_hasta_el_margen {
2541                out.push(
2542                    View::new(Style {
2543                        position: Position::Absolute,
2544                        inset: Rect {
2545                            left: length(text_x(0, metrics)),
2546                            top: length(text_y(t.local, metrics)),
2547                            right: length(0.0_f32),
2548                            bottom: auto(),
2549                        },
2550                        size: Size { width: auto(), height: length(metrics.line_height) },
2551                        ..Default::default()
2552                    })
2553                    .fill(palette.bg_selection),
2554                );
2555                continue;
2556            }
2557            let extra = if line < end_line && t.fin_de_linea { 1.0 } else { 0.0 };
2558            let ancho_cols = if vacio { 0.0 } else { (t.col_b - t.col_a) as f32 };
2559            let w = (ancho_cols + extra) * metrics.char_width;
2560            if w <= 0.0 {
2561                continue;
2562            }
2563            out.push(
2564                View::new(Style {
2565                    position: Position::Absolute,
2566                    inset: Rect {
2567                        left: length(text_x(t.col_a, metrics)),
2568                        top: length(text_y(t.local, metrics)),
2569                        right: auto(),
2570                        bottom: auto(),
2571                    },
2572                    size: Size { width: length(w), height: length(metrics.line_height) },
2573                    ..Default::default()
2574                })
2575                .fill(palette.bg_selection),
2576            );
2577        }
2578    }
2579    out
2580}
2581
2582/// Redondeo half-away-from-zero sin `std` — `f32::round` es un método de la
2583/// stdlib, no una operación del lenguaje (misma razón que el `ceil_f32` del
2584/// compositor; NUNCA `FloatFuncs` de kurbo, es una trampa de cfg).
2585#[inline]
2586fn redondear(x: f32) -> f32 {
2587    if x >= 0.0 {
2588        (x + 0.5) as i64 as f32
2589    } else {
2590        (x - 0.5) as i64 as f32
2591    }
2592}
2593
2594#[cfg(test)]
2595mod tests {
2596    use super::*;
2597    use crate::geometria::{Desplazamiento, Disposicion};
2598
2599    /// Cuenta los nodos del árbol de `View` — el número que de verdad importa:
2600    /// es lo que se le manda a taffy y a wgpu por cuadro.
2601    fn nodos<Msg: Clone + 'static>(v: &View<Msg>) -> usize {
2602        1 + v.children.iter().map(nodos).sum::<usize>()
2603    }
2604
2605    fn estado_de(lineas: usize) -> EditorState {
2606        let mut s = EditorState::new();
2607        let mut t = String::new();
2608        for i in 0..lineas {
2609            t.push_str("fn linea_");
2610            t.push_str(&i.to_string());
2611            t.push_str("() { let x = 1; }\n");
2612        }
2613        s.set_text(&t);
2614        s
2615    }
2616
2617    fn render(state: &EditorState, vp: &Viewport) -> View<()> {
2618        text_editor_view_viewport(
2619            state,
2620            &EditorPalette::default(),
2621            vp,
2622            OpcionesRender::default(),
2623            |_| None,
2624        )
2625    }
2626
2627    /// **La juntura entre dos párrafos, seleccionada, es un bloque y no un
2628    /// hilo** (`seleccion_hasta_el_margen`).
2629    ///
2630    /// Con el default —el chorrito de un carácter que marca el salto de línea—
2631    /// seleccionar tres párrafos de un `.pluma` daba tres franjas sueltas: cada
2632    /// juntura es una línea en blanco. Medido sobre el volcado: la selección se
2633    /// cortaba en cinco tramos y ahora es uno solo.
2634    #[test]
2635    fn la_linea_en_blanco_del_medio_se_selecciona_hasta_el_margen() {
2636        // "uno" / "" / "dos" — dos párrafos y su juntura.
2637        let mut st = EditorState::new();
2638        st.set_text("uno\n\ndos\n");
2639        st.cursor.caret = crate::cursor::Pos::new(0, 0);
2640        st.cursor.anchor = None;
2641        st.extend_selection_to(2, 3);
2642
2643        let ancho = |m: EditorMetrics| -> f32 {
2644            let _ = m;
2645            let vp = Viewport::medido(800.0, 400.0, m);
2646            let v: View<()> = text_editor_view_viewport(
2647                &st,
2648                &EditorPalette::default(),
2649                &vp,
2650                OpcionesRender::default(),
2651                |_| None,
2652            );
2653            // El rect de la línea 1 (la vacía): el que arranca en su `y`.
2654            let y = text_y(1, m);
2655            let mut ancho = 0.0_f32;
2656            // `auto` se distingue de un largo por el flag del propio valor
2657            // (taffy los empaqueta en un `CompactLength`, no en un enum).
2658            fn buscar(v: &View<()>, y: f32, sel: Color, out: &mut f32) {
2659                let top = v.style.inset.top.into_raw();
2660                // Sólo los rects de SELECCIÓN: a esa misma `y` hay un tinte de
2661                // línea, el renglón activo y la capa de contenido.
2662                if v.fill == Some(sel) && !top.is_auto() && (top.value() - y).abs() < 0.01 {
2663                    let w = v.style.size.width;
2664                    if w.is_auto() && !v.style.inset.right.into_raw().is_auto() {
2665                        *out = f32::INFINITY; // estirado entre los dos bordes
2666                    } else if !w.is_auto() {
2667                        *out = out.max(w.value());
2668                    }
2669                }
2670                for h in &v.children {
2671                    buscar(h, y, sel, out);
2672                }
2673            }
2674            buscar(&v, y, EditorPalette::default().bg_selection, &mut ancho);
2675            ancho
2676        };
2677
2678        let base = EditorMetrics::for_font_size(13.0);
2679        let chorrito = ancho(base);
2680        assert!(
2681            chorrito > 0.0 && chorrito <= base.char_width * 1.5,
2682            "el default sigue siendo el chorrito de un carácter: {chorrito}"
2683        );
2684        assert!(
2685            ancho(base.con_seleccion_hasta_el_margen(true)).is_infinite(),
2686            "con la perilla puesta, la juntura se pinta hasta el margen"
2687        );
2688    }
2689
2690    #[test]
2691    fn el_render_no_crece_con_el_tamano_del_archivo() {
2692        let metrics = EditorMetrics::for_font_size(13.0);
2693        let vp = Viewport::medido(800.0, 400.0, metrics);
2694        let chico = nodos(&render(&estado_de(1_000), &vp));
2695        let enorme = nodos(&render(&estado_de(100_000), &vp));
2696        // Ésta es la propiedad entera de la etapa: cien veces más archivo, el
2697        // mismo árbol. Antes el host pasaba `visible_lines` y el widget lo
2698        // capaba a 200 o 512 porque wgpu rechazaba el bind group — el tope era
2699        // el parche al síntoma de no virtualizar.
2700        assert_eq!(chico, enorme, "el árbol cambió con el tamaño del archivo");
2701        // Y es chico: ~23 filas de contenido + ~23 de gutter + andamiaje.
2702        assert!(enorme < 120, "árbol de {enorme} nodos para 23 filas visibles");
2703    }
2704
2705    #[test]
2706    fn el_render_crece_con_el_alto_del_panel_y_no_con_una_constante() {
2707        let metrics = EditorMetrics::for_font_size(13.0);
2708        let state = estado_de(10_000);
2709        let bajo = nodos(&render(&state, &Viewport::medido(800.0, 400.0, metrics)));
2710        let alto = nodos(&render(&state, &Viewport::medido(800.0, 1400.0, metrics)));
2711        // Un panel 3,5× más alto materializa más filas. Con la constante del
2712        // host, los dos daban exactamente lo mismo: 40 líneas.
2713        assert!(alto > bajo * 2, "panel alto: {alto} nodos vs panel bajo: {bajo}");
2714    }
2715
2716    #[test]
2717    fn el_camino_viejo_sigue_capado_y_el_nuevo_no_lo_necesita() {
2718        let metrics = EditorMetrics::for_font_size(13.0);
2719        let state = estado_de(10_000);
2720        // Un host que pide de más por el camino viejo: el cap lo corta en 200.
2721        let viejo: View<()> = text_editor_view_full(
2722            &state,
2723            &EditorPalette::default(),
2724            metrics,
2725            5_000,
2726            Language::Plain,
2727            &[],
2728            |_| None,
2729        );
2730        // Por el camino nuevo, el mismo alto de panel decide, sin tope.
2731        let nuevo = render(&state, &Viewport::medido(800.0, 400.0, metrics));
2732        assert!(
2733            nodos(&viejo) > nodos(&nuevo) * 5,
2734            "viejo {} nodos vs nuevo {}",
2735            nodos(&viejo),
2736            nodos(&nuevo)
2737        );
2738    }
2739
2740    #[test]
2741    fn la_barra_aparece_solo_cuando_hay_a_donde_ir() {
2742        let metrics = EditorMetrics::for_font_size(13.0);
2743        let vp = Viewport::medido(800.0, 400.0, metrics);
2744        // 10 líneas entran en 400 px: nada que indicar.
2745        let corto = Disposicion::para(10, metrics);
2746        assert!(barra_de_scroll::<()>(&vp, &corto, None).is_none());
2747        // 10.000 no entran: aparece.
2748        let largo = Disposicion::para(10_000, metrics);
2749        assert!(barra_de_scroll::<()>(&vp, &largo, None).is_some());
2750        // Y el árbol la lleva: un nodo más de canal, con su pulgar adentro.
2751        let con = render(&estado_de(10_000), &vp);
2752        assert!(con.children.iter().any(|c| c.children.len() == 1
2753            && c.children[0].children.is_empty()));
2754    }
2755
2756    #[test]
2757    fn la_barra_se_arrastra_y_mueve_el_documento() {
2758        let metrics = EditorMetrics::for_font_size(13.0);
2759        let d = Disposicion::para(10_000, metrics);
2760        let vp = Viewport::medido(800.0, 400.0, metrics);
2761        let cb: Arc<dyn Fn(f32) -> Option<f32> + Send + Sync> = Arc::new(Some);
2762        let canal = barra_de_scroll(&vp, &d, Some(cb)).expect("hay barra");
2763
2764        // Arrastrar el pulgar 10 px de PANTALLA tiene que mover muchos más
2765        // píxeles de DOCUMENTO: 10.000 líneas comprimidas en 400 px de canal.
2766        let arrastre = llimphi_compositor::raro!(canal, drag_at).expect("se arrastra");
2767        let salto = arrastre(llimphi_compositor::DragPhase::Move, 0.0, 10.0, 0.0, 0.0)
2768            .expect("el arrastre emite");
2769        assert!(salto > 100.0, "arrastrar 10 px movió sólo {salto} px de documento");
2770
2771        // Y el factor es el que corresponde: recorrer el canal entero tiene que
2772        // recorrer el documento entero, ni más ni menos.
2773        let (alto_thumb, _, _) =
2774            llimphi_widget_scroll::thumb_geometry(0.0, d.alto_total(), 400.0);
2775        let recorrido = 400.0 - alto_thumb;
2776        let total = arrastre(llimphi_compositor::DragPhase::Move, 0.0, recorrido, 0.0, 0.0)
2777            .expect("emite");
2778        assert!(
2779            (total - vp.max_y(&d)).abs() < d.alto_de(0),
2780            "recorrer el canal dio {total} y el documento mide {}",
2781            vp.max_y(&d)
2782        );
2783    }
2784
2785    #[test]
2786    fn el_click_en_el_canal_salta_una_pagina_y_sobre_el_pulgar_no() {
2787        let metrics = EditorMetrics::for_font_size(13.0);
2788        let d = Disposicion::para(10_000, metrics);
2789        let vp = Viewport::medido(800.0, 400.0, metrics);
2790        let cb: Arc<dyn Fn(f32) -> Option<f32> + Send + Sync> = Arc::new(Some);
2791        let canal = barra_de_scroll(&vp, &d, Some(cb)).expect("hay barra");
2792        let click = llimphi_compositor::raro!(canal, on_click_at).expect("se clickea");
2793
2794        // Con el scroll arriba del todo, el pulgar está pegado al tope: un click
2795        // bien abajo baja una página.
2796        let abajo = click(0.0, 390.0, CANAL_W, 400.0).expect("emite");
2797        assert!(abajo > 0.0, "el click de abajo dio {abajo}");
2798        // Y sobre el pulgar no emite nada: ese click empieza un arrastre.
2799        assert!(click(0.0, 1.0, CANAL_W, 400.0).is_none());
2800    }
2801
2802    #[test]
2803    fn la_rueda_la_atiende_el_editor_cuando_hay_a_quien_avisarle() {
2804        let metrics = EditorMetrics::for_font_size(13.0);
2805        let state = estado_de(10_000);
2806        let vp = Viewport::medido(800.0, 400.0, metrics);
2807
2808        // Sin `on_scroll` el editor no toca la rueda: cae al `on_wheel` del host,
2809        // que es como venía funcionando.
2810        let sordo: View<f32> = text_editor_view_viewport(
2811            &state,
2812            &EditorPalette::default(),
2813            &vp,
2814            OpcionesRender::default(),
2815            |_| None,
2816        );
2817        assert!(llimphi_compositor::raro!(sordo, on_scroll).is_none());
2818
2819        // Con `on_scroll`, la rueda la consume el editor: una muesca son tres
2820        // líneas, en píxeles.
2821        let cb: Arc<dyn Fn(f32) -> Option<f32> + Send + Sync> = Arc::new(Some);
2822        let vivo: View<f32> = text_editor_view_viewport(
2823            &state,
2824            &EditorPalette::default(),
2825            &vp,
2826            OpcionesRender { on_scroll: Some(cb), ..Default::default() },
2827            |_| None,
2828        );
2829        let rueda = llimphi_compositor::raro!(vivo, on_scroll).expect("atiende la rueda");
2830        let px = rueda(0.0, 1.0).expect("emite");
2831        assert!((px - 3.0 * metrics.line_height).abs() < 0.01, "una muesca dio {px} px");
2832        // Un delta cero no genera Msg — no hay por qué repintar.
2833        assert!(rueda(0.0, 0.0).is_none());
2834    }
2835
2836    #[test]
2837    fn el_editor_topado_le_suelta_la_rueda_al_ancestro() {
2838        let metrics = EditorMetrics::for_font_size(13.0);
2839        let state = estado_de(10_000);
2840        let cb: Arc<dyn Fn(f32) -> Option<f32> + Send + Sync> = Arc::new(Some);
2841        let arriba = Viewport::medido(800.0, 400.0, metrics);
2842        let vista: View<f32> = text_editor_view_viewport(
2843            &state,
2844            &EditorPalette::default(),
2845            &arriba,
2846            OpcionesRender { on_scroll: Some(cb.clone()), ..Default::default() },
2847            |_| None,
2848        );
2849        let rueda = llimphi_compositor::raro!(vista, on_scroll).expect("atiende");
2850        // En el tope, hacia arriba no consume; hacia abajo sí.
2851        assert!(rueda(0.0, -1.0).is_none());
2852        assert!(rueda(0.0, 1.0).is_some());
2853
2854        // Y al final del documento, al revés.
2855        let d = Disposicion::para(state.line_count(), metrics);
2856        let fondo = arriba.con_desplazamiento(Desplazamiento::desde_y(
2857            arriba.max_y(&d),
2858            0.0,
2859            &d,
2860        ));
2861        let vista: View<f32> = text_editor_view_viewport(
2862            &state,
2863            &EditorPalette::default(),
2864            &fondo,
2865            OpcionesRender { on_scroll: Some(cb), ..Default::default() },
2866            |_| None,
2867        );
2868        let rueda = llimphi_compositor::raro!(vista, on_scroll).expect("atiende");
2869        assert!(rueda(0.0, 1.0).is_none());
2870        assert!(rueda(0.0, -1.0).is_some());
2871    }
2872
2873    #[test]
2874    fn sin_medir_no_hay_barra() {
2875        let metrics = EditorMetrics::for_font_size(13.0);
2876        let vp = Viewport::medido(0.0, 0.0, metrics);
2877        let d = Disposicion::para(10_000, metrics);
2878        assert!(barra_de_scroll::<()>(&vp, &d, None).is_none());
2879    }
2880
2881    #[test]
2882    fn el_pulgar_baja_al_scrollear() {
2883        let metrics = EditorMetrics::for_font_size(13.0);
2884        let d = Disposicion::para(10_000, metrics);
2885        let arriba = Viewport::medido(800.0, 400.0, metrics);
2886        let abajo = arriba.con_desplazamiento(Desplazamiento::en_fila(9_000));
2887        let (_, y0, _) = llimphi_widget_scroll::thumb_geometry(
2888            arriba.desplazamiento.y_documento(&d),
2889            d.alto_total(),
2890            400.0,
2891        );
2892        let (_, y1, _) = llimphi_widget_scroll::thumb_geometry(
2893            abajo.desplazamiento.y_documento(&d),
2894            d.alto_total(),
2895            400.0,
2896        );
2897        assert_eq!(y0, 0.0);
2898        assert!(y1 > y0);
2899    }
2900
2901    // ---------------------------------------------------------------
2902    // El plegado, visto desde el render.
2903    // ---------------------------------------------------------------
2904
2905    /// Un buffer con estructura: tres funciones con cuerpo indentado.
2906    fn estado_con_bloques(bloques: usize) -> EditorState {
2907        let mut s = EditorState::new();
2908        let mut t = String::new();
2909        for i in 0..bloques {
2910            t.push_str(&alloc::format!("fn f{i}() {{\n"));
2911            for j in 0..5 {
2912                t.push_str(&alloc::format!("    paso_{j}();\n"));
2913            }
2914            t.push_str("}\n");
2915        }
2916        s.set_text(&t);
2917        s
2918    }
2919
2920    #[test]
2921    fn plegar_saca_las_lineas_del_arbol_de_render() {
2922        let metrics = EditorMetrics::for_font_size(13.0);
2923        let vp = Viewport::medido(800.0, 400.0, metrics);
2924        let mut state = estado_con_bloques(3);
2925        let antes = nodos(&render(&state, &vp));
2926
2927        // La primera función encabeza en la línea 0 y su cuerpo son 5 líneas.
2928        assert!(state.alternar_pliegue(0), "la línea 0 tenía que ser plegable");
2929        let despues = nodos(&render(&state, &vp));
2930        // El árbol se achica: las líneas ocultas ya no generan ni número de
2931        // gutter ni texto. Es el mismo render de la etapa 1 sin tocar una sola
2932        // función de dibujo — lo único que cambió es el mapa.
2933        assert!(despues < antes, "plegar no sacó nodos: {antes} → {despues}");
2934    }
2935
2936    #[test]
2937    fn con_un_pliegue_el_render_empieza_en_la_linea_correcta() {
2938        let metrics = EditorMetrics::for_font_size(13.0);
2939        // Un panel de una sola fila, para poder mirar exactamente qué línea cae
2940        // en el tope.
2941        let vp = Viewport::medido(800.0, 20.0, metrics);
2942        let mut state = estado_con_bloques(3);
2943        state.alternar_pliegue(0);
2944        let d = state.disposicion(metrics);
2945        // Fila 0 = la cabecera; fila 1 = la llave de cierre (línea 6), NO la
2946        // línea 1, que está adentro del pliegue.
2947        assert_eq!(d.linea_de_fila(0).linea, 0);
2948        assert_eq!(d.linea_de_fila(1).linea, 6);
2949        assert_eq!(d.linea_de_fila(2).linea, 7); // la segunda función
2950    }
2951
2952    #[test]
2953    fn el_click_cae_en_la_linea_que_se_ve_con_el_bloque_plegado() {
2954        let metrics = EditorMetrics::for_font_size(13.0);
2955        let mut state = estado_con_bloques(3);
2956        state.alternar_pliegue(0);
2957        let vp = Viewport::medido(800.0, 400.0, metrics);
2958        // Clickear en la segunda fila visible tiene que dar la línea 6 (la
2959        // llave), no la 1. Sin esto, plegar dejaría el caret siempre corrido.
2960        let (linea, _) = state.pos_en(&vp, PAD_X, PAD_Y + metrics.line_height + 2.0);
2961        assert_eq!(linea, 6);
2962    }
2963
2964    #[test]
2965    fn el_gutter_dibuja_flecha_solo_donde_hay_algo_que_plegar() {
2966        let metrics = EditorMetrics::for_font_size(13.0);
2967        let vp = Viewport::medido(800.0, 400.0, metrics);
2968        let state = estado_con_bloques(3);
2969        let sin: View<usize> = text_editor_view_viewport(
2970            &state,
2971            &EditorPalette::default(),
2972            &vp,
2973            OpcionesRender::default(),
2974            |_| None,
2975        );
2976        let cb: Arc<dyn Fn(usize) -> Option<usize> + Send + Sync> = Arc::new(Some);
2977        let con: View<usize> = text_editor_view_viewport(
2978            &state,
2979            &EditorPalette::default(),
2980            &vp,
2981            OpcionesRender { on_pliegue: Some(cb), ..Default::default() },
2982            |_| None,
2983        );
2984        // Tres bloques plegables = tres flechas de más en el gutter. Sin
2985        // callback no se dibuja ninguna: un botón que no hace nada es peor que
2986        // no tenerlo.
2987        assert_eq!(nodos(&con), nodos(&sin) + 3);
2988    }
2989
2990    #[test]
2991    fn el_pliegue_se_deshace_solo_si_el_bloque_deja_de_existir() {
2992        let metrics = EditorMetrics::for_font_size(13.0);
2993        let mut state = estado_con_bloques(2);
2994        let lineas = state.line_count();
2995        state.alternar_pliegue(0);
2996        // El cuerpo son las cinco líneas indentadas: la llave de cierre vuelve
2997        // a la sangría de la firma, así que cierra el bloque y queda visible.
2998        assert_eq!(state.disposicion(metrics).filas(), lineas - 5);
2999
3000        // El usuario borra el cuerpo de la primera función: la cabecera deja de
3001        // encabezar nada. El pliegue no puede seguir escondiendo las líneas de
3002        // otro, así que no produce tramo.
3003        state.set_text("fn f0() {}\nfn f1() {\n    paso();\n}\n");
3004        let d = state.disposicion(metrics);
3005        assert!(d.pliegues().is_empty(), "{:?}", d.pliegues());
3006        assert_eq!(d.filas(), state.line_count());
3007    }
3008
3009    #[test]
3010    fn revelar_abre_lo_que_haga_falta_para_ver_una_linea() {
3011        let mut state = estado_con_bloques(3);
3012        state.alternar_pliegue(0);
3013        assert!(state.esta_plegado(0));
3014        // Saltar a la línea 3, que está adentro del pliegue: se abre. Aterrizar
3015        // en un pliegue y no ver el caret es peor que no saltar.
3016        state.revelar(3);
3017        assert!(!state.esta_plegado(0));
3018        // Y revelar algo de afuera no toca nada.
3019        state.alternar_pliegue(0);
3020        state.revelar(8);
3021        assert!(state.esta_plegado(0));
3022    }
3023
3024    #[test]
3025    fn plegar_todo_toma_los_de_afuera_y_no_los_anidados() {
3026        let mut state = EditorState::new();
3027        state.set_text("impl X {\n    fn a() {\n        uno();\n    }\n}\nfn b() {\n    dos();\n}\n");
3028        state.plegar_todo();
3029        // Dos bloques externos: el `impl` y `fn b`. El `fn a` está adentro del
3030        // primero, y plegarlo además no cambiaría nada de lo que se ve.
3031        assert_eq!(state.plegados(), &[0, 5]);
3032    }
3033
3034    #[test]
3035    fn plegar_donde_esta_el_caret_lo_sube_a_la_cabecera_y_no_se_deshace() {
3036        let mut state = estado_con_bloques(2);
3037        state.set_caret_at(3, 4); // adentro del cuerpo de la primera función
3038        state.alternar_pliegue(0);
3039        // El pliegue queda hecho — plegar no puede desarmarse solo — y el caret
3040        // sube a la cabecera, que es lo único visible de ese bloque.
3041        assert!(state.esta_plegado(0));
3042        assert_eq!(state.cursor.caret.line, 0);
3043    }
3044
3045    #[test]
3046    fn saltar_adentro_de_un_pliegue_lo_abre() {
3047        let mut state = estado_con_bloques(2);
3048        state.alternar_pliegue(0);
3049        // Un salto pone el caret y después revela: al revés que plegar.
3050        state.set_caret_at(3, 0);
3051        state.revelar_caret();
3052        assert!(!state.esta_plegado(0));
3053        assert_eq!(state.cursor.caret.line, 3);
3054    }
3055
3056    #[test]
3057    fn el_scroll_fino_corre_la_capa_y_no_las_lineas() {
3058        let metrics = EditorMetrics::for_font_size(13.0);
3059        let state = estado_de(1_000);
3060        let vp = Viewport::medido(800.0, 400.0, metrics);
3061        let quieto = nodos(&render(&state, &vp));
3062        let fino = vp.con_desplazamiento(Desplazamiento {
3063            fila_ancla: 10,
3064            fino_px: 7.0,
3065            x_px: 0.0,
3066        });
3067        // Desplazarse medio renglón no agrega ni saca nodos: el corrimiento lo
3068        // absorbe la capa contenedora, no las veinte funciones de dibujo.
3069        assert_eq!(nodos(&render(&state, &fino)), quieto);
3070    }
3071
3072    #[test]
3073    fn con_el_panel_sin_medir_todavia_igual_se_pinta_algo() {
3074        let metrics = EditorMetrics::for_font_size(13.0);
3075        let state = estado_de(1_000);
3076        // El primer cuadro: `TamanoVisible` todavía no publicó nada.
3077        let vp = Viewport::medido(0.0, 0.0, metrics);
3078        let d = Disposicion::para(state.line_count(), metrics);
3079        assert_eq!(vp.rango_visible(&d).cuantas(), crate::geometria::FILAS_SIN_MEDIDA + 2);
3080        assert!(nodos(&render(&state, &vp)) > 10);
3081    }
3082
3083    // ---------------------------------------------------------------
3084    // El ajuste de línea, visto desde el render.
3085    // ---------------------------------------------------------------
3086
3087    /// Un buffer de prosa: `lineas` párrafos largos, del tipo que no entra en
3088    /// el panel y hoy se va a la derecha.
3089    fn estado_de_prosa(lineas: usize, palabras: usize) -> EditorState {
3090        let mut s = EditorState::new();
3091        let mut t = String::new();
3092        for _ in 0..lineas {
3093            for j in 0..palabras {
3094                t.push_str(&alloc::format!("palabra{j} "));
3095            }
3096            t.push('\n');
3097        }
3098        s.set_text(&t);
3099        s
3100    }
3101
3102    /// El estado con el ajuste ya armado contra un panel de `ancho` px.
3103    fn ajustado(mut s: EditorState, ancho: f32, metrics: EditorMetrics) -> EditorState {
3104        s.ajuste_de_linea = true;
3105        s.preparar_ajuste(ancho, metrics);
3106        s
3107    }
3108
3109    #[test]
3110    fn sin_ajuste_una_linea_larga_sigue_siendo_un_solo_renglon() {
3111        // El brazo de control: el mismo buffer, sin la perilla puesta, se
3112        // comporta exactamente como antes.
3113        let metrics = EditorMetrics::for_font_size(13.0);
3114        let s = estado_de_prosa(3, 40);
3115        assert_eq!(s.disposicion(metrics).filas(), 4); // 3 párrafos + la vacía final
3116    }
3117
3118    #[test]
3119    fn el_ajuste_reparte_la_linea_sin_perder_ni_repetir_un_caracter() {
3120        let metrics = EditorMetrics::for_font_size(13.0);
3121        let s = ajustado(estado_de_prosa(1, 60), 400.0, metrics);
3122        let d = s.disposicion(metrics);
3123        assert!(d.ajustada());
3124        // 400 px de panel a 7,8 px por char son 49 columnas: un párrafo de ~540
3125        // caracteres no entra en un renglón ni en dos.
3126        assert!(d.filas() > 8, "{} filas", d.filas());
3127        // Y la propiedad que importa: las filas, pegadas en orden, son la línea
3128        // entera. Ni un char de menos (texto que desaparece) ni uno de más
3129        // (texto duplicado en el corte).
3130        let linea = s.buffer.line(0);
3131        let junto: String = (0..d.filas())
3132            .filter(|f| d.linea_de_fila(*f).linea == 0)
3133            .map(|f| texto_de_fila(&linea, d.linea_de_fila(f)).into_owned())
3134            .collect();
3135        assert_eq!(junto, linea.trim_end_matches('\n'));
3136    }
3137
3138    #[test]
3139    fn el_gutter_numera_la_linea_una_sola_vez_aunque_ocupe_diez_renglones() {
3140        let metrics = EditorMetrics::for_font_size(13.0);
3141        let s = ajustado(estado_de_prosa(1, 60), 400.0, metrics);
3142        let d = s.disposicion(metrics);
3143        let v = Ventana::nueva(&d, 0, d.filas());
3144        let numeradas = v.recorrer_filas().filter(|(_, f)| f.es_primera()).count();
3145        // Dos líneas lógicas (el párrafo y la vacía del final), diez y pico de
3146        // filas: dos números.
3147        assert_eq!(numeradas, 2);
3148        assert!(v.filas() > numeradas);
3149    }
3150
3151    #[test]
3152    fn el_caret_al_final_de_la_linea_ajustada_se_pinta_en_su_renglon() {
3153        let metrics = EditorMetrics::for_font_size(13.0);
3154        let s = ajustado(estado_de_prosa(1, 60), 400.0, metrics);
3155        let d = s.disposicion(metrics);
3156        let v = Ventana::nueva(&d, 0, d.filas());
3157        let fin = s.buffer.line_len_chars(0).saturating_sub(1);
3158        let (local, col) = v.ubicar(0, fin).expect("el final se ve");
3159        // Está varios renglones más abajo que el principio de la línea...
3160        assert!(local > 5, "el caret quedó en el renglón {local}");
3161        // ...y su columna está rebasada a ese renglón, no es la del documento.
3162        assert!(col < 60, "columna {col} sin rebasar");
3163        assert_eq!(v.fila_de(local).col_inicio + col, fin);
3164    }
3165
3166    #[test]
3167    fn la_seleccion_de_una_linea_ajustada_se_parte_en_un_rectangulo_por_renglon() {
3168        let metrics = EditorMetrics::for_font_size(13.0);
3169        let s = ajustado(estado_de_prosa(1, 60), 400.0, metrics);
3170        let d = s.disposicion(metrics);
3171        let v = Ventana::nueva(&d, 0, d.filas());
3172        let fin = s.buffer.line_len_chars(0).saturating_sub(1);
3173        let tramos = v.tramos(0, 0, fin);
3174        assert!(tramos.len() > 5, "{} tramos", tramos.len());
3175        // Uno por renglón, en orden, y ninguno se sale de su fila.
3176        for (i, t) in tramos.iter().enumerate() {
3177            assert_eq!(t.local, i);
3178            assert!(t.col_b > t.col_a);
3179        }
3180        // El chorrito de fin de línea es de la última y de ninguna otra.
3181        assert_eq!(tramos.iter().filter(|t| t.fin_de_linea).count(), 1);
3182        assert!(tramos.last().expect("hay tramos").fin_de_linea);
3183    }
3184
3185    #[test]
3186    fn sin_ajuste_un_rango_de_una_linea_da_exactamente_el_rectangulo_de_antes() {
3187        let metrics = EditorMetrics::for_font_size(13.0);
3188        let s = estado_de(100);
3189        let d = s.disposicion(metrics);
3190        let v = Ventana::nueva(&d, 0, 20);
3191        let t = v.tramos(3, 2, 9);
3192        assert_eq!(t.len(), 1);
3193        assert_eq!((t[0].local, t[0].col_a, t[0].col_b, t[0].fin_de_linea), (3, 2, 9, true));
3194    }
3195
3196    #[test]
3197    fn con_ajuste_el_render_sigue_sin_crecer_con_el_archivo() {
3198        // La virtualización no se pierde por ajustar: el mapa crece con el
3199        // archivo, el árbol de `View` no.
3200        let metrics = EditorMetrics::for_font_size(13.0);
3201        let vp = Viewport::medido(400.0, 400.0, metrics);
3202        let chico = nodos(&render(&ajustado(estado_de_prosa(50, 40), 400.0, metrics), &vp));
3203        let grande = nodos(&render(&ajustado(estado_de_prosa(5_000, 40), 400.0, metrics), &vp));
3204        assert_eq!(chico, grande);
3205    }
3206
3207    #[test]
3208    fn el_ajuste_se_rearma_cuando_cambia_el_ancho_y_no_cuando_no() {
3209        let metrics = EditorMetrics::for_font_size(13.0);
3210        let mut s = ajustado(estado_de_prosa(1, 60), 400.0, metrics);
3211        let filas_angosto = s.disposicion(metrics).filas();
3212        // El mismo ancho no rearma nada — y sobre todo no cambia el mapa.
3213        s.preparar_ajuste(400.0, metrics);
3214        assert_eq!(s.disposicion(metrics).filas(), filas_angosto);
3215        // Un panel del doble de ancho reparte la misma línea en la mitad de
3216        // renglones. Ésta es la mitad que se rompe si el mapa no se invalida.
3217        s.preparar_ajuste(900.0, metrics);
3218        let filas_ancho = s.disposicion(metrics).filas();
3219        assert!(filas_ancho < filas_angosto, "{filas_ancho} vs {filas_angosto}");
3220    }
3221
3222    #[test]
3223    fn apagar_el_ajuste_devuelve_el_documento_a_una_fila_por_linea() {
3224        let metrics = EditorMetrics::for_font_size(13.0);
3225        let mut s = ajustado(estado_de_prosa(3, 60), 400.0, metrics);
3226        assert!(s.disposicion(metrics).filas() > 4);
3227        assert!(!s.alternar_ajuste());
3228        assert_eq!(s.disposicion(metrics).filas(), 4);
3229    }
3230
3231    #[test]
3232    fn editar_una_linea_ajustada_rearma_el_mapa() {
3233        // Sin invalidar por `edit_seq`, el mapa viejo sigue cortando donde el
3234        // texto ya no está — y eso no se ve como un error, se ve como texto
3235        // pintado en el renglón equivocado.
3236        let metrics = EditorMetrics::for_font_size(13.0);
3237        let mut s = ajustado(estado_de_prosa(1, 60), 400.0, metrics);
3238        let antes = s.disposicion(metrics).filas();
3239        s.set_text("corto\n");
3240        s.preparar_ajuste(400.0, metrics);
3241        let d = s.disposicion(metrics);
3242        assert!(!d.ajustada(), "el mapa viejo sobrevivió a la edición");
3243        assert!(d.filas() < antes);
3244    }
3245    // ---------------------------------------------------------------
3246    // El contexto pegado arriba.
3247    // ---------------------------------------------------------------
3248
3249    fn render_con<Msg: Clone + 'static>(
3250        state: &EditorState,
3251        vp: &Viewport,
3252        pegajosas: usize,
3253        on_pegajosa: Option<Arc<dyn Fn(usize) -> Option<Msg> + Send + Sync>>,
3254    ) -> View<Msg> {
3255        text_editor_view_viewport(
3256            state,
3257            &EditorPalette::default(),
3258            vp,
3259            OpcionesRender { pegajosas, on_pegajosa, ..Default::default() },
3260            |_| None,
3261        )
3262    }
3263
3264    #[test]
3265    fn el_contexto_son_los_ancestros_del_renglon_de_arriba() {
3266        let mut s = EditorState::new();
3267        s.set_text(&estructura());
3268        // Parado en el cuerpo del `if` de la segunda función.
3269        assert_eq!(s.lineas_pegajosas(4, 4), alloc::vec![0, 1, 2, 3]);
3270        // Y en la primera línea no hay nada arriba que pegar.
3271        assert!(s.lineas_pegajosas(0, 4).is_empty());
3272    }
3273
3274    /// `mod` → `impl` → `fn` → `if` → cuerpo, cada uno un nivel más adentro.
3275    fn estructura() -> String {
3276        alloc::string::String::from(
3277            "mod uno {\n    impl Dos {\n        fn tres() {\n            if x {\n                hacer();\n            }\n        }\n    }\n}\n",
3278        )
3279    }
3280
3281    #[test]
3282    fn cuando_no_entran_todos_se_quedan_los_de_adentro() {
3283        // El `fn` en el que estás importa más que el `mod` de arriba de todo.
3284        let mut s = EditorState::new();
3285        s.set_text(&estructura());
3286        assert_eq!(s.lineas_pegajosas(4, 2), alloc::vec![2, 3]);
3287        assert!(s.lineas_pegajosas(4, 0).is_empty());
3288    }
3289
3290    #[test]
3291    fn sin_pegajosas_el_arbol_es_exactamente_el_de_antes() {
3292        // El brazo de control: la opción en 0 no agrega un solo nodo. Es lo que
3293        // deja tranquilos a los otros ocho consumidores del widget, que no la
3294        // pasan.
3295        let metrics = EditorMetrics::for_font_size(13.0);
3296        let mut s = EditorState::new();
3297        s.set_text(&estructura().repeat(40));
3298        let vp = Viewport::medido(800.0, 400.0, metrics)
3299            .con_desplazamiento(Desplazamiento::en_fila(100));
3300        let sin = nodos(&render_con::<()>(&s, &vp, 0, None));
3301        let con = nodos(&render_con::<()>(&s, &vp, 4, None));
3302        assert_eq!(sin, nodos(&render(&s, &vp)));
3303        assert!(con > sin, "la banda no agregó nada: {con} vs {sin}");
3304    }
3305
3306    #[test]
3307    fn la_banda_lleva_a_la_linea_que_se_clickeo() {
3308        let metrics = EditorMetrics::for_font_size(13.0);
3309        let mut s = EditorState::new();
3310        s.set_text(&estructura().repeat(40));
3311        let vp = Viewport::medido(800.0, 400.0, metrics)
3312            .con_desplazamiento(Desplazamiento::en_fila(13));
3313        let cb: Arc<dyn Fn(usize) -> Option<usize> + Send + Sync> = Arc::new(Some);
3314        let vista = render_con(&s, &vp, 4, Some(cb));
3315        // La banda es el hijo absoluto que lleva el callback de click.
3316        let banda = vista
3317            .children
3318            .iter()
3319            .find(|c| {
3320                c.semantics.as_ref().and_then(|s| s.label.as_deref()) == Some("Contexto")
3321            })
3322            .expect("hay banda");
3323        let click = llimphi_compositor::raro!(banda, on_click_at).expect("se clickea");
3324        // Clickear la segunda fila de la banda lleva a su línea, no a la primera.
3325        let esperadas = s.lineas_pegajosas(13, 4);
3326        assert!(esperadas.len() >= 2, "{esperadas:?}");
3327        assert_eq!(click(0.0, metrics.line_height * 1.5, 800.0, 40.0), Some(esperadas[1]));
3328        // Y más abajo del último encabezado se clampea al último, no se va del
3329        // vector.
3330        assert_eq!(click(0.0, 9_999.0, 800.0, 40.0), esperadas.last().copied());
3331    }
3332
3333    #[test]
3334    fn la_banda_no_aparece_arriba_de_todo() {
3335        let metrics = EditorMetrics::for_font_size(13.0);
3336        let mut s = EditorState::new();
3337        s.set_text(&estructura().repeat(40));
3338        let vp = Viewport::medido(800.0, 400.0, metrics);
3339        // Sin scrollear, el renglón de arriba es la línea 0: no hay ancestros.
3340        let sin_scroll = nodos(&render_con::<()>(&s, &vp, 4, None));
3341        assert_eq!(sin_scroll, nodos(&render_con::<()>(&s, &vp, 0, None)));
3342    }
3343
3344    // ---------------------------------------------------------------
3345    // La franja de marcas del borde derecho.
3346    // ---------------------------------------------------------------
3347
3348    fn franja<'a, Msg: Clone + 'static>(v: &'a View<Msg>) -> Option<&'a View<Msg>> {
3349        v.children
3350            .iter()
3351            .find(|c| c.semantics.as_ref().and_then(|s| s.label.as_deref()) == Some("Marcas del archivo"))
3352    }
3353
3354    fn diag(linea: usize, sev: Severity) -> Diagnostic {
3355        Diagnostic {
3356            range: crate::diagnostics::DiagnosticRange {
3357                start: crate::cursor::Pos::new(linea, 0),
3358                end: crate::cursor::Pos::new(linea, 3),
3359            },
3360            severity: sev,
3361            message: alloc::string::String::from("x"),
3362            source: None,
3363        }
3364    }
3365
3366    #[test]
3367    fn sin_diagnostics_ni_matches_no_hay_franja() {
3368        // Para decir «estoy acá» ya está el pulgar de la barra: una capa más
3369        // que no marca nada es un nodo por cuadro a cambio de nada.
3370        let metrics = EditorMetrics::for_font_size(13.0);
3371        let vp = Viewport::medido(800.0, 400.0, metrics);
3372        assert!(franja(&render(&estado_de(1_000), &vp)).is_none());
3373    }
3374
3375    #[test]
3376    fn la_franja_marca_cada_diagnostico_y_el_caret() {
3377        let metrics = EditorMetrics::for_font_size(13.0);
3378        let vp = Viewport::medido(800.0, 400.0, metrics);
3379        let mut s = estado_de(1_000);
3380        // `set_text` deja el caret al final del buffer; para que la rayita del
3381        // caret tenga un lugar predecible, lo mandamos arriba.
3382        s.set_caret_at(0, 0);
3383        s.set_diagnostics(alloc::vec![
3384            diag(0, Severity::Error),
3385            diag(400, Severity::Warning),
3386            diag(900, Severity::Hint),
3387        ]);
3388        let vista = render(&s, &vp);
3389        let f = franja(&vista).expect("hay franja");
3390        // Tres marcas + la rayita del caret.
3391        assert_eq!(f.children.len(), 4);
3392        // Y caen donde corresponde: la fracción del archivo por el alto del
3393        // panel, cuantizada a la cubeta de 2 px. Mil una filas en 400 px: la
3394        // línea 400 cae en 158, la 900 en 358.
3395        assert_eq!(f.children[0].style.inset.top, length(0.0_f32));
3396        assert_eq!(f.children[1].style.inset.top, length(158.0_f32));
3397        assert_eq!(f.children[2].style.inset.top, length(358.0_f32));
3398        // Y la rayita del caret, que está en la línea 0.
3399        assert_eq!(f.children[3].style.inset.top, length(0.0_f32));
3400    }
3401
3402    #[test]
3403    fn la_franja_funde_las_marcas_que_caen_en_el_mismo_pixel() {
3404        // Sin esto, un archivo con miles de advertencias produce miles de nodos
3405        // por cuadro — exactamente el costo que la etapa 1 sacó del render. Una
3406        // franja no puede mostrar más marcas que píxeles tiene.
3407        let metrics = EditorMetrics::for_font_size(13.0);
3408        let vp = Viewport::medido(800.0, 400.0, metrics);
3409        let mut s = estado_de(5_000);
3410        let todos: Vec<Diagnostic> = (0..5_000).map(|l| diag(l, Severity::Warning)).collect();
3411        s.set_diagnostics(todos);
3412        let vista = render(&s, &vp);
3413        let f = franja(&vista).expect("hay franja");
3414        // 400 px de panel con marcas de 2 px: 200 cubetas como mucho, más el
3415        // caret. Cinco mil diagnósticos NO son cinco mil nodos.
3416        assert!(f.children.len() <= 201, "{} marcas", f.children.len());
3417        assert!(f.children.len() > 100, "se fundió de más: {}", f.children.len());
3418    }
3419
3420    #[test]
3421    fn en_el_mismo_pixel_gana_el_mas_grave() {
3422        // Un error no se puede perder detrás de una advertencia que cayó en el
3423        // mismo renglón de la franja.
3424        let metrics = EditorMetrics::for_font_size(13.0);
3425        let vp = Viewport::medido(800.0, 400.0, metrics);
3426        let paleta = EditorPalette::default();
3427        let mut s = estado_de(10_000);
3428        // Diez mil filas en 400 px son cincuenta líneas por cubeta: dos líneas
3429        // contiguas caen sí o sí en la misma.
3430        s.set_diagnostics(alloc::vec![diag(5_010, Severity::Warning), diag(5_011, Severity::Error)]);
3431        let vista = render(&s, &vp);
3432        let f = franja(&vista).expect("hay franja");
3433        assert_eq!(f.children.len(), 2, "no se fundieron"); // la marca + el caret
3434        assert_eq!(f.children[0].fill, Some(paleta.diag_error));
3435    }
3436
3437    #[test]
3438    fn la_barra_se_pinta_encima_de_la_franja_y_sigue_siendo_la_que_se_agarra() {
3439        // Comparten el canal derecho. Si la franja quedara arriba, se comería
3440        // el arrastre del pulgar.
3441        let metrics = EditorMetrics::for_font_size(13.0);
3442        let vp = Viewport::medido(800.0, 400.0, metrics);
3443        let mut s = estado_de(10_000);
3444        s.set_diagnostics(alloc::vec![diag(5_000, Severity::Error)]);
3445        let cb: Arc<dyn Fn(f32) -> Option<f32> + Send + Sync> = Arc::new(Some);
3446        let vista = text_editor_view_viewport(
3447            &s,
3448            &EditorPalette::default(),
3449            &vp,
3450            OpcionesRender { on_scroll: Some(cb), ..Default::default() },
3451            |_| None,
3452        );
3453        let i_franja = vista
3454            .children
3455            .iter()
3456            .position(|c| c.semantics.as_ref().and_then(|s| s.label.as_deref()) == Some("Marcas del archivo"))
3457            .expect("hay franja");
3458        // `rposition` y no `position`: el área de texto también se arrastra
3459        // (es la selección con el mouse), y es el primer hijo con `drag_at`.
3460        let i_barra = vista
3461            .children
3462            .iter()
3463            .rposition(|c| llimphi_compositor::raro!(c, drag_at).is_some())
3464            .expect("hay barra");
3465        assert!(i_franja < i_barra, "la franja tapa la barra");
3466    }
3467
3468    // ---------------------------------------------------------------
3469    // Las ayudas de lectura: guías de sangría y margen de columna.
3470    // ---------------------------------------------------------------
3471
3472    fn con_ayudas<Msg: Clone + 'static>(
3473        state: &EditorState,
3474        vp: &Viewport,
3475        guias: bool,
3476        margen: Option<usize>,
3477    ) -> View<Msg> {
3478        text_editor_view_viewport(
3479            state,
3480            &EditorPalette::default(),
3481            vp,
3482            OpcionesRender { guias_de_sangria: guias, margen_columna: margen, ..Default::default() },
3483            |_| None,
3484        )
3485    }
3486
3487    fn estado_sangrado() -> EditorState {
3488        let mut s = EditorState::new();
3489        // Sangrías de 0, 2, 4, 6 y 8 columnas, con un renglón vacío adentro.
3490        s.set_text("a\n  b\n    c\n\n      d\n        e\n");
3491        s
3492    }
3493
3494    #[test]
3495    fn sin_ayudas_el_arbol_es_exactamente_el_de_antes() {
3496        // El brazo de control: las dos opciones apagadas no agregan un nodo. Es
3497        // lo que deja tranquilos a los otros consumidores del widget, que no
3498        // las pasan.
3499        let metrics = EditorMetrics::for_font_size(13.0);
3500        let vp = Viewport::medido(800.0, 400.0, metrics);
3501        let s = estado_sangrado();
3502        assert_eq!(nodos(&con_ayudas::<()>(&s, &vp, false, None)), nodos(&render(&s, &vp)));
3503    }
3504
3505    #[test]
3506    fn hay_una_guia_por_nivel_de_adentro_y_ninguna_bajo_el_texto() {
3507        // Con sangría de 2 (el default), una línea a 4 columnas tiene UNA guía
3508        // —la de la columna 2—: la del nivel 4 caería justo debajo de su primer
3509        // carácter, que es una raya y no una guía.
3510        let metrics = EditorMetrics::for_font_size(13.0);
3511        let vp = Viewport::medido(800.0, 400.0, metrics);
3512        let s = estado_sangrado();
3513        let con = nodos(&con_ayudas::<()>(&s, &vp, true, None));
3514        let sin = nodos(&con_ayudas::<()>(&s, &vp, false, None));
3515        // Siete renglones (el último es el vacío del final del archivo), con
3516        // sangrías 0, 2, 4, (vacía→4), 6, 8 y (vacía→8): 0+0+1+1+2+3+3.
3517        assert_eq!(con - sin, 10, "guías dibujadas: {}", con - sin);
3518    }
3519
3520    #[test]
3521    fn una_linea_en_blanco_hereda_la_sangria_de_la_anterior() {
3522        // Sin esto, un renglón vacío en medio de una función corta todas las
3523        // guías — justo donde más se las mira.
3524        let mut s = EditorState::new();
3525        s.set_text("a\n      b\n\n      c\n");
3526        assert_eq!(sangria_efectiva(&s, 2), 6);
3527        // Y arriba de todo, sin nada atrás, no hereda nada.
3528        assert_eq!(sangria_efectiva(&s, 0), 0);
3529    }
3530
3531    #[test]
3532    fn el_margen_es_una_sola_raya_para_todo_el_panel() {
3533        let metrics = EditorMetrics::for_font_size(13.0);
3534        let vp = Viewport::medido(800.0, 400.0, metrics);
3535        let s = estado_de(1_000);
3536        let con = nodos(&con_ayudas::<()>(&s, &vp, false, Some(100)));
3537        let sin = nodos(&con_ayudas::<()>(&s, &vp, false, None));
3538        // Una raya, no una por renglón: el costo del margen no crece con el
3539        // alto del panel.
3540        assert_eq!(con - sin, 1);
3541    }
3542
3543    #[test]
3544    fn las_guias_no_crecen_con_el_archivo() {
3545        // Mismo contrato que todo el resto del render: el costo depende del
3546        // alto del panel, no del tamaño del archivo.
3547        let metrics = EditorMetrics::for_font_size(13.0);
3548        let vp = Viewport::medido(800.0, 400.0, metrics);
3549        let mut chico = EditorState::new();
3550        chico.set_text(&"        hondo();\n".repeat(100));
3551        let mut enorme = EditorState::new();
3552        enorme.set_text(&"        hondo();\n".repeat(50_000));
3553        assert_eq!(
3554            nodos(&con_ayudas::<()>(&chico, &vp, true, Some(100))),
3555            nodos(&con_ayudas::<()>(&enorme, &vp, true, Some(100)))
3556        );
3557    }
3558
3559}