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