gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ c1b11b2c01d7623a4f346519b9d1ec435be30b95 widgets/text-editor/src/view.rs Rust · 855 líneas · 29.8 KB Historia Quién tocó qué crudo

feat: llimphi standalone — framework UI soberano extraído del monorepo e65e9cc62 · 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 llimphi_ui::llimphi_layout::taffy::{
17    prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style},
18    AlignItems,
19};
20use llimphi_ui::llimphi_raster::peniko::Color;
21use llimphi_ui::llimphi_text::Alignment;
22use llimphi_ui::View;
23
24use crate::cursor::Pos;
25use crate::diagnostics::{Diagnostic, Severity};
26use crate::highlight::{Language, Span, SyntaxPalette, TokenKind};
27use crate::state::EditorState;
28
29/// Paleta del editor. Defaults dark.
30#[derive(Debug, Clone, Copy)]
31pub struct EditorPalette {
32    pub bg: Color,
33    pub bg_gutter: Color,
34    pub bg_selection: Color,
35    pub bg_current_line: Color,
36    pub fg_text: Color,
37    pub fg_line_number: Color,
38    pub fg_line_number_active: Color,
39    pub caret: Color,
40    /// Fondo del bracket bajo el cursor + su par. Un acento sutil.
41    pub bg_bracket_pair: Color,
42    /// Fondo de cada match del find activo.
43    pub bg_match: Color,
44    /// Subrayado de diagnostic — Error.
45    pub diag_error: Color,
46    /// Subrayado de diagnostic — Warning.
47    pub diag_warning: Color,
48    /// Subrayado de diagnostic — Information.
49    pub diag_info: Color,
50    /// Subrayado de diagnostic — Hint.
51    pub diag_hint: Color,
52}
53
54impl Default for EditorPalette {
55    fn default() -> Self {
56        Self::from_theme(&llimphi_theme::Theme::dark())
57    }
58}
59
60impl EditorPalette {
61    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
62        // Reutilizamos slots del theme; los que no existen como semánticos
63        // se derivan con `mix`/transparencia conceptual.
64        Self {
65            bg: t.bg_input,
66            bg_gutter: t.bg_panel,
67            bg_selection: t.bg_selected,
68            bg_current_line: t.bg_panel_alt,
69            fg_text: t.fg_text,
70            fg_line_number: t.fg_muted,
71            fg_line_number_active: t.fg_text,
72            caret: t.accent,
73            bg_bracket_pair: t.bg_button_hover,
74            bg_match: t.bg_button_hover,
75            diag_error: t.fg_destructive,
76            diag_warning: Color::from_rgb8(229, 192, 123),
77            diag_info: Color::from_rgb8(97, 175, 239),
78            diag_hint: t.fg_muted,
79        }
80    }
81}
82
83/// Cómo renderizar la columna izquierda del editor.
84///
85/// - [`GutterStyle::Numbers`] es el comportamiento clásico de IDE:
86///   "1", "2", "3"… alineados a la derecha del gutter.
87/// - [`GutterStyle::Phantom`] suprime los números y dibuja en su lugar
88///   un tick **muy sutil** por línea (un pequeño segmento horizontal
89///   con baja opacidad). Sirve para prosa narrativa donde el número de
90///   línea es ruido — la línea sigue estando, pero "fingiendo no
91///   estar". El gutter en este modo se acorta a un sliver fino.
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93pub enum GutterStyle {
94    #[default]
95    Numbers,
96    Phantom,
97}
98
99/// Métricas del editor — todo derivado del `font_size`. Cambiar la
100/// fuente requiere recalcular `char_width` empíricamente para la mono
101/// que use llimphi-text; los valores acá son razonables para
102/// `font_size = 12` con la mono default de parley.
103#[derive(Debug, Clone, Copy)]
104pub struct EditorMetrics {
105    pub font_size: f32,
106    /// Alto de cada línea en píxeles (font_size * line_height_ratio).
107    pub line_height: f32,
108    /// Ancho promedio de un char (mono). Si la fuente no es mono, esto
109    /// es sólo una aproximación.
110    pub char_width: f32,
111    /// Ancho del gutter (incluye padding interno).
112    pub gutter_width: f32,
113    /// Cómo se pinta el gutter. Default [`GutterStyle::Numbers`] — el
114    /// comportamiento clásico se conserva para callers existentes.
115    pub gutter_style: GutterStyle,
116    /// Si `true`, cada línea **guarda** (índices en
117    /// `EditorState::guard_lines`) recibe un segmento horizontal con
118    /// baja opacidad atravesando su centro — un divisor fantasma que
119    /// sugiere "acá termina un bloque" sin gritar. Sin guardas, esto
120    /// no hace nada visible. Default `false`: comportamiento IDE
121    /// clásico.
122    pub phantom_guard_lines: bool,
123}
124
125impl Default for EditorMetrics {
126    fn default() -> Self {
127        Self::for_font_size(12.0)
128    }
129}
130
131impl EditorMetrics {
132    pub const fn for_font_size(font_size: f32) -> Self {
133        Self {
134            font_size,
135            line_height: font_size * 1.4,
136            char_width: font_size * 0.6,
137            gutter_width: font_size * 3.5,
138            gutter_style: GutterStyle::Numbers,
139            phantom_guard_lines: false,
140        }
141    }
142
143    /// Variante "prosa": gutter fantasma (ticks sutiles, sin números) +
144    /// divisores fantasma en cada guarda. Ancho del gutter reducido a
145    /// un sliver porque ya no necesita acomodar dígitos.
146    ///
147    /// Pensado para editores narrativos tipo `cuerpo_ide` donde el
148    /// número de línea es ruido y las junctions están marcadas como
149    /// guardas.
150    pub const fn prosa(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 * 1.0,
156            gutter_style: GutterStyle::Phantom,
157            phantom_guard_lines: true,
158        }
159    }
160
161    /// Convierte coords locales del **área de contenido** (no del gutter)
162    /// a `(line, col)` absolutas en el buffer. `local_x` se mide desde el
163    /// borde izquierdo del área de texto (sin el padding interno de 4 px);
164    /// `local_y` desde la primera línea visible.
165    ///
166    /// Devuelve coordenadas siempre dentro del buffer — el caller
167    /// generalmente las pasa a `EditorState::set_caret_at` que clampea
168    /// `col` al ancho real de la línea.
169    pub fn screen_to_pos(self, local_x: f32, local_y: f32, scroll_offset: usize) -> (usize, usize) {
170        let line_local = (local_y / self.line_height).max(0.0) as usize;
171        let col = ((local_x - 4.0).max(0.0) / self.char_width).round() as usize;
172        (scroll_offset + line_local, col)
173    }
174}
175
176/// Render principal sin syntax highlight — todas las líneas visibles
177/// en `palette.fg_text`. `visible_lines` es cuántas líneas mostrar como
178/// máximo en el viewport.
179///
180/// `on_pointer` se invoca con el evento del mouse dentro del área de
181/// texto (no del gutter): el caller decide cómo mover el caret /
182/// extender selección. Ver [`PointerEvent`].
183pub fn text_editor_view<Msg: Clone + 'static>(
184    state: &EditorState,
185    palette: &EditorPalette,
186    metrics: EditorMetrics,
187    visible_lines: usize,
188    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
189) -> View<Msg> {
190    text_editor_view_highlighted(
191        state,
192        palette,
193        metrics,
194        visible_lines,
195        Language::Plain,
196        on_pointer,
197    )
198}
199
200/// Evento de mouse que el view envía al caller dentro del área de texto.
201/// El caller convierte `(x, y)` con [`EditorMetrics::screen_to_pos`] y
202/// aplica `set_caret_at` (Click) o `extend_selection_to` (Drag).
203///
204/// `Drag` entrega `initial` (pos del press inicial, constante durante el
205/// drag) + `delta` (delta desde el evento anterior). El caller debe
206/// acumular el delta — el view no mantiene state. Patrón típico:
207/// `accum += (dx, dy); actual = (initial_x + accum.0, initial_y + accum.1)`.
208#[derive(Debug, Clone, Copy)]
209pub enum PointerEvent {
210    Click { x: f32, y: f32 },
211    Drag { initial_x: f32, initial_y: f32, dx: f32, dy: f32 },
212}
213
214/// Render con syntax highlight + **viewport scrolling**: sólo se renderizan
215/// las líneas en `[state.scroll_offset, scroll_offset + visible_lines)`.
216///
217/// `visible_lines` es cuántas líneas máximo dibujamos por frame; el caller
218/// se asegura de tener un container con altura ≥ `visible_lines * line_height`
219/// o aplica clip propio. Para archivos grandes (1000+ líneas), el cap es
220/// crítico — sin él generaríamos miles de Views y wgpu rechazaría el bind
221/// group por `max_*_buffer_binding_size`.
222///
223/// Recomendación para el caller: tras cada edición, llamar a
224/// [`EditorState::ensure_caret_visible`] con el mismo `visible_lines` para
225/// que el viewport siga al caret.
226pub fn text_editor_view_highlighted<Msg: Clone + 'static>(
227    state: &EditorState,
228    palette: &EditorPalette,
229    metrics: EditorMetrics,
230    visible_lines: usize,
231    language: Language,
232    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
233) -> View<Msg> {
234    text_editor_view_full(
235        state,
236        palette,
237        metrics,
238        visible_lines,
239        language,
240        &[],
241        on_pointer,
242    )
243}
244
245/// Como [`text_editor_view_highlighted`] + `match_ranges` para pintar
246/// las ocurrencias de un find activo. Cada par `(char_start, char_end)`
247/// es un rango de chars globales del buffer.
248pub fn text_editor_view_full<Msg: Clone + 'static>(
249    state: &EditorState,
250    palette: &EditorPalette,
251    metrics: EditorMetrics,
252    visible_lines: usize,
253    language: Language,
254    match_ranges: &[(usize, usize)],
255    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
256) -> View<Msg> {
257    let caret = state.cursor.caret;
258    let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark());
259
260    let visible = visible_lines.max(1).min(200);
261    let line_count = state.line_count();
262    let scroll = state.scroll_offset.min(line_count.saturating_sub(1));
263    let end_line = (scroll + visible).min(line_count);
264    let height = (end_line - scroll) as f32 * metrics.line_height;
265
266    // Memoizado por `edit_seq` — sólo reparseamos cuando el buffer
267    // realmente cambió o cambia el `Language`.
268    let spans = state.highlighted_spans(language);
269
270    let gutter = build_gutter(state, scroll, end_line, caret.line, metrics, palette);
271    let content = build_content(
272        state,
273        palette,
274        metrics,
275        height,
276        scroll,
277        end_line,
278        spans,
279        &syntax,
280        match_ranges,
281        on_pointer,
282    );
283
284    View::new(Style {
285        flex_direction: FlexDirection::Row,
286        size: Size { width: percent(1.0_f32), height: length(height) },
287        ..Default::default()
288    })
289    .fill(palette.bg)
290    .clip(true)
291    .children(vec![gutter, content])
292}
293
294fn build_gutter<Msg: Clone + 'static>(
295    state: &EditorState,
296    scroll: usize,
297    end_line: usize,
298    active_line: usize,
299    metrics: EditorMetrics,
300    palette: &EditorPalette,
301) -> View<Msg> {
302    let count = end_line.saturating_sub(scroll);
303    let mut children: Vec<View<Msg>> = Vec::with_capacity(count);
304    for n in scroll..end_line {
305        // Las líneas-guarda son separadores estructurales entre zonas
306        // de texto: ni se numeran ni se pueden escribir. El espacio
307        // se preserva (la línea sigue existiendo), pero el gutter las
308        // saltea — visualmente la numeración "rompe" en cada zona.
309        // Si `guard_lines` está vacío, este check es siempre `false`
310        // y la numeración cubre todas las líneas (modo IDE clásico).
311        if state.is_guard_line(n) {
312            continue;
313        }
314        let color = if n == active_line {
315            palette.fg_line_number_active
316        } else {
317            palette.fg_line_number
318        };
319        let y = (n - scroll) as f32 * metrics.line_height;
320        match metrics.gutter_style {
321            GutterStyle::Numbers => {
322                let label = (n + 1).to_string();
323                children.push(
324                    View::new(Style {
325                        position: Position::Absolute,
326                        inset: Rect {
327                            left: length(0.0_f32),
328                            top: length(y),
329                            right: length(4.0_f32),
330                            bottom: auto(),
331                        },
332                        size: Size {
333                            width: length(metrics.gutter_width - 4.0),
334                            height: length(metrics.line_height),
335                        },
336                        align_items: Some(AlignItems::Center),
337                        ..Default::default()
338                    })
339                    .text_aligned(label, metrics.font_size * 0.85, color, Alignment::End),
340                );
341            }
342            GutterStyle::Phantom => {
343                // Tick fantasma — un segmento horizontal corto centrado
344                // verticalmente en la línea, con la opacidad bajada.
345                // La línea activa queda un pelín más visible.
346                let alpha = if n == active_line { 0.35 } else { 0.12 };
347                let tick_w = (metrics.gutter_width * 0.5).max(3.0);
348                let tick_h = 1.0_f32;
349                let tick_y = y + (metrics.line_height - tick_h) * 0.5;
350                let tick_x = (metrics.gutter_width - tick_w) * 0.5;
351                children.push(
352                    View::new(Style {
353                        position: Position::Absolute,
354                        inset: Rect {
355                            left: length(tick_x),
356                            top: length(tick_y),
357                            right: auto(),
358                            bottom: auto(),
359                        },
360                        size: Size {
361                            width: length(tick_w),
362                            height: length(tick_h),
363                        },
364                        ..Default::default()
365                    })
366                    .fill(with_alpha(color, alpha)),
367                );
368            }
369        }
370    }
371
372    // En modo Phantom el gutter es un sliver: no aplicamos `fill` —
373    // se mezcla con el fondo del editor. El gutter "está sin estar".
374    let bg = match metrics.gutter_style {
375        GutterStyle::Numbers => palette.bg_gutter,
376        GutterStyle::Phantom => palette.bg,
377    };
378    View::new(Style {
379        size: Size {
380            width: length(metrics.gutter_width),
381            height: percent(1.0_f32),
382        },
383        ..Default::default()
384    })
385    .fill(bg)
386    .clip(true)
387    .children(children)
388}
389
390/// Devuelve `c` con la opacidad multiplicada por `alpha` (clamp 0..1).
391fn with_alpha(c: Color, alpha: f32) -> Color {
392    let rgba = c.to_rgba8();
393    let a = ((alpha.clamp(0.0, 1.0)) * (rgba.a as f32)) as u8;
394    Color::from_rgba8(rgba.r, rgba.g, rgba.b, a)
395}
396
397fn build_content<Msg: Clone + 'static>(
398    state: &EditorState,
399    palette: &EditorPalette,
400    metrics: EditorMetrics,
401    height: f32,
402    scroll: usize,
403    end_line: usize,
404    spans_per_line: Vec<Vec<Span>>,
405    syntax: &SyntaxPalette,
406    match_ranges: &[(usize, usize)],
407    on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
408) -> View<Msg> {
409    let caret = state.cursor.caret;
410    let mut children: Vec<View<Msg>> = Vec::new();
411
412    // 0) Tintes por línea — la capa más baja, debajo de todo el resto.
413    //    Pinta un rect del ancho completo del área de contenido por
414    //    cada línea con tinte asignado. El caller elige el alpha — el
415    //    widget no lo modula. Si la línea cae fuera de viewport o no
416    //    tiene tinte, no se pinta nada.
417    for n in scroll..end_line {
418        if let Some(Some(c)) = state.line_tints.get(n) {
419            children.push(line_tint(n - scroll, *c, metrics));
420        }
421    }
422
423    // 1) Fondo del renglón activo — sólo el del primary cursor.
424    if caret.line >= scroll && caret.line < end_line {
425        children.push(line_highlight(caret.line - scroll, metrics, palette));
426    }
427
428    // 1b) Highlight de matches del find.
429    for (s, e) in match_ranges {
430        children.extend(match_rects(state, *s, *e, scroll, end_line, metrics, palette));
431    }
432
433    // 2) Selección — por cada cursor que tenga selección.
434    for c in state.all_cursors() {
435        if c.has_selection() {
436            children.extend(selection_rects_for_cursor(
437                state, c, scroll, end_line, metrics, palette,
438            ));
439        }
440    }
441
442    // 2b) Bracket pair bajo el primary cursor — si visible.
443    if let Some((a, b)) = crate::bracket::find_bracket_pair(&state.buffer, &state.cursor) {
444        if a.line >= scroll && a.line < end_line {
445            children.push(bracket_highlight(crate::cursor::Pos::new(a.line - scroll, a.col), metrics, palette));
446        }
447        if b.line >= scroll && b.line < end_line {
448            children.push(bracket_highlight(crate::cursor::Pos::new(b.line - scroll, b.col), metrics, palette));
449        }
450    }
451
452    // 3) Texto — sólo las líneas en viewport.
453    //    Si `phantom_guard_lines` está activo, cada guarda recibe un
454    //    divisor fantasma (segmento horizontal con baja opacidad)
455    //    atravesando su centro — sin texto, sólo un susurro visual.
456    for n in scroll..end_line {
457        let text = state.buffer.line(n);
458        let text = text.trim_end_matches('\n').to_owned();
459        let local_line = n - scroll;
460        if metrics.phantom_guard_lines && state.is_guard_line(n) {
461            children.push(phantom_guard_divider(local_line, metrics, palette));
462            continue;
463        }
464        if let Some(line_spans) = spans_per_line.get(n) {
465            children.push(line_text_tokens(local_line, &text, line_spans, metrics, palette, syntax));
466        } else {
467            children.push(line_text_plain(local_line, text, metrics, palette));
468        }
469    }
470
471    // 3b) Diagnostics — subrayado bajo el rango, color por severity.
472    for d in &state.diagnostics {
473        children.extend(diagnostic_underline(d, scroll, end_line, metrics, palette));
474    }
475
476    // 4) Caret — uno por cursor, sólo si visible.
477    for c in state.all_cursors() {
478        let p = c.caret;
479        if p.line >= scroll && p.line < end_line {
480            let local = crate::cursor::Pos::new(p.line - scroll, p.col);
481            children.push(caret_rect(local, metrics, palette));
482        }
483    }
484
485    let click_cb = on_pointer.clone();
486    let drag_cb = on_pointer;
487    View::new(Style {
488        flex_grow: 1.0,
489        size: Size { width: percent(1.0_f32), height: length(height) },
490        ..Default::default()
491    })
492    .fill(palette.bg)
493    .clip(true)
494    .on_click_at(move |x, y, _w, _h| click_cb(PointerEvent::Click { x, y }))
495    .draggable_at(move |phase, dx, dy, lx, ly| match phase {
496        llimphi_ui::DragPhase::Move => drag_cb(PointerEvent::Drag {
497            initial_x: lx,
498            initial_y: ly,
499            dx,
500            dy,
501        }),
502        llimphi_ui::DragPhase::End => None,
503    })
504    .children(children)
505}
506
507/// Rect de tinte para una línea. Cubre el ancho completo y el alto
508/// exacto de la línea, pintado al color literal pasado (el caller
509/// elige el alpha). Posición absoluta dentro del área de contenido.
510fn line_tint<Msg: Clone + 'static>(
511    line: usize,
512    color: Color,
513    metrics: EditorMetrics,
514) -> View<Msg> {
515    View::new(Style {
516        position: Position::Absolute,
517        inset: Rect {
518            left: length(0.0_f32),
519            top: length(line as f32 * metrics.line_height),
520            right: length(0.0_f32),
521            bottom: auto(),
522        },
523        size: Size {
524            width: percent(1.0_f32),
525            height: length(metrics.line_height),
526        },
527        ..Default::default()
528    })
529    .fill(color)
530}
531
532fn line_highlight<Msg: Clone + 'static>(
533    line: usize,
534    metrics: EditorMetrics,
535    palette: &EditorPalette,
536) -> View<Msg> {
537    View::new(Style {
538        position: Position::Absolute,
539        inset: Rect {
540            left: length(0.0_f32),
541            top: length(line as f32 * metrics.line_height),
542            right: length(0.0_f32),
543            bottom: auto(),
544        },
545        size: Size {
546            width: percent(1.0_f32),
547            height: length(metrics.line_height),
548        },
549        ..Default::default()
550    })
551    .fill(palette.bg_current_line)
552}
553
554/// Línea-fantasma para una guarda: un segmento horizontal con baja
555/// opacidad atravesando el centro vertical de la línea. Ancho
556/// limitado para que parezca un susurro y no una regla. Color derivado
557/// de `fg_line_number` que ya está pensado como "muted".
558fn phantom_guard_divider<Msg: Clone + 'static>(
559    line: usize,
560    metrics: EditorMetrics,
561    palette: &EditorPalette,
562) -> View<Msg> {
563    let h = 1.0_f32;
564    let y = line as f32 * metrics.line_height + (metrics.line_height - h) * 0.5;
565    // Largo visual del divisor — generoso pero no infinito.
566    let w = 320.0_f32;
567    View::new(Style {
568        position: Position::Absolute,
569        inset: Rect {
570            left: length(8.0_f32),
571            top: length(y),
572            right: auto(),
573            bottom: auto(),
574        },
575        size: Size {
576            width: length(w),
577            height: length(h),
578        },
579        ..Default::default()
580    })
581    .fill(with_alpha(palette.fg_line_number, 0.18))
582}
583
584fn line_text_plain<Msg: Clone + 'static>(
585    line: usize,
586    text: String,
587    metrics: EditorMetrics,
588    palette: &EditorPalette,
589) -> View<Msg> {
590    View::new(Style {
591        position: Position::Absolute,
592        inset: Rect {
593            left: length(4.0_f32),
594            top: length(line as f32 * metrics.line_height),
595            right: auto(),
596            bottom: auto(),
597        },
598        size: Size {
599            width: length(2000.0_f32),
600            height: length(metrics.line_height),
601        },
602        align_items: Some(AlignItems::Center),
603        ..Default::default()
604    })
605    .text_aligned(text, metrics.font_size, palette.fg_text, Alignment::Start)
606}
607
608/// Renderiza una línea como secuencia de Views absolutos posicionados,
609/// cada uno con el color de su span. El posicionamiento horizontal usa
610/// `char_width` (mono); para fuentes proporcionales habría que medir
611/// cada token con parley (TODO).
612fn line_text_tokens<Msg: Clone + 'static>(
613    line: usize,
614    text: &str,
615    spans: &[Span],
616    metrics: EditorMetrics,
617    palette: &EditorPalette,
618    syntax: &SyntaxPalette,
619) -> View<Msg> {
620    // char-col → byte-offset: parley rangea por bytes, los spans por chars.
621    let mut byte_at: Vec<usize> = Vec::with_capacity(text.len() + 1);
622    let mut acc = 0usize;
623    byte_at.push(0);
624    for ch in text.chars() {
625        acc += ch.len_utf8();
626        byte_at.push(acc);
627    }
628    let nchars = byte_at.len() - 1;
629
630    // Un run de color por span no-Other (el default_color cubre el resto).
631    let mut runs: Vec<(usize, usize, Color)> = Vec::with_capacity(spans.len());
632    for span in spans {
633        if span.start_col >= nchars || matches!(span.kind, TokenKind::Other) {
634            continue;
635        }
636        let end = span.end_col.min(nchars);
637        if end <= span.start_col {
638            continue;
639        }
640        runs.push((byte_at[span.start_col], byte_at[end], syntax.color(span.kind)));
641    }
642
643    // Una sola línea shapeada de una vez, multicolor, en lugar de un nodo
644    // (+ layout parley) por token. El `+4` de gutter va en el inset del nodo.
645    View::new(Style {
646        position: Position::Absolute,
647        inset: Rect {
648            left: length(4.0_f32),
649            top: length(line as f32 * metrics.line_height),
650            right: auto(),
651            bottom: auto(),
652        },
653        size: Size { width: length(2000.0_f32), height: length(metrics.line_height) },
654        ..Default::default()
655    })
656    .text_runs(
657        text.to_string(),
658        metrics.font_size,
659        palette.fg_text,
660        runs,
661        Alignment::Start,
662    )
663}
664
665fn caret_rect<Msg: Clone + 'static>(
666    caret: Pos,
667    metrics: EditorMetrics,
668    palette: &EditorPalette,
669) -> View<Msg> {
670    let x = 4.0 + caret.col as f32 * metrics.char_width;
671    let y = caret.line as f32 * metrics.line_height;
672    View::new(Style {
673        position: Position::Absolute,
674        inset: Rect {
675            left: length(x),
676            top: length(y + 2.0),
677            right: auto(),
678            bottom: auto(),
679        },
680        size: Size { width: length(2.0_f32), height: length(metrics.line_height - 4.0) },
681        ..Default::default()
682    })
683    .fill(palette.caret)
684}
685
686fn bracket_highlight<Msg: Clone + 'static>(
687    pos: Pos,
688    metrics: EditorMetrics,
689    palette: &EditorPalette,
690) -> View<Msg> {
691    let x = 4.0 + pos.col as f32 * metrics.char_width;
692    let y = pos.line as f32 * metrics.line_height;
693    View::new(Style {
694        position: Position::Absolute,
695        inset: Rect {
696            left: length(x),
697            top: length(y),
698            right: auto(),
699            bottom: auto(),
700        },
701        size: Size { width: length(metrics.char_width), height: length(metrics.line_height) },
702        ..Default::default()
703    })
704    .fill(palette.bg_bracket_pair)
705}
706
707fn diagnostic_underline<Msg: Clone + 'static>(
708    d: &Diagnostic,
709    scroll: usize,
710    end_viewport: usize,
711    metrics: EditorMetrics,
712    palette: &EditorPalette,
713) -> Vec<View<Msg>> {
714    let color = match d.severity {
715        Severity::Error => palette.diag_error,
716        Severity::Warning => palette.diag_warning,
717        Severity::Information => palette.diag_info,
718        Severity::Hint => palette.diag_hint,
719    };
720    let mut out: Vec<View<Msg>> = Vec::new();
721    let first = d.range.start.line.max(scroll);
722    let last = d.range.end.line.min(end_viewport.saturating_sub(1));
723    if first > last {
724        return out;
725    }
726    for line in first..=last {
727        let col_start = if line == d.range.start.line { d.range.start.col } else { 0 };
728        let col_end = if line == d.range.end.line {
729            d.range.end.col
730        } else {
731            // Fin de línea — extendemos 1 char extra para visualizar el wrap.
732            col_start + 1
733        };
734        if col_end <= col_start {
735            continue;
736        }
737        let x = 4.0 + col_start as f32 * metrics.char_width;
738        let w = (col_end - col_start) as f32 * metrics.char_width;
739        // Subrayado de 1.5 px al final de la línea.
740        let y = (line - scroll) as f32 * metrics.line_height + metrics.line_height - 2.0;
741        out.push(
742            View::new(Style {
743                position: Position::Absolute,
744                inset: Rect {
745                    left: length(x),
746                    top: length(y),
747                    right: auto(),
748                    bottom: auto(),
749                },
750                size: Size { width: length(w), height: length(1.5_f32) },
751                ..Default::default()
752            })
753            .fill(color),
754        );
755    }
756    out
757}
758
759fn match_rects<Msg: Clone + 'static>(
760    state: &EditorState,
761    start_off: usize,
762    end_off: usize,
763    scroll: usize,
764    end_viewport: usize,
765    metrics: EditorMetrics,
766    palette: &EditorPalette,
767) -> Vec<View<Msg>> {
768    if start_off == end_off {
769        return vec![];
770    }
771    let (start_line, start_col) = state.buffer.offset_to_pos(start_off);
772    let (end_line, end_col) = state.buffer.offset_to_pos(end_off);
773    let mut out: Vec<View<Msg>> = Vec::new();
774    let first = start_line.max(scroll);
775    let last = end_line.min(end_viewport.saturating_sub(1));
776    if first > last {
777        return out;
778    }
779    for line in first..=last {
780        let line_len = state.buffer.line_len_chars(line);
781        let col_start = if line == start_line { start_col } else { 0 };
782        let col_end = if line == end_line { end_col } else { line_len };
783        if col_end <= col_start {
784            continue;
785        }
786        let x = 4.0 + col_start as f32 * metrics.char_width;
787        let w = (col_end - col_start) as f32 * metrics.char_width;
788        let local_y = (line - scroll) as f32 * metrics.line_height;
789        out.push(
790            View::new(Style {
791                position: Position::Absolute,
792                inset: Rect {
793                    left: length(x),
794                    top: length(local_y),
795                    right: auto(),
796                    bottom: auto(),
797                },
798                size: Size { width: length(w), height: length(metrics.line_height) },
799                ..Default::default()
800            })
801            .fill(palette.bg_match),
802        );
803    }
804    out
805}
806
807fn selection_rects_for_cursor<Msg: Clone + 'static>(
808    state: &EditorState,
809    cursor: &crate::cursor::Cursor,
810    scroll: usize,
811    end_viewport: usize,
812    metrics: EditorMetrics,
813    palette: &EditorPalette,
814) -> Vec<View<Msg>> {
815    let (start_off, end_off) = cursor.selection_range(&state.buffer);
816    if start_off == end_off {
817        return vec![];
818    }
819    let (start_line, start_col) = state.buffer.offset_to_pos(start_off);
820    let (end_line, end_col) = state.buffer.offset_to_pos(end_off);
821
822    let mut out: Vec<View<Msg>> = Vec::new();
823    let first = start_line.max(scroll);
824    let last = end_line.min(end_viewport.saturating_sub(1));
825    if first > last {
826        return out;
827    }
828    for line in first..=last {
829        let line_len = state.buffer.line_len_chars(line);
830        let col_start = if line == start_line { start_col } else { 0 };
831        let col_end = if line == end_line { end_col } else { line_len };
832        let x = 4.0 + col_start as f32 * metrics.char_width;
833        let extra = if line < end_line { 1.0 } else { 0.0 };
834        let w = ((col_end - col_start) as f32 + extra) * metrics.char_width;
835        if w <= 0.0 {
836            continue;
837        }
838        let local_y = (line - scroll) as f32 * metrics.line_height;
839        out.push(
840            View::new(Style {
841                position: Position::Absolute,
842                inset: Rect {
843                    left: length(x),
844                    top: length(local_y),
845                    right: auto(),
846                    bottom: auto(),
847                },
848                size: Size { width: length(w), height: length(metrics.line_height) },
849                ..Default::default()
850            })
851            .fill(palette.bg_selection),
852        );
853    }
854    out
855}