tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Sutichaykuna

⎇ dba855e446ca2bd4e7d146bdc168fb232cad15a4 widgets/text-editor/src/view.rs Rust · 1019 siq'ikuna · 36.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

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