tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c1b11b2c01d7623a4f346519b9d1ec435be30b95
widgets/text-editor/src/view.rs
Historia
Quién tocó qué
crudo
feat: llimphi standalone — framework UI soberano extraído del monorepo
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}