tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 widgets/text-editor/src/view.rs Rust · 1311 líneas · 48.1 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! Render del editor. Layout: gutter izquierdo (line numbers) + área
2//! principal (texto + selección como rects + caret bloque). El scroll
3//! vertical es implícito por viewport — el caller decide cuántas líneas
4//! caben en el `height` que pasa.
5//!
6//! Limitaciones del PMV de render:
7//! - **Char width fijo** — asume fuente monoespaciada y un ancho de
8//!   carácter en píxeles fijo. Para CJK / proportional el caret y la
9//!   selección se desalinean. Para texto ASCII monoespaciado es exacto.
10//! - **Selección multilínea** se pinta como un rect por línea afectada
11//!   (sin "rio" continuo); estilo Sublime Text / antiguo, lectura clara.
12//! - **Sin syntax highlight todavía** — eso vive en su propio bloque y
13//!   requiere `llimphi-text` rich (Vec<Run>); aquí cada línea va
14//!   monocolor `fg_text`.
15
16use alloc::borrow::ToOwned;
17use alloc::string::{String, ToString};
18use alloc::vec;
19use alloc::vec::Vec;
20
21use llimphi_compositor::llimphi_layout::taffy::{
22    prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style},
23    AlignItems,
24};
25use llimphi_compositor::peniko::Color;
26use llimphi_compositor::llimphi_text::Alignment;
27use llimphi_compositor::View;
28
29use crate::cursor::Pos;
30use crate::diagnostics::{Diagnostic, Severity};
31use crate::highlight::{Language, Span, SyntaxPalette, TokenKind};
32use crate::state::EditorState;
33
34/// Tope de líneas que la variante embebida (`text_editor_view_colored`)
35/// renderiza de una. La virtualización del editor-de-archivos capa a 200 para
36/// no generar miles de Views (wgpu rechaza el bind group); pero la variante
37/// embebida deja el scroll al contenedor de afuera y necesita pintar TODAS sus
38/// líneas (si no, la mitad de abajo queda sin pintar = negro al anclar el panel
39/// al fondo). Este tope es sólo la red de seguridad de wgpu — el caller acota el
40/// total real (el shell, por su `MAX_VISIBLE = 400`). Probado: ~400 líneas
41/// renderizan sin que wgpu rechace nada (el render plano viejo ya lo hacía).
42pub const EMBEDDED_LINE_CAP: usize = 512;
43
44/// Paleta del editor. Defaults dark.
45#[derive(Debug, Clone, Copy)]
46pub struct EditorPalette {
47    pub bg: Color,
48    pub bg_gutter: Color,
49    pub bg_selection: Color,
50    pub bg_current_line: Color,
51    pub fg_text: Color,
52    pub fg_line_number: Color,
53    pub fg_line_number_active: Color,
54    pub caret: Color,
55    /// Fondo del bracket bajo el cursor + su par. Un acento sutil.
56    pub bg_bracket_pair: Color,
57    /// Fondo de cada match del find activo.
58    pub bg_match: Color,
59    /// Subrayado de diagnostic — Error.
60    pub diag_error: Color,
61    /// Subrayado de diagnostic — Warning.
62    pub diag_warning: Color,
63    /// Subrayado de diagnostic — Information.
64    pub diag_info: Color,
65    /// Subrayado de diagnostic — Hint.
66    pub diag_hint: Color,
67}
68
69impl Default for EditorPalette {
70    fn default() -> Self {
71        Self::from_theme(&llimphi_theme::Theme::dark())
72    }
73}
74
75impl EditorPalette {
76    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
77        // Reutilizamos slots del theme; los que no existen como semánticos
78        // se derivan con `mix`/transparencia conceptual.
79        Self {
80            bg: t.bg_input,
81            bg_gutter: t.bg_panel,
82            bg_selection: t.bg_selected,
83            bg_current_line: t.bg_panel_alt,
84            fg_text: t.fg_text,
85            fg_line_number: t.fg_muted,
86            fg_line_number_active: t.fg_text,
87            caret: t.accent,
88            bg_bracket_pair: t.bg_button_hover,
89            bg_match: t.bg_button_hover,
90            diag_error: t.fg_destructive,
91            diag_warning: Color::from_rgb8(229, 192, 123),
92            diag_info: Color::from_rgb8(97, 175, 239),
93            diag_hint: t.fg_muted,
94        }
95    }
96}
97
98/// Cómo renderizar la columna izquierda del editor.
99///
100/// - [`GutterStyle::Numbers`] es el comportamiento clásico de IDE:
101///   "1", "2", "3"… alineados a la derecha del gutter.
102/// - [`GutterStyle::Phantom`] suprime los números y dibuja en su lugar
103///   un tick **muy sutil** por línea (un pequeño segmento horizontal
104///   con baja opacidad). Sirve para prosa narrativa donde el número de
105///   línea es ruido — la línea sigue estando, pero "fingiendo no
106///   estar". El gutter en este modo se acorta a un sliver fino.
107#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
108pub enum GutterStyle {
109    #[default]
110    Numbers,
111    Phantom,
112    /// Sin gutter: ancho 0, nada dibujado. Para vistas tipo terminal donde
113    /// numerar líneas es ruido.
114    None,
115}
116
117/// Métricas del editor — todo derivado del `font_size`. Cambiar la
118/// fuente requiere recalcular `char_width` empíricamente para la mono
119/// que use llimphi-text; los valores aquí son razonables para
120/// `font_size = 12` con la mono default de parley.
121#[derive(Debug, Clone, Copy)]
122pub struct EditorMetrics {
123    pub font_size: f32,
124    /// Alto de cada línea en píxeles (font_size * line_height_ratio).
125    pub line_height: f32,
126    /// Ancho promedio de un char (mono). Si la fuente no es mono, esto
127    /// es sólo una aproximación.
128    pub char_width: f32,
129    /// Ancho del gutter (incluye padding interno).
130    pub gutter_width: f32,
131    /// Cómo se pinta el gutter. Default [`GutterStyle::Numbers`] — el
132    /// comportamiento clásico se conserva para callers existentes.
133    pub gutter_style: GutterStyle,
134    /// Si `true`, cada línea **guarda** (índices en
135    /// `EditorState::guard_lines`) recibe un segmento horizontal con
136    /// baja opacidad atravesando su centro — un divisor fantasma que
137    /// sugiere "aquí termina un bloque" sin gritar. Sin guardas, esto
138    /// no hace nada visible. Default `false`: comportamiento IDE
139    /// clásico.
140    pub phantom_guard_lines: bool,
141}
142
143impl Default for EditorMetrics {
144    fn default() -> Self {
145        Self::for_font_size(12.0)
146    }
147}
148
149impl EditorMetrics {
150    pub const fn for_font_size(font_size: f32) -> Self {
151        Self {
152            font_size,
153            line_height: font_size * 1.4,
154            char_width: font_size * 0.6,
155            gutter_width: font_size * 3.5,
156            gutter_style: GutterStyle::Numbers,
157            phantom_guard_lines: false,
158        }
159    }
160
161    /// Variante "prosa": gutter fantasma (ticks sutiles, sin números) +
162    /// divisores fantasma en cada guarda. Ancho del gutter reducido a
163    /// un sliver porque ya no necesita acomodar dígitos.
164    ///
165    /// Pensado para editores narrativos tipo `cuerpo_ide` donde el
166    /// número de línea es ruido y las junctions están marcadas como
167    /// guardas.
168    pub const fn prosa(font_size: f32) -> Self {
169        Self {
170            font_size,
171            line_height: font_size * 1.4,
172            char_width: font_size * 0.6,
173            gutter_width: font_size * 1.0,
174            gutter_style: GutterStyle::Phantom,
175            phantom_guard_lines: true,
176        }
177    }
178
179    /// Variante "terminal": sin gutter (ancho 0, sin números ni ticks) — para
180    /// pintar la salida de un PTY como texto plano donde numerar es ruido.
181    pub const fn terminal(font_size: f32) -> Self {
182        Self {
183            font_size,
184            line_height: font_size * 1.4,
185            char_width: font_size * 0.6,
186            gutter_width: 0.0,
187            gutter_style: GutterStyle::None,
188            phantom_guard_lines: false,
189        }
190    }
191
192    /// Convierte coords locales del **área de contenido** (no del gutter)
193    /// a `(line, col)` absolutas en el buffer. `local_x` se mide desde el
194    /// borde izquierdo del área de texto (sin el padding interno de 4 px);
195    /// `local_y` desde la primera línea visible.
196    ///
197    /// Devuelve coordenadas siempre dentro del buffer — el caller
198    /// generalmente las pasa a `EditorState::set_caret_at` que clampea
199    /// `col` al ancho real de la línea.
200    pub fn screen_to_pos(self, local_x: f32, local_y: f32, scroll_offset: usize) -> (usize, usize) {
201        let line_local = ((local_y - PAD_Y).max(0.0) / self.line_height) as usize;
202        let col = redondear(((local_x - PAD_X).max(0.0)) / self.char_width) as usize;
203        (scroll_offset + line_local, col)
204    }
205}
206
207/// Sangría izquierda del área de texto (px): aire entre el gutter y el
208/// primer carácter. El caret, la selección, el preedit y `screen_to_pos`
209/// usan la misma constante para no desalinearse.
210pub(crate) const PAD_X: f32 = 10.0;
211/// Aire vertical arriba de la primera línea (px). El gutter lo respeta para
212/// que los números queden a la par del texto.
213pub(crate) const PAD_Y: f32 = 7.0;
214/// Margen izquierdo del número de línea (px) — separa del borde del panel.
215const GUTTER_PAD_L: f32 = 6.0;
216/// Respiro entre el número de línea y el texto (px).
217const GUTTER_PAD_R: f32 = 8.0;
218
219/// X (px, dentro del área de contenido) del carácter en la columna `col`.
220#[inline]
221fn text_x(col: usize, m: EditorMetrics) -> f32 {
222    PAD_X + col as f32 * m.char_width
223}
224/// Y (px) del tope de la línea `local_line` (relativa al viewport).
225#[inline]
226fn text_y(local_line: usize, m: EditorMetrics) -> f32 {
227    PAD_Y + local_line as f32 * m.line_height
228}
229
230/// Render principal sin syntax highlight — todas las líneas visibles
231/// en `palette.fg_text`. `visible_lines` es cuántas líneas mostrar como
232/// máximo en el viewport.
233///
234/// `on_pointer` se invoca con el evento del mouse dentro del área de
235/// texto (no del gutter): el caller decide cómo mover el caret /
236/// extender selección. Ver [`PointerEvent`].
237pub fn text_editor_view<Msg: Clone + 'static>(
238    state: &EditorState,
239    palette: &EditorPalette,
240    metrics: EditorMetrics,
241    visible_lines: usize,
242    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
243) -> View<Msg> {
244    text_editor_view_highlighted(
245        state,
246        palette,
247        metrics,
248        visible_lines,
249        Language::Plain,
250        on_pointer,
251    )
252}
253
254/// Evento de mouse que el view envía al caller dentro del área de texto.
255/// El caller convierte `(x, y)` con [`EditorMetrics::screen_to_pos`] y
256/// aplica `set_caret_at` (Click) o `extend_selection_to` (Drag).
257///
258/// `Drag` entrega `initial` (pos del press inicial, constante durante el
259/// drag) + `delta` (delta desde el evento anterior). El caller debe
260/// acumular el delta — el view no mantiene state. Patrón típico:
261/// `accum += (dx, dy); actual = (initial_x + accum.0, initial_y + accum.1)`.
262#[derive(Debug, Clone, Copy)]
263pub enum PointerEvent {
264    Click { x: f32, y: f32 },
265    Drag { initial_x: f32, initial_y: f32, dx: f32, dy: f32 },
266}
267
268/// Override de estilo de texto sobre un rango de **columnas-char**
269/// `[start_col, end_col)` de una línea. A diferencia de [`Span`] (que sólo
270/// lleva una `TokenKind` → color), un `StyledSpan` lleva el set completo de
271/// propiedades rich-text que el caller quiera pisar: color de glifo, color de
272/// fondo (resaltado), familia, tamaño, peso, itálica, subrayado y tachado.
273/// Cada campo es opcional — `None` hereda del default del editor.
274///
275/// El widget traduce internamente las columnas-char a offsets de byte (que es
276/// lo que consume `text_spans`/parley) y pinta el `bg` como rect por debajo
277/// del texto (parley no lleva fondo por span). Pensado para editores de prosa
278/// que estilan zonas o la selección (pluma multilienzo); no se combina con el
279/// syntax highlight por `Language` — es una vía de pintado paralela.
280#[derive(Debug, Clone, Default, PartialEq)]
281pub struct StyledSpan {
282    pub start_col: usize,
283    pub end_col: usize,
284    pub fg: Option<Color>,
285    pub bg: Option<Color>,
286    pub font_family: Option<String>,
287    pub size_px: Option<f32>,
288    pub weight: Option<f32>,
289    pub italic: Option<bool>,
290    pub underline: Option<bool>,
291    pub strikethrough: Option<bool>,
292}
293
294/// Como [`text_editor_view_highlighted`] pero el caller provee, por línea, un
295/// conjunto de [`StyledSpan`] con estilo rich-text completo (color/fondo/
296/// fuente/tamaño/peso/itálica/subrayado/tachado). `styled_per_line[n]` son los
297/// spans de la línea `n` del buffer (índice absoluto). Las líneas sin spans se
298/// pintan planas en `palette.fg_text`. Va por encima del syntax highlight: si
299/// pasas spans, mandan ellos (el caso de pluma, que es prosa sin lenguaje).
300pub fn text_editor_view_styled<Msg: Clone + 'static>(
301    state: &EditorState,
302    palette: &EditorPalette,
303    metrics: EditorMetrics,
304    visible_lines: usize,
305    styled_per_line: &[Vec<StyledSpan>],
306    match_ranges: &[(usize, usize)],
307    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
308) -> View<Msg> {
309    let caret = state.cursor.caret;
310    let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark());
311
312    let visible = visible_lines.max(1).min(200);
313    let line_count = state.line_count();
314    let scroll = state.scroll_offset.min(line_count.saturating_sub(1));
315    let end_line = (scroll + visible).min(line_count);
316    let height = (end_line - scroll) as f32 * metrics.line_height;
317
318    let gutter = build_gutter(state, scroll, end_line, caret.line, metrics, palette);
319    let content = build_content(
320        state,
321        palette,
322        metrics,
323        height,
324        scroll,
325        end_line,
326        Vec::new(),
327        &syntax,
328        match_ranges,
329        None,
330        Some(styled_per_line),
331        on_pointer,
332    );
333
334    View::new(Style {
335        flex_direction: FlexDirection::Row,
336        flex_grow: 1.0,
337        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
338        min_size: Size { width: auto(), height: length(height) },
339        ..Default::default()
340    })
341    .fill(palette.bg)
342    .clip(true)
343    // Editor de texto multilínea: los lectores cambian de modo de navegación
344    // con este rol (por línea/palabra) en vez de leerlo como un bloque. No
345    // mandamos el contenido en `value` a propósito: puede ser un archivo
346    // entero, y el buffer se navega por el árbol, no por el nombre.
347    .role(llimphi_compositor::Role::MultilineTextInput)
348    .aria_label("Editor de texto")
349    .children(vec![gutter, content])
350}
351
352/// Render con syntax highlight + **viewport scrolling**: sólo se renderizan
353/// las líneas en `[state.scroll_offset, scroll_offset + visible_lines)`.
354///
355/// `visible_lines` es cuántas líneas máximo dibujamos por frame; el caller
356/// se asegura de tener un container con altura ≥ `visible_lines * line_height`
357/// o aplica clip propio. Para archivos grandes (1000+ líneas), el cap es
358/// crítico — sin él generaríamos miles de Views y wgpu rechazaría el bind
359/// group por `max_*_buffer_binding_size`.
360///
361/// Recomendación para el caller: tras cada edición, llamar a
362/// [`EditorState::ensure_caret_visible`] con el mismo `visible_lines` para
363/// que el viewport siga al caret.
364pub fn text_editor_view_highlighted<Msg: Clone + 'static>(
365    state: &EditorState,
366    palette: &EditorPalette,
367    metrics: EditorMetrics,
368    visible_lines: usize,
369    language: Language,
370    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
371) -> View<Msg> {
372    text_editor_view_full(
373        state,
374        palette,
375        metrics,
376        visible_lines,
377        language,
378        &[],
379        on_pointer,
380    )
381}
382
383/// Como [`text_editor_view_highlighted`] + `match_ranges` para pintar
384/// las ocurrencias de un find activo. Cada par `(char_start, char_end)`
385/// es un rango de chars globales del buffer.
386pub fn text_editor_view_full<Msg: Clone + 'static>(
387    state: &EditorState,
388    palette: &EditorPalette,
389    metrics: EditorMetrics,
390    visible_lines: usize,
391    language: Language,
392    match_ranges: &[(usize, usize)],
393    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
394) -> View<Msg> {
395    let caret = state.cursor.caret;
396    let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark());
397
398    let visible = visible_lines.max(1).min(200);
399    let line_count = state.line_count();
400    let scroll = state.scroll_offset.min(line_count.saturating_sub(1));
401    let end_line = (scroll + visible).min(line_count);
402    let height = (end_line - scroll) as f32 * metrics.line_height;
403
404    // Memoizado por `edit_seq` — sólo reparseamos cuando el buffer
405    // realmente cambió o cambia el `Language`.
406    let spans = state.highlighted_spans(language);
407
408    let gutter = build_gutter(state, scroll, end_line, caret.line, metrics, palette);
409    let content = build_content(
410        state,
411        palette,
412        metrics,
413        height,
414        scroll,
415        end_line,
416        spans,
417        &syntax,
418        match_ranges,
419        None,
420        None,
421        on_pointer,
422    );
423
424    // Llena el alto disponible (para verse como UN campo continuo: se puede
425    // clickear/tipear debajo de la última línea), pero nunca colapsa por debajo
426    // del alto del contenido — `min_size` es el piso si el contenedor es
427    // chico/indefinido. El caret y los clicks debajo del texto aterrizan en la
428    // última línea (los clampea `set_caret_at`).
429    View::new(Style {
430        flex_direction: FlexDirection::Row,
431        flex_grow: 1.0,
432        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
433        min_size: Size { width: auto(), height: length(height) },
434        ..Default::default()
435    })
436    .fill(palette.bg)
437    .clip(true)
438    // Editor de texto multilínea: los lectores cambian de modo de navegación
439    // con este rol (por línea/palabra) en vez de leerlo como un bloque. No
440    // mandamos el contenido en `value` a propósito: puede ser un archivo
441    // entero, y el buffer se navega por el árbol, no por el nombre.
442    .role(llimphi_compositor::Role::MultilineTextInput)
443    .aria_label("Editor de texto")
444    .children(vec![gutter, content])
445}
446
447/// Como [`text_editor_view`] pero el caller provee el color de cada tramo de
448/// cada línea (`line_color_runs[n]` = `(byte_start, byte_end, Color)` de la
449/// línea `n`), en vez de derivarlo de un `Language`. Para outputs con coloreo
450/// semántico propio (un shell que tinta `ls`, paths, urls, números…) sobre el
451/// mismo editor read-only (numeración + selección + copiar).
452pub fn text_editor_view_colored<Msg: Clone + 'static>(
453    state: &EditorState,
454    palette: &EditorPalette,
455    metrics: EditorMetrics,
456    visible_lines: usize,
457    line_color_runs: &[Vec<(usize, usize, Color)>],
458    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
459) -> View<Msg> {
460    let caret = state.cursor.caret;
461    let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark());
462    // Variante embebida: el contenedor de afuera (el panel de output del shell)
463    // hace el scroll y reserva alto para TODAS las líneas, así que las pintamos
464    // completas (cap alto = red de seguridad de wgpu, ver `EMBEDDED_LINE_CAP`).
465    let visible = visible_lines.max(1).min(EMBEDDED_LINE_CAP);
466    let line_count = state.line_count();
467    let scroll = state.scroll_offset.min(line_count.saturating_sub(1));
468    let end_line = (scroll + visible).min(line_count);
469    let height = (end_line - scroll) as f32 * metrics.line_height;
470    let gutter = build_gutter(state, scroll, end_line, caret.line, metrics, palette);
471    let content = build_content(
472        state,
473        palette,
474        metrics,
475        height,
476        scroll,
477        end_line,
478        Vec::new(),
479        &syntax,
480        &[],
481        Some(line_color_runs),
482        None,
483        on_pointer,
484    );
485    View::new(Style {
486        flex_direction: FlexDirection::Row,
487        size: Size { width: percent(1.0_f32), height: length(height) },
488        ..Default::default()
489    })
490    .fill(palette.bg)
491    .clip(true)
492    // Editor de texto multilínea: los lectores cambian de modo de navegación
493    // con este rol (por línea/palabra) en vez de leerlo como un bloque. No
494    // mandamos el contenido en `value` a propósito: puede ser un archivo
495    // entero, y el buffer se navega por el árbol, no por el nombre.
496    .role(llimphi_compositor::Role::MultilineTextInput)
497    .aria_label("Editor de texto")
498    .children(vec![gutter, content])
499}
500
501fn build_gutter<Msg: Clone + 'static>(
502    state: &EditorState,
503    scroll: usize,
504    end_line: usize,
505    active_line: usize,
506    metrics: EditorMetrics,
507    palette: &EditorPalette,
508) -> View<Msg> {
509    let count = end_line.saturating_sub(scroll);
510    let mut children: Vec<View<Msg>> = Vec::with_capacity(count);
511    for n in scroll..end_line {
512        // Las líneas-guarda son separadores estructurales entre zonas
513        // de texto: ni se numeran ni se pueden escribir. El espacio
514        // se preserva (la línea sigue existiendo), pero el gutter las
515        // saltea — visualmente la numeración "rompe" en cada zona.
516        // Si `guard_lines` está vacío, este check es siempre `false`
517        // y la numeración cubre todas las líneas (modo IDE clásico).
518        if state.is_guard_line(n) {
519            continue;
520        }
521        let color = if n == active_line {
522            palette.fg_line_number_active
523        } else {
524            palette.fg_line_number
525        };
526        let y = text_y(n - scroll, metrics);
527        match metrics.gutter_style {
528            GutterStyle::Numbers => {
529                let label = (n + 1).to_string();
530                // Número alineado a la derecha con un respiro de `GUTTER_PAD_R`
531                // hasta el texto (antes 4 px → se pegaba y el clip lo cortaba) y
532                // un margen izquierdo para que no toque el borde de la pantalla.
533                children.push(
534                    View::new(Style {
535                        position: Position::Absolute,
536                        inset: Rect {
537                            left: length(GUTTER_PAD_L),
538                            top: length(y),
539                            right: length(GUTTER_PAD_R),
540                            bottom: auto(),
541                        },
542                        size: Size {
543                            width: length(
544                                (metrics.gutter_width - GUTTER_PAD_L - GUTTER_PAD_R).max(1.0),
545                            ),
546                            height: length(metrics.line_height),
547                        },
548                        align_items: Some(AlignItems::CENTER),
549                        ..Default::default()
550                    })
551                    .text_aligned(label, metrics.font_size * 0.85, color, Alignment::End)
552                    .mono(),
553                );
554            }
555            GutterStyle::None => {}
556            GutterStyle::Phantom => {
557                // Tick fantasma — un segmento horizontal corto centrado
558                // verticalmente en la línea, con la opacidad bajada.
559                // La línea activa queda un pelín más visible.
560                let alpha = if n == active_line { 0.35 } else { 0.12 };
561                let tick_w = (metrics.gutter_width * 0.5).max(3.0);
562                let tick_h = 1.0_f32;
563                let tick_y = y + (metrics.line_height - tick_h) * 0.5;
564                let tick_x = (metrics.gutter_width - tick_w) * 0.5;
565                children.push(
566                    View::new(Style {
567                        position: Position::Absolute,
568                        inset: Rect {
569                            left: length(tick_x),
570                            top: length(tick_y),
571                            right: auto(),
572                            bottom: auto(),
573                        },
574                        size: Size {
575                            width: length(tick_w),
576                            height: length(tick_h),
577                        },
578                        ..Default::default()
579                    })
580                    .fill(with_alpha(color, alpha)),
581                );
582            }
583        }
584    }
585
586    // En modo Phantom el gutter es un sliver: no aplicamos `fill` —
587    // se mezcla con el fondo del editor. El gutter "está sin estar".
588    let bg = match metrics.gutter_style {
589        GutterStyle::Numbers => palette.bg_gutter,
590        GutterStyle::Phantom | GutterStyle::None => palette.bg,
591    };
592    View::new(Style {
593        size: Size {
594            width: length(metrics.gutter_width),
595            height: percent(1.0_f32),
596        },
597        ..Default::default()
598    })
599    .fill(bg)
600    .clip(true)
601    .children(children)
602}
603
604/// Devuelve `c` con la opacidad multiplicada por `alpha` (clamp 0..1).
605fn with_alpha(c: Color, alpha: f32) -> Color {
606    let rgba = c.to_rgba8();
607    let a = ((alpha.clamp(0.0, 1.0)) * (rgba.a as f32)) as u8;
608    Color::from_rgba8(rgba.r, rgba.g, rgba.b, a)
609}
610
611#[allow(clippy::too_many_arguments)]
612fn build_content<Msg: Clone + 'static>(
613    state: &EditorState,
614    palette: &EditorPalette,
615    metrics: EditorMetrics,
616    height: f32,
617    scroll: usize,
618    end_line: usize,
619    spans_per_line: Vec<Vec<Span>>,
620    syntax: &SyntaxPalette,
621    match_ranges: &[(usize, usize)],
622    // Override de color por línea: `color_runs[n]` son `(byte_start, byte_end,
623    // Color)` para la línea `n` del buffer (índice absoluto). Cuando es `Some`,
624    // gana sobre el syntax highlight — para callers que colorean por semántica
625    // propia (un shell que tinta `ls`, paths, urls…). `None` = highlight normal.
626    color_runs: Option<&[Vec<(usize, usize, Color)>]>,
627    // Spans rich-text por línea (color/fondo/fuente/tamaño/peso/itálica/…).
628    // Cuando es `Some`, ganan sobre el syntax highlight — la vía de pluma para
629    // estilar zonas y selección de prosa. `None` = comportamiento previo.
630    styled_per_line: Option<&[Vec<StyledSpan>]>,
631    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
632) -> View<Msg> {
633    let caret = state.cursor.caret;
634    let mut children: Vec<View<Msg>> = Vec::new();
635
636    // 0) Tintes por línea — la capa más baja, debajo de todo el resto.
637    //    Pinta un rect del ancho completo del área de contenido por
638    //    cada línea con tinte asignado. El caller elige el alpha — el
639    //    widget no lo modula. Si la línea cae fuera de viewport o no
640    //    tiene tinte, no se pinta nada.
641    for n in scroll..end_line {
642        if let Some(Some(c)) = state.line_tints.get(n) {
643            children.push(line_tint(n - scroll, *c, metrics));
644        }
645    }
646
647    // 1) Fondo del renglón activo — sólo el del primary cursor.
648    if caret.line >= scroll && caret.line < end_line {
649        children.push(line_highlight(caret.line - scroll, metrics, palette));
650    }
651
652    // 1b) Highlight de matches del find.
653    for (s, e) in match_ranges {
654        children.extend(match_rects(state, *s, *e, scroll, end_line, metrics, palette));
655    }
656
657    // 1c) Fondos de los spans estilados (resaltado). Por debajo de la
658    //     selección y del texto, igual que los tintes — un rect por span
659    //     con `bg`. Char-cols → x/w vía `char_width` (mono).
660    if let Some(styled) = styled_per_line {
661        for n in scroll..end_line {
662            if let Some(line_spans) = styled.get(n) {
663                children.extend(styled_bg_rects(n - scroll, line_spans, metrics));
664            }
665        }
666    }
667
668    // 2) Selección — por cada cursor que tenga selección.
669    for c in state.all_cursors() {
670        if c.has_selection() {
671            children.extend(selection_rects_for_cursor(
672                state, c, scroll, end_line, metrics, palette,
673            ));
674        }
675    }
676
677    // 2b) Bracket pair bajo el primary cursor — si visible.
678    if let Some((a, b)) = crate::bracket::find_bracket_pair(&state.buffer, &state.cursor) {
679        if a.line >= scroll && a.line < end_line {
680            children.push(bracket_highlight(crate::cursor::Pos::new(a.line - scroll, a.col), metrics, palette));
681        }
682        if b.line >= scroll && b.line < end_line {
683            children.push(bracket_highlight(crate::cursor::Pos::new(b.line - scroll, b.col), metrics, palette));
684        }
685    }
686
687    // 3) Texto — sólo las líneas en viewport.
688    //    Si `phantom_guard_lines` está activo, cada guarda recibe un
689    //    divisor fantasma (segmento horizontal con baja opacidad)
690    //    atravesando su centro — sin texto, sólo un susurro visual.
691    for n in scroll..end_line {
692        let text = state.buffer.line(n);
693        let text = text.trim_end_matches('\n').to_owned();
694        let local_line = n - scroll;
695        if metrics.phantom_guard_lines && state.is_guard_line(n) {
696            children.push(phantom_guard_divider(local_line, metrics, palette));
697            continue;
698        }
699        if let Some(line_spans) = styled_per_line.and_then(|s| s.get(n)).filter(|s| !s.is_empty()) {
700            children.push(line_text_styled(local_line, &text, line_spans, metrics, palette));
701        } else if let Some(runs) = color_runs.and_then(|cr| cr.get(n)) {
702            children.push(line_text_color_runs(local_line, &text, runs, metrics, palette));
703        } else if let Some(line_spans) = spans_per_line.get(n) {
704            children.push(line_text_tokens(local_line, &text, line_spans, metrics, palette, syntax));
705        } else {
706            children.push(line_text_plain(local_line, text, metrics, palette));
707        }
708    }
709
710    // 3b) Diagnostics — subrayado bajo el rango, color por severity.
711    for d in &state.diagnostics {
712        children.extend(diagnostic_underline(d, scroll, end_line, metrics, palette));
713    }
714
715    // 4) Caret — uno por cursor, sólo si visible. El caret del cursor
716    //    primario se corre detrás del preedit del IME en composición (el
717    //    texto compuesto se pinta desde `p.col`), para que quede al final
718    //    de lo que el usuario está tecleando. En la fase "apagada" del
719    //    parpadeo (`caret_on == false`) no se dibuja ningún caret.
720    let preedit_cols = state.preedit.as_ref().map_or(0, |p| p.text.chars().count());
721    for c in state.all_cursors() {
722        let p = c.caret;
723        if state.caret_on && p.line >= scroll && p.line < end_line {
724            let is_primary = core::ptr::eq(c, &state.cursor);
725            let col = if is_primary { p.col + preedit_cols } else { p.col };
726            let local = crate::cursor::Pos::new(p.line - scroll, col);
727            children.push(caret_rect(local, metrics, palette));
728        }
729    }
730
731    // 5) Preedit del IME — texto en composición pintado en el caret con
732    //    subrayado, todavía fuera del buffer. Sólo en el cursor primario y
733    //    si su línea está en viewport. (En mono el ancho es exacto; el
734    //    texto que sigue al caret puede solaparse mientras se compone —
735    //    transitorio y, en el caso típico de acentos, de un solo char.)
736    if let Some(pre) = state.preedit.as_ref() {
737        let p = state.cursor.caret;
738        if p.line >= scroll && p.line < end_line {
739            children.extend(preedit_views(p.line - scroll, p.col, &pre.text, metrics, palette));
740        }
741    }
742
743    let click_cb = on_pointer.clone();
744    let drag_cb = on_pointer;
745    View::new(Style {
746        flex_grow: 1.0,
747        // Llena el alto del editor (campo continuo), con piso en el alto del
748        // contenido para el modo embebido que scrollea (`_colored`).
749        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
750        min_size: Size { width: auto(), height: length(height) },
751        ..Default::default()
752    })
753    .fill(palette.bg)
754    .clip(true)
755    // Cursor de texto (I-beam) sobre el área editable; fuera vuelve al normal.
756    .cursor(llimphi_compositor::Cursor::Text)
757    .on_click_at(move |x, y, _w, _h| click_cb(PointerEvent::Click { x, y }))
758    .draggable_at(move |phase, dx, dy, lx, ly| match phase {
759        llimphi_compositor::DragPhase::Move => drag_cb(PointerEvent::Drag {
760            initial_x: lx,
761            initial_y: ly,
762            dx,
763            dy,
764        }),
765        llimphi_compositor::DragPhase::End => None,
766    })
767    .children(children)
768}
769
770/// Rect de tinte para una línea. Cubre el ancho completo y el alto
771/// exacto de la línea, pintado al color literal pasado (el caller
772/// elige el alpha). Posición absoluta dentro del área de contenido.
773fn line_tint<Msg: Clone + 'static>(
774    line: usize,
775    color: Color,
776    metrics: EditorMetrics,
777) -> View<Msg> {
778    View::new(Style {
779        position: Position::Absolute,
780        inset: Rect {
781            left: length(0.0_f32),
782            top: length(text_y(line, metrics)),
783            right: length(0.0_f32),
784            bottom: auto(),
785        },
786        size: Size {
787            width: percent(1.0_f32),
788            height: length(metrics.line_height),
789        },
790        ..Default::default()
791    })
792    .fill(color)
793}
794
795fn line_highlight<Msg: Clone + 'static>(
796    line: usize,
797    metrics: EditorMetrics,
798    palette: &EditorPalette,
799) -> View<Msg> {
800    View::new(Style {
801        position: Position::Absolute,
802        inset: Rect {
803            left: length(0.0_f32),
804            top: length(text_y(line, metrics)),
805            right: length(0.0_f32),
806            bottom: auto(),
807        },
808        size: Size {
809            width: percent(1.0_f32),
810            height: length(metrics.line_height),
811        },
812        ..Default::default()
813    })
814    .fill(palette.bg_current_line)
815}
816
817/// Línea-fantasma para una guarda: un segmento horizontal con baja
818/// opacidad atravesando el centro vertical de la línea. Ancho
819/// limitado para que parezca un susurro y no una regla. Color derivado
820/// de `fg_line_number` que ya está pensado como "muted".
821fn phantom_guard_divider<Msg: Clone + 'static>(
822    line: usize,
823    metrics: EditorMetrics,
824    palette: &EditorPalette,
825) -> View<Msg> {
826    let h = 1.0_f32;
827    let y = text_y(line, metrics) + (metrics.line_height - h) * 0.5;
828    // Largo visual del divisor — generoso pero no infinito.
829    let w = 320.0_f32;
830    View::new(Style {
831        position: Position::Absolute,
832        inset: Rect {
833            left: length(8.0_f32),
834            top: length(y),
835            right: auto(),
836            bottom: auto(),
837        },
838        size: Size {
839            width: length(w),
840            height: length(h),
841        },
842        ..Default::default()
843    })
844    .fill(with_alpha(palette.fg_line_number, 0.18))
845}
846
847fn line_text_plain<Msg: Clone + 'static>(
848    line: usize,
849    text: String,
850    metrics: EditorMetrics,
851    palette: &EditorPalette,
852) -> View<Msg> {
853    View::new(Style {
854        position: Position::Absolute,
855        inset: Rect {
856            left: length(PAD_X),
857            top: length(text_y(line, metrics)),
858            right: auto(),
859            bottom: auto(),
860        },
861        size: Size {
862            width: length(2000.0_f32),
863            height: length(metrics.line_height),
864        },
865        align_items: Some(AlignItems::CENTER),
866        ..Default::default()
867    })
868    .text_aligned(text, metrics.font_size, palette.fg_text, Alignment::Start)
869    .mono()
870}
871
872/// Renderiza una línea como secuencia de Views absolutos posicionados,
873/// cada uno con el color de su span. El posicionamiento horizontal usa
874/// `char_width` (mono); para fuentes proporcionales habría que medir
875/// cada token con parley (TODO).
876fn line_text_tokens<Msg: Clone + 'static>(
877    line: usize,
878    text: &str,
879    spans: &[Span],
880    metrics: EditorMetrics,
881    palette: &EditorPalette,
882    syntax: &SyntaxPalette,
883) -> View<Msg> {
884    // char-col → byte-offset: parley rangea por bytes, los spans por chars.
885    let mut byte_at: Vec<usize> = Vec::with_capacity(text.len() + 1);
886    let mut acc = 0usize;
887    byte_at.push(0);
888    for ch in text.chars() {
889        acc += ch.len_utf8();
890        byte_at.push(acc);
891    }
892    let nchars = byte_at.len() - 1;
893
894    // Un run de color por span no-Other (el default_color cubre el resto).
895    let mut runs: Vec<(usize, usize, Color)> = Vec::with_capacity(spans.len());
896    for span in spans {
897        if span.start_col >= nchars || matches!(span.kind, TokenKind::Other) {
898            continue;
899        }
900        let end = span.end_col.min(nchars);
901        if end <= span.start_col {
902            continue;
903        }
904        runs.push((byte_at[span.start_col], byte_at[end], syntax.color(span.kind)));
905    }
906
907    // Una sola línea shapeada de una vez, multicolor, en lugar de un nodo
908    // (+ layout parley) por token. El `+4` de gutter va en el inset del nodo.
909    View::new(Style {
910        position: Position::Absolute,
911        inset: Rect {
912            left: length(PAD_X),
913            top: length(text_y(line, metrics)),
914            right: auto(),
915            bottom: auto(),
916        },
917        size: Size { width: length(2000.0_f32), height: length(metrics.line_height) },
918        ..Default::default()
919    })
920    .text_runs(
921        text.to_string(),
922        metrics.font_size,
923        palette.fg_text,
924        runs,
925        Alignment::Start,
926    )
927    .mono()
928}
929
930/// Como [`line_text_tokens`] pero con `(byte_start, byte_end, Color)`
931/// explícitos provistos por el caller (coloreo semántico propio, p. ej. un
932/// shell que tinta `ls`/paths/urls). El resto del texto va en `fg_text`.
933fn line_text_color_runs<Msg: Clone + 'static>(
934    line: usize,
935    text: &str,
936    runs: &[(usize, usize, Color)],
937    metrics: EditorMetrics,
938    palette: &EditorPalette,
939) -> View<Msg> {
940    View::new(Style {
941        position: Position::Absolute,
942        inset: Rect {
943            left: length(PAD_X),
944            top: length(text_y(line, metrics)),
945            right: auto(),
946            bottom: auto(),
947        },
948        size: Size { width: length(2000.0_f32), height: length(metrics.line_height) },
949        ..Default::default()
950    })
951    .text_runs(
952        text.to_string(),
953        metrics.font_size,
954        palette.fg_text,
955        runs.to_vec(),
956        Alignment::Start,
957    )
958    .mono()
959}
960
961/// Renderiza una línea con spans rich-text ([`StyledSpan`]): construye los
962/// [`llimphi_text::TextSpan`] (offsets en byte, vía el mapa char→byte) y los
963/// pasa a `text_spans`. El fondo de cada span lo pinta [`styled_bg_rects`]
964/// aparte. El texto fuera de todo span va en `palette.fg_text` /
965/// `metrics.font_size`. Mantiene `.mono()` para que el posicionamiento por
966/// `char_width` (caret/selección) siga cuadrando.
967fn line_text_styled<Msg: Clone + 'static>(
968    line: usize,
969    text: &str,
970    spans: &[StyledSpan],
971    metrics: EditorMetrics,
972    palette: &EditorPalette,
973) -> View<Msg> {
974    // char-col → byte-offset (parley rangea por bytes; los spans, por chars).
975    let mut byte_at: Vec<usize> = Vec::with_capacity(text.len() + 1);
976    let mut acc = 0usize;
977    byte_at.push(0);
978    for ch in text.chars() {
979        acc += ch.len_utf8();
980        byte_at.push(acc);
981    }
982    let nchars = byte_at.len() - 1;
983
984    let mut ts: Vec<llimphi_compositor::llimphi_text::TextSpan> = Vec::with_capacity(spans.len());
985    for s in spans {
986        if s.start_col >= nchars {
987            continue;
988        }
989        let end = s.end_col.min(nchars);
990        if end <= s.start_col {
991            continue;
992        }
993        let style = llimphi_compositor::llimphi_text::TextSpanStyle {
994            size_px: s.size_px,
995            weight: s.weight,
996            italic: s.italic,
997            font_family: s.font_family.clone(),
998            color: s.fg,
999            underline: s.underline,
1000            strikethrough: s.strikethrough,
1001        };
1002        ts.push(llimphi_compositor::llimphi_text::TextSpan::new(
1003            byte_at[s.start_col],
1004            byte_at[end],
1005            style,
1006        ));
1007    }
1008
1009    View::new(Style {
1010        position: Position::Absolute,
1011        inset: Rect {
1012            left: length(PAD_X),
1013            top: length(text_y(line, metrics)),
1014            right: auto(),
1015            bottom: auto(),
1016        },
1017        size: Size { width: length(2000.0_f32), height: length(metrics.line_height) },
1018        ..Default::default()
1019    })
1020    .text_spans(
1021        text.to_string(),
1022        metrics.font_size,
1023        palette.fg_text,
1024        ts,
1025        Alignment::Start,
1026    )
1027    .mono()
1028}
1029
1030/// Rects de fondo (resaltado) de los spans de una línea con `bg` definido.
1031/// Char-cols → x/w por `char_width` (mono), mismo origen que el texto.
1032fn styled_bg_rects<Msg: Clone + 'static>(
1033    local_line: usize,
1034    spans: &[StyledSpan],
1035    metrics: EditorMetrics,
1036) -> Vec<View<Msg>> {
1037    let mut out: Vec<View<Msg>> = Vec::new();
1038    for s in spans {
1039        let Some(bg) = s.bg else { continue };
1040        if s.end_col <= s.start_col {
1041            continue;
1042        }
1043        let x = text_x(s.start_col, metrics);
1044        let w = (s.end_col - s.start_col) as f32 * metrics.char_width;
1045        out.push(
1046            View::new(Style {
1047                position: Position::Absolute,
1048                inset: Rect {
1049                    left: length(x),
1050                    top: length(text_y(local_line, metrics)),
1051                    right: auto(),
1052                    bottom: auto(),
1053                },
1054                size: Size { width: length(w), height: length(metrics.line_height) },
1055                ..Default::default()
1056            })
1057            .fill(bg),
1058        );
1059    }
1060    out
1061}
1062
1063fn caret_rect<Msg: Clone + 'static>(
1064    caret: Pos,
1065    metrics: EditorMetrics,
1066    palette: &EditorPalette,
1067) -> View<Msg> {
1068    let x = text_x(caret.col, metrics);
1069    let y = text_y(caret.line, metrics);
1070    View::new(Style {
1071        position: Position::Absolute,
1072        inset: Rect {
1073            left: length(x),
1074            top: length(y + 2.0),
1075            right: auto(),
1076            bottom: auto(),
1077        },
1078        size: Size { width: length(2.0_f32), height: length(metrics.line_height - 4.0) },
1079        ..Default::default()
1080    })
1081    .fill(palette.caret)
1082}
1083
1084/// Pinta el texto en composición del IME en `(local_line, col)`: el texto
1085/// provisional + un subrayado debajo que lo marca como no-confirmado.
1086/// Devuelve los dos Views (texto y subrayado). Posición en coords del
1087/// área de contenido (mismo origen que [`line_text_plain`]).
1088fn preedit_views<Msg: Clone + 'static>(
1089    local_line: usize,
1090    col: usize,
1091    text: &str,
1092    metrics: EditorMetrics,
1093    palette: &EditorPalette,
1094) -> Vec<View<Msg>> {
1095    let x = text_x(col, metrics);
1096    let y = text_y(local_line, metrics);
1097    let w = (text.chars().count() as f32 * metrics.char_width).max(metrics.char_width);
1098    vec![
1099        // Texto provisional, en el color de texto normal.
1100        View::new(Style {
1101            position: Position::Absolute,
1102            inset: Rect {
1103                left: length(x),
1104                top: length(y),
1105                right: auto(),
1106                bottom: auto(),
1107            },
1108            size: Size { width: length(w), height: length(metrics.line_height) },
1109            align_items: Some(AlignItems::CENTER),
1110            ..Default::default()
1111        })
1112        .text_aligned(text.to_string(), metrics.font_size, palette.fg_text, Alignment::Start)
1113        .mono(),
1114        // Subrayado: una línea fina en el color del caret bajo el texto.
1115        View::new(Style {
1116            position: Position::Absolute,
1117            inset: Rect {
1118                left: length(x),
1119                top: length(y + metrics.line_height - 2.0),
1120                right: auto(),
1121                bottom: auto(),
1122            },
1123            size: Size { width: length(w), height: length(1.5_f32) },
1124            ..Default::default()
1125        })
1126        .fill(palette.caret),
1127    ]
1128}
1129
1130fn bracket_highlight<Msg: Clone + 'static>(
1131    pos: Pos,
1132    metrics: EditorMetrics,
1133    palette: &EditorPalette,
1134) -> View<Msg> {
1135    let x = text_x(pos.col, metrics);
1136    let y = text_y(pos.line, metrics);
1137    View::new(Style {
1138        position: Position::Absolute,
1139        inset: Rect {
1140            left: length(x),
1141            top: length(y),
1142            right: auto(),
1143            bottom: auto(),
1144        },
1145        size: Size { width: length(metrics.char_width), height: length(metrics.line_height) },
1146        ..Default::default()
1147    })
1148    .fill(palette.bg_bracket_pair)
1149}
1150
1151fn diagnostic_underline<Msg: Clone + 'static>(
1152    d: &Diagnostic,
1153    scroll: usize,
1154    end_viewport: usize,
1155    metrics: EditorMetrics,
1156    palette: &EditorPalette,
1157) -> Vec<View<Msg>> {
1158    let color = match d.severity {
1159        Severity::Error => palette.diag_error,
1160        Severity::Warning => palette.diag_warning,
1161        Severity::Information => palette.diag_info,
1162        Severity::Hint => palette.diag_hint,
1163    };
1164    let mut out: Vec<View<Msg>> = Vec::new();
1165    let first = d.range.start.line.max(scroll);
1166    let last = d.range.end.line.min(end_viewport.saturating_sub(1));
1167    if first > last {
1168        return out;
1169    }
1170    for line in first..=last {
1171        let col_start = if line == d.range.start.line { d.range.start.col } else { 0 };
1172        let col_end = if line == d.range.end.line {
1173            d.range.end.col
1174        } else {
1175            // Fin de línea — extendemos 1 char extra para visualizar el wrap.
1176            col_start + 1
1177        };
1178        if col_end <= col_start {
1179            continue;
1180        }
1181        let x = text_x(col_start, metrics);
1182        let w = (col_end - col_start) as f32 * metrics.char_width;
1183        // Subrayado de 1.5 px al final de la línea.
1184        let y = text_y(line - scroll, metrics) + metrics.line_height - 2.0;
1185        out.push(
1186            View::new(Style {
1187                position: Position::Absolute,
1188                inset: Rect {
1189                    left: length(x),
1190                    top: length(y),
1191                    right: auto(),
1192                    bottom: auto(),
1193                },
1194                size: Size { width: length(w), height: length(1.5_f32) },
1195                ..Default::default()
1196            })
1197            .fill(color),
1198        );
1199    }
1200    out
1201}
1202
1203fn match_rects<Msg: Clone + 'static>(
1204    state: &EditorState,
1205    start_off: usize,
1206    end_off: usize,
1207    scroll: usize,
1208    end_viewport: usize,
1209    metrics: EditorMetrics,
1210    palette: &EditorPalette,
1211) -> Vec<View<Msg>> {
1212    if start_off == end_off {
1213        return vec![];
1214    }
1215    let (start_line, start_col) = state.buffer.offset_to_pos(start_off);
1216    let (end_line, end_col) = state.buffer.offset_to_pos(end_off);
1217    let mut out: Vec<View<Msg>> = Vec::new();
1218    let first = start_line.max(scroll);
1219    let last = end_line.min(end_viewport.saturating_sub(1));
1220    if first > last {
1221        return out;
1222    }
1223    for line in first..=last {
1224        let line_len = state.buffer.line_len_chars(line);
1225        let col_start = if line == start_line { start_col } else { 0 };
1226        let col_end = if line == end_line { end_col } else { line_len };
1227        if col_end <= col_start {
1228            continue;
1229        }
1230        let x = text_x(col_start, metrics);
1231        let w = (col_end - col_start) as f32 * metrics.char_width;
1232        let local_y = text_y(line - scroll, metrics);
1233        out.push(
1234            View::new(Style {
1235                position: Position::Absolute,
1236                inset: Rect {
1237                    left: length(x),
1238                    top: length(local_y),
1239                    right: auto(),
1240                    bottom: auto(),
1241                },
1242                size: Size { width: length(w), height: length(metrics.line_height) },
1243                ..Default::default()
1244            })
1245            .fill(palette.bg_match),
1246        );
1247    }
1248    out
1249}
1250
1251fn selection_rects_for_cursor<Msg: Clone + 'static>(
1252    state: &EditorState,
1253    cursor: &crate::cursor::Cursor,
1254    scroll: usize,
1255    end_viewport: usize,
1256    metrics: EditorMetrics,
1257    palette: &EditorPalette,
1258) -> Vec<View<Msg>> {
1259    let (start_off, end_off) = cursor.selection_range(&state.buffer);
1260    if start_off == end_off {
1261        return vec![];
1262    }
1263    let (start_line, start_col) = state.buffer.offset_to_pos(start_off);
1264    let (end_line, end_col) = state.buffer.offset_to_pos(end_off);
1265
1266    let mut out: Vec<View<Msg>> = Vec::new();
1267    let first = start_line.max(scroll);
1268    let last = end_line.min(end_viewport.saturating_sub(1));
1269    if first > last {
1270        return out;
1271    }
1272    for line in first..=last {
1273        let line_len = state.buffer.line_len_chars(line);
1274        let col_start = if line == start_line { start_col } else { 0 };
1275        let col_end = if line == end_line { end_col } else { line_len };
1276        let x = text_x(col_start, metrics);
1277        let extra = if line < end_line { 1.0 } else { 0.0 };
1278        let w = ((col_end - col_start) as f32 + extra) * metrics.char_width;
1279        if w <= 0.0 {
1280            continue;
1281        }
1282        let local_y = text_y(line - scroll, metrics);
1283        out.push(
1284            View::new(Style {
1285                position: Position::Absolute,
1286                inset: Rect {
1287                    left: length(x),
1288                    top: length(local_y),
1289                    right: auto(),
1290                    bottom: auto(),
1291                },
1292                size: Size { width: length(w), height: length(metrics.line_height) },
1293                ..Default::default()
1294            })
1295            .fill(palette.bg_selection),
1296        );
1297    }
1298    out
1299}
1300
1301/// Redondeo half-away-from-zero sin `std` — `f32::round` es un método de la
1302/// stdlib, no una operación del lenguaje (misma razón que el `ceil_f32` del
1303/// compositor; NUNCA `FloatFuncs` de kurbo, es una trampa de cfg).
1304#[inline]
1305fn redondear(x: f32) -> f32 {
1306    if x >= 0.0 {
1307        (x + 0.5) as i64 as f32
1308    } else {
1309        (x - 0.5) as i64 as f32
1310    }
1311}