tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ dba855e446ca2bd4e7d146bdc168fb232cad15a4 widgets/text-editor/src/state.rs Rust · 1390 siq'ikuna · 52.4 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//! [`EditorState`] — la unión de buffer + cursor + undo + opciones, con
2//! `apply_key` que mapea un `KeyEvent` de llimphi-ui a operaciones de
3//! edición o movimiento. Este es el tipo que el caller pone en su
4//! `Model` y mete en el `update` Elm.
5
6use std::cell::RefCell;
7
8use llimphi_ui::{ImeEvent, Key, KeyEvent, KeyState, NamedKey};
9
10use crate::buffer::Buffer;
11use crate::clipboard::{Clipboard, NullClipboard};
12use crate::cursor::{Cursor, Pos};
13use crate::highlight::{Highlighter, Language, Span};
14use crate::ops::{
15    dedent, delete_backward, delete_forward, indent_or_insert_tab,
16    insert_newline_auto_indent, replace_selection,
17};
18use crate::undo::UndoStack;
19
20/// Opciones del editor — afectan indent + límite de undo + page size.
21#[derive(Debug, Clone, Copy)]
22pub struct EditorOptions {
23    /// `true` = Tab inserta `indent_size` spaces; `false` = inserta `\t`.
24    pub tab_to_spaces: bool,
25    pub indent_size: usize,
26    /// Cuántas líneas avanza PageUp/PageDown.
27    pub page_size: usize,
28    /// `true` = Enter no inserta `\n`; el caller maneja submit. (modo
29    /// single-line para el text-input refactorizado).
30    pub single_line: bool,
31}
32
33impl Default for EditorOptions {
34    fn default() -> Self {
35        Self {
36            tab_to_spaces: true,
37            indent_size: 2,
38            page_size: 12,
39            single_line: false,
40        }
41    }
42}
43
44#[derive(Debug, Clone)]
45pub struct EditorState {
46    pub buffer: Buffer,
47    /// Cursor primario — el que la API legacy expone como "el" cursor.
48    /// Edit ops aplican al primary + todos los `extra_cursors` en orden.
49    pub cursor: Cursor,
50    /// Cursores adicionales (multi-cursor). Vacío en el caso típico.
51    /// Cuando hay extras, las ediciones aplican a todos; Esc los colapsa
52    /// dejando sólo el primary.
53    pub extra_cursors: Vec<Cursor>,
54    /// Diagnostics del LSP (o equivalente). El client externo los popa
55    /// vía `set_diagnostics`; el render del editor los pinta como
56    /// subrayado bajo el rango con color según severity.
57    pub diagnostics: Vec<crate::diagnostics::Diagnostic>,
58    pub options: EditorOptions,
59    /// Líneas-guarda: índices ordenados (ascendente, sin duplicados) de
60    /// líneas que el caret no puede ocupar y que el gutter no numera.
61    /// El widget no decide qué es guarda — lo decide el caller (el
62    /// `cuerpo_ide` lo computa a partir de la estructura de átomos +
63    /// flags de fusión). La lista debe mantenerse al día tras cada
64    /// edición que cambie la cantidad o posición de líneas: el caller
65    /// es responsable de actualizar este campo cuando reaccione a
66    /// `Changed`. Vacío = sin guardas, comportamiento clásico de IDE.
67    pub guard_lines: Vec<usize>,
68    /// Tinte de fondo por línea. `line_tints[i]` controla la línea
69    /// `i` del buffer: `Some(color)` pinta un rectángulo del ancho
70    /// completo del área de contenido al ALPHA del color, **debajo**
71    /// del texto y de cualquier highlight; `None` deja la línea sin
72    /// tinte. Vacío o ausente = sin tintes (modo IDE clásico). Pensado
73    /// para colorear zonas en editores narrativos sin afectar la
74    /// lectura — los callers deben elegir colores con alpha bajo (≤
75    /// ~40/255 sobre el bg).
76    pub line_tints: Vec<Option<llimphi_ui::llimphi_raster::peniko::Color>>,
77    pub undo: UndoStack,
78    /// Texto en composición del IME (dead keys de acentos, CJK, emoji
79    /// picker). No vive en el buffer hasta el `Commit`: el view lo pinta
80    /// subrayado en el caret y el caret se corre detrás. `None` = sin
81    /// composición activa (el caso normal). Lo administra
82    /// [`Self::apply_ime_event`].
83    pub preedit: Option<Preedit>,
84    /// Línea inicial visible — el viewport renderiza
85    /// `[scroll_offset, scroll_offset + visible)`. El caller llama a
86    /// [`Self::ensure_caret_visible`] tras movimientos para auto-scrollear.
87    pub scroll_offset: usize,
88    /// Contador monotónico que se incrementa con cada edición del buffer.
89    /// Lo usa el cache de highlight para invalidarse sin re-hashear el
90    /// texto entero por frame.
91    pub edit_seq: u64,
92    /// InputEdits que el editor produjo y todavía no fueron aplicados
93    /// al `Tree` cached del highlighter. El highlight, antes de
94    /// reparsear, los drena y los aplica al tree → parseo incremental
95    /// real (tree-sitter sólo reconstruye los subtrees afectados).
96    pub pending_input_edits: RefCell<Vec<tree_sitter::InputEdit>>,
97    /// Cache memoizado del syntax highlight. Interior mutability vía
98    /// `RefCell` para que el view (que recibe `&EditorState`) lo
99    /// actualice on-demand. Se invalida cuando cambian `edit_seq` o el
100    /// `Language` solicitado.
101    pub highlight_cache: RefCell<Option<HighlightCache>>,
102}
103
104/// Texto en composición del IME — el que el método de entrada está
105/// armando antes de confirmarlo (un acento muerto a medio componer, una
106/// sílaba CJK con su ventana de candidatos, un emoji del picker). No
107/// pertenece al buffer todavía; el view lo dibuja en el caret con
108/// subrayado para que el usuario vea lo que está tecleando.
109#[derive(Debug, Clone, Default, PartialEq, Eq)]
110pub struct Preedit {
111    /// Texto provisional a pintar en el caret.
112    pub text: String,
113    /// Rango `(inicio, fin)` en **bytes** dentro de `text` que el IME
114    /// quiere resaltar (la "clause" activa), si lo reporta. Hoy el view
115    /// subraya todo el preedit por igual; el campo se conserva para un
116    /// resaltado más fino cuando haga falta.
117    pub cursor: Option<(usize, usize)>,
118}
119
120/// Entrada del cache: spans por línea + clave que la generó.
121#[derive(Debug, Clone)]
122pub struct HighlightCache {
123    pub seq: u64,
124    pub language: Language,
125    pub spans: Vec<Vec<Span>>,
126}
127
128impl Default for EditorState {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134impl EditorState {
135    pub fn new() -> Self {
136        Self {
137            buffer: Buffer::new(),
138            cursor: Cursor::new(),
139            extra_cursors: Vec::new(),
140            diagnostics: Vec::new(),
141            options: EditorOptions::default(),
142            guard_lines: Vec::new(),
143            line_tints: Vec::new(),
144            undo: UndoStack::new(),
145            preedit: None,
146            scroll_offset: 0,
147            edit_seq: 0,
148            pending_input_edits: RefCell::new(Vec::new()),
149            highlight_cache: RefCell::new(None),
150        }
151    }
152
153    /// Devuelve todos los cursores en orden: primary + extras. Útil para
154    /// el render que dibuja un caret + selección por cada uno.
155    pub fn all_cursors(&self) -> impl Iterator<Item = &Cursor> {
156        std::iter::once(&self.cursor).chain(self.extra_cursors.iter())
157    }
158
159    /// Agrega un cursor adicional con caret en `(line, col)`. Si ya hay
160    /// un cursor exactamente ahí, no duplica.
161    pub fn add_cursor_at(&mut self, line: usize, col: usize) {
162        let line = line.min(self.buffer.len_lines().saturating_sub(1));
163        let col = col.min(self.buffer.line_len_chars(line));
164        let pos = Pos::new(line, col);
165        if self.cursor.caret == pos {
166            return;
167        }
168        if self.extra_cursors.iter().any(|c| c.caret == pos) {
169            return;
170        }
171        self.extra_cursors.push(Cursor::at(line, col));
172    }
173
174    /// Colapsa multi-cursor: descarta los `extra_cursors`. No toca el
175    /// primary.
176    pub fn collapse_to_primary(&mut self) {
177        self.extra_cursors.clear();
178    }
179
180    pub fn has_multi_cursor(&self) -> bool {
181        !self.extra_cursors.is_empty()
182    }
183
184    /// Reemplaza los diagnostics del editor. Usado por el client LSP
185    /// cuando recibe `textDocument/publishDiagnostics`.
186    pub fn set_diagnostics(&mut self, diags: Vec<crate::diagnostics::Diagnostic>) {
187        self.diagnostics = diags;
188    }
189
190    pub fn with_options(options: EditorOptions) -> Self {
191        Self {
192            options,
193            ..Self::new()
194        }
195    }
196
197    /// Ajusta `scroll_offset` para que la línea del caret quede dentro
198    /// de `[scroll_offset, scroll_offset + visible_lines)`. Si el caret
199    /// está arriba, scrollea para arriba; si está abajo, scrollea para
200    /// abajo dejando el caret en la última línea visible.
201    pub fn ensure_caret_visible(&mut self, visible_lines: usize) {
202        if visible_lines == 0 {
203            return;
204        }
205        let line = self.cursor.caret.line;
206        if line < self.scroll_offset {
207            self.scroll_offset = line;
208        } else if line >= self.scroll_offset + visible_lines {
209            self.scroll_offset = line + 1 - visible_lines;
210        }
211        // Clampea al rango válido — no scrollear más allá del fin del
212        // buffer (deja la última línea siempre visible).
213        let max_scroll = self.line_count().saturating_sub(1);
214        if self.scroll_offset > max_scroll {
215            self.scroll_offset = max_scroll;
216        }
217    }
218
219    /// Scrollea relativo (positivo = abajo). Clampea a 0..line_count-1.
220    pub fn scroll_by(&mut self, delta: i32) {
221        let new = (self.scroll_offset as i32 + delta).max(0) as usize;
222        let max = self.line_count().saturating_sub(1);
223        self.scroll_offset = new.min(max);
224    }
225
226    pub fn text(&self) -> String {
227        self.buffer.text()
228    }
229
230    pub fn set_text(&mut self, s: &str) {
231        self.buffer.set_text(s);
232        // Las guardas y tintes previos referían al texto viejo:
233        // limpiar. El caller los repuebla cuando reaccione al cambio.
234        self.guard_lines.clear();
235        self.line_tints.clear();
236        // Clampea el caret a la nueva longitud.
237        let last_line = self.buffer.len_lines().saturating_sub(1);
238        let col = self.buffer.line_len_chars(last_line);
239        self.cursor = Cursor::at(last_line, col);
240        self.undo.clear();
241        self.bump_edit_seq();
242        // Cambio masivo de buffer — el árbol cached del highlighter
243        // queda inválido. Lo borramos para forzar full parse próximo.
244        for lang in [Language::Rust, Language::Python] {
245            crate::highlight::invalidate_tree_cache(lang);
246        }
247    }
248
249    /// Incrementa el contador de ediciones — invalidando el cache de
250    /// highlight automáticamente.
251    pub fn bump_edit_seq(&mut self) {
252        self.edit_seq = self.edit_seq.wrapping_add(1);
253    }
254
255    /// Devuelve los spans del highlight cacheados. Si el cache no matchea
256    /// (distinto `edit_seq` o `language`), reparsea con tree-sitter
257    /// incremental — aplica los `pending_input_edits` al tree previo
258    /// antes de parsear, y guarda el nuevo tree.
259    pub fn highlighted_spans(&self, language: Language) -> Vec<Vec<Span>> {
260        if matches!(language, Language::Plain) {
261            return Vec::new();
262        }
263        let mut cache = self.highlight_cache.borrow_mut();
264        if let Some(c) = cache.as_ref() {
265            if c.seq == self.edit_seq && c.language == language {
266                return c.spans.clone();
267            }
268        }
269        // Aplica los InputEdits pending al tree cached antes de parsear
270        // — eso convierte el parseo de "full" a "incremental real".
271        let edits: Vec<tree_sitter::InputEdit> =
272            self.pending_input_edits.borrow_mut().drain(..).collect();
273        crate::highlight::apply_pending_edits(language, &edits);
274
275        let mut h = Highlighter::new(language);
276        let spans = h.highlight(&self.buffer.text());
277        *cache = Some(HighlightCache {
278            seq: self.edit_seq,
279            language,
280            spans: spans.clone(),
281        });
282        spans
283    }
284
285    pub fn is_empty(&self) -> bool {
286        self.buffer.is_empty()
287    }
288
289    pub fn line_count(&self) -> usize {
290        self.buffer.len_lines()
291    }
292
293    /// Posiciona el caret en `(line, col)`, clampeando al rango válido
294    /// del buffer. Colapsa la selección. Usado por el caller cuando el
295    /// usuario clickea en el área de texto.
296    ///
297    /// Si la línea destino está en [`Self::guard_lines`], el caret
298    /// salta a la línea no-guarda más cercana (privilegia hacia
299    /// abajo). Así un click "en la franja entre zonas" aterriza en el
300    /// inicio de la zona siguiente.
301    pub fn set_caret_at(&mut self, line: usize, col: usize) {
302        self.cursor.set_caret(&self.buffer, Pos::new(line, col));
303        if !self.guard_lines.is_empty() {
304            snap_cursor_off_guard(&mut self.cursor, &self.buffer, &self.guard_lines, 0);
305        }
306    }
307
308    /// `true` si la línea `line` figura en `guard_lines`.
309    pub fn is_guard_line(&self, line: usize) -> bool {
310        self.guard_lines.binary_search(&line).is_ok()
311    }
312
313    /// Reemplaza la lista de líneas-guarda. La entrada se ordena y
314    /// deduplica — el caller puede pasarlas en cualquier orden. Tras
315    /// el cambio NO se snappea el caret automáticamente: si tu nueva
316    /// lista deja al caret sobre una guarda, llamá a
317    /// [`Self::snap_off_guards`] explícitamente.
318    pub fn set_guard_lines(&mut self, mut lines: Vec<usize>) {
319        lines.sort_unstable();
320        lines.dedup();
321        self.guard_lines = lines;
322    }
323
324    /// Salta el primary cursor + extras fuera de cualquier línea
325    /// guarda. `dir` orienta la búsqueda: `+1` busca primero abajo,
326    /// `-1` arriba, `0` igual a `+1` (con fallback al opuesto).
327    /// No-op si `guard_lines` está vacío.
328    pub fn snap_off_guards(&mut self, dir: i32) {
329        if self.guard_lines.is_empty() {
330            return;
331        }
332        snap_cursor_off_guard(&mut self.cursor, &self.buffer, &self.guard_lines, dir);
333        for c in &mut self.extra_cursors {
334            snap_cursor_off_guard(c, &self.buffer, &self.guard_lines, dir);
335        }
336    }
337
338    /// Extiende la selección hasta `(line, col)`. Si no había anchor,
339    /// lo planta en el caret actual antes de mover. Usado por drag del
340    /// mouse: cada `Move` del drag llama esto con la nueva pos.
341    pub fn extend_selection_to(&mut self, line: usize, col: usize) {
342        let line = line.min(self.buffer.len_lines().saturating_sub(1));
343        let col = col.min(self.buffer.line_len_chars(line));
344        if self.cursor.anchor.is_none() {
345            self.cursor.anchor = Some(self.cursor.caret);
346        }
347        self.cursor.caret = Pos::new(line, col);
348        self.cursor.desired_col = col;
349    }
350
351    /// Selecciona todo el buffer: anchor en `(0,0)`, caret al final de
352    /// la última línea. Colapsa los multi-cursor extras. Operación de
353    /// sólo-cursor (no edita) — la usan el menú de edición y Ctrl+A.
354    pub fn select_all(&mut self) {
355        self.collapse_to_primary();
356        let last_line = self.buffer.len_lines().saturating_sub(1);
357        let last_col = self.buffer.line_len_chars(last_line);
358        self.cursor.anchor = Some(Pos::ORIGIN);
359        self.cursor.caret = Pos::new(last_line, last_col);
360        self.cursor.desired_col = last_col;
361    }
362
363    /// `true` si hay algo que deshacer (para habilitar "Deshacer" en el
364    /// menú de edición).
365    pub fn can_undo(&self) -> bool {
366        self.undo.can_undo()
367    }
368
369    /// `true` si hay algo que rehacer.
370    pub fn can_redo(&self) -> bool {
371        self.undo.can_redo()
372    }
373
374    /// `true` si hay una selección no-vacía (para habilitar Cortar/
375    /// Copiar/Eliminar en el menú de edición).
376    pub fn has_selection(&self) -> bool {
377        self.cursor.has_selection()
378    }
379
380    /// Texto seleccionado, si hay selección no-vacía. `None` cuando el
381    /// cursor está colapsado.
382    pub fn selected_text(&self) -> Option<String> {
383        if !self.cursor.has_selection() {
384            return None;
385        }
386        let (s, e) = self.cursor.selection_range(&self.buffer);
387        if s == e {
388            return None;
389        }
390        Some(self.buffer.slice(s, e))
391    }
392
393    /// Resultado: `Changed` si la tecla modificó el buffer o el cursor;
394    /// `Ignored` si la tecla no aplica al editor. Útil para que el
395    /// caller decida si rebuildear el view.
396    ///
397    /// Copy/cut/paste (Ctrl+C/X/V) son ignorados — para habilitarlos,
398    /// usá [`Self::apply_key_with_clipboard`] pasando un backend.
399    pub fn apply_key(&mut self, event: &KeyEvent) -> ApplyResult {
400        self.apply_key_with_clipboard(event, &mut NullClipboard)
401    }
402
403    /// Aplica un evento de IME al editor — el camino por el que llegan los
404    /// acentos compuestos (dead keys), CJK y el emoji picker cuando la app
405    /// habilita `App::ime_allowed`. El flujo del IME es
406    /// `Enabled → Preedit* → Commit | Disabled`:
407    ///
408    /// - `Enabled`: el IME tomó el foco; no hay nada que insertar aún.
409    /// - `Preedit{text,..}`: texto en composición. Lo guardamos en
410    ///   [`Self::preedit`] para que el view lo pinte subrayado en el caret
411    ///   **sin** tocar el buffer. Un `Preedit` con `text` vacío cierra la
412    ///   composición sin confirmar.
413    /// - `Commit(text)`: texto confirmado. Limpiamos el preedit e
414    ///   insertamos `text` en todos los cursores, exactamente como si se
415    ///   hubiera tecleado (mismo camino de undo + parseo incremental).
416    /// - `Disabled`: el IME soltó el foco; descartamos cualquier
417    ///   composición pendiente.
418    ///
419    /// Devuelve [`ApplyResult::Changed`] sólo en el `Commit` no-vacío (lo
420    /// único que persiste); el resto es `CursorMoved` (hubo que
421    /// redibujar el preedit) o `Ignored`.
422    pub fn apply_ime_event(&mut self, event: &ImeEvent) -> ApplyResult {
423        match event {
424            ImeEvent::Enabled => ApplyResult::Ignored,
425            ImeEvent::Preedit { text, cursor } => {
426                let had = self.preedit.is_some();
427                if text.is_empty() {
428                    self.preedit = None;
429                    if had { ApplyResult::CursorMoved } else { ApplyResult::Ignored }
430                } else {
431                    self.preedit = Some(Preedit { text: text.clone(), cursor: *cursor });
432                    ApplyResult::CursorMoved
433                }
434            }
435            ImeEvent::Commit(text) => {
436                let had = self.preedit.take().is_some();
437                if text.is_empty() {
438                    return if had { ApplyResult::CursorMoved } else { ApplyResult::Ignored };
439                }
440                let text = text.clone();
441                let changed =
442                    self.apply_edit_all(|b, c, _opts| Some(replace_selection(b, c, &text)));
443                if changed {
444                    self.bump_edit_seq();
445                    ApplyResult::Changed
446                } else {
447                    ApplyResult::Ignored
448                }
449            }
450            ImeEvent::Disabled => {
451                let had = self.preedit.take().is_some();
452                if had { ApplyResult::CursorMoved } else { ApplyResult::Ignored }
453            }
454        }
455    }
456
457    /// Como [`Self::apply_key`] pero con backend de clipboard activo:
458    /// Ctrl+C copia la selección, Ctrl+X la corta, Ctrl+V pega lo que
459    /// haya en el clipboard.
460    pub fn apply_key_with_clipboard(
461        &mut self,
462        event: &KeyEvent,
463        clipboard: &mut dyn Clipboard,
464    ) -> ApplyResult {
465        // Antes de aplicar la tecla guardamos la línea del primary
466        // cursor: si la edición/movimiento termina parando en una
467        // guarda, la dirección del salto es la diferencia
468        // post-pre. Up → snap arriba, Down → snap abajo, click/edit
469        // en el mismo sitio → snap abajo por default.
470        let pre_line = self.cursor.caret.line as i32;
471        let r = self.apply_key_inner(event, clipboard);
472        if r.changed() {
473            self.bump_edit_seq();
474        }
475        if r.touched() && !self.guard_lines.is_empty() && !self.cursor.has_selection() {
476            // Si hay selección viva (shift+arrow / drag) no snappeamos:
477            // el usuario está seleccionando a través de la guarda y
478            // forzar el caret afuera rompería la selección.
479            let dir = (self.cursor.caret.line as i32 - pre_line).signum();
480            self.snap_off_guards(dir);
481        }
482        r
483    }
484
485    fn apply_key_inner(
486        &mut self,
487        event: &KeyEvent,
488        clipboard: &mut dyn Clipboard,
489    ) -> ApplyResult {
490        if event.state != KeyState::Pressed {
491            return ApplyResult::Ignored;
492        }
493        let extending = event.modifiers.shift;
494        let ctrl = event.modifiers.ctrl || event.modifiers.meta;
495        let alt = event.modifiers.alt;
496
497        // Esc colapsa multi-cursor (sin extras = ignorado, el caller
498        // decide qué más hacer — cancelar edit, cerrar find, etc.).
499        if matches!(&event.key, Key::Named(NamedKey::Escape)) {
500            if self.has_multi_cursor() {
501                self.collapse_to_primary();
502                return ApplyResult::CursorMoved;
503            }
504            return ApplyResult::Ignored;
505        }
506
507        // Multi-cursor: Ctrl+Alt+ArrowDown/Up agrega un cursor en la
508        // línea siguiente/anterior usando la misma desired_col. Esc del
509        // caller debería colapsar — no lo manejamos acá porque el caller
510        // puede querer usar Esc para otras cosas (cerrar find, cancelar
511        // edit). El caller chequea has_multi_cursor() antes.
512        if ctrl && alt {
513            match &event.key {
514                Key::Named(NamedKey::ArrowDown) => {
515                    let line = self.cursor.caret.line + 1;
516                    if line < self.buffer.len_lines() {
517                        self.add_cursor_at(line, self.cursor.desired_col);
518                        return ApplyResult::CursorMoved;
519                    }
520                    return ApplyResult::Ignored;
521                }
522                Key::Named(NamedKey::ArrowUp) => {
523                    if self.cursor.caret.line > 0 {
524                        self.add_cursor_at(self.cursor.caret.line - 1, self.cursor.desired_col);
525                        return ApplyResult::CursorMoved;
526                    }
527                    return ApplyResult::Ignored;
528                }
529                _ => {}
530            }
531        }
532
533        let page = self.options.page_size;
534        match &event.key {
535            // Movimiento
536            Key::Named(NamedKey::ArrowLeft) => {
537                if ctrl {
538                    self.apply_move_all(|b, c| c.move_word_left(b, extending));
539                } else {
540                    self.apply_move_all(|b, c| c.move_left(b, extending));
541                }
542                ApplyResult::CursorMoved
543            }
544            Key::Named(NamedKey::ArrowRight) => {
545                if ctrl {
546                    self.apply_move_all(|b, c| c.move_word_right(b, extending));
547                } else {
548                    self.apply_move_all(|b, c| c.move_right(b, extending));
549                }
550                ApplyResult::CursorMoved
551            }
552            Key::Named(NamedKey::ArrowUp) => {
553                self.apply_move_all(|b, c| c.move_up(b, extending));
554                ApplyResult::CursorMoved
555            }
556            Key::Named(NamedKey::ArrowDown) => {
557                self.apply_move_all(|b, c| c.move_down(b, extending));
558                ApplyResult::CursorMoved
559            }
560            Key::Named(NamedKey::Home) => {
561                if ctrl {
562                    self.apply_move_all(|b, c| c.move_doc_start(b, extending));
563                } else {
564                    self.apply_move_all(|b, c| c.move_home(b, extending));
565                }
566                ApplyResult::CursorMoved
567            }
568            Key::Named(NamedKey::End) => {
569                if ctrl {
570                    self.apply_move_all(|b, c| c.move_doc_end(b, extending));
571                } else {
572                    self.apply_move_all(|b, c| c.move_end(b, extending));
573                }
574                ApplyResult::CursorMoved
575            }
576            Key::Named(NamedKey::PageUp) => {
577                self.apply_move_all(|b, c| c.move_page_up(b, extending, page));
578                ApplyResult::CursorMoved
579            }
580            Key::Named(NamedKey::PageDown) => {
581                self.apply_move_all(|b, c| c.move_page_down(b, extending, page));
582                ApplyResult::CursorMoved
583            }
584
585            // Edición
586            Key::Named(NamedKey::Enter) => {
587                if self.options.single_line {
588                    return ApplyResult::Ignored;
589                }
590                self.apply_edit_all(|b, c, _opts| Some(insert_newline_auto_indent(b, c)));
591                ApplyResult::Changed
592            }
593            Key::Named(NamedKey::Backspace) => {
594                if self.apply_edit_all(|b, c, _opts| delete_backward(b, c)) {
595                    ApplyResult::Changed
596                } else {
597                    ApplyResult::Ignored
598                }
599            }
600            Key::Named(NamedKey::Delete) => {
601                if self.apply_edit_all(|b, c, _opts| delete_forward(b, c)) {
602                    ApplyResult::Changed
603                } else {
604                    ApplyResult::Ignored
605                }
606            }
607            Key::Named(NamedKey::Tab) => {
608                let any = if extending {
609                    self.apply_edit_all(|b, c, opts| {
610                        dedent(b, c, opts.tab_to_spaces, opts.indent_size)
611                    })
612                } else {
613                    self.apply_edit_all(|b, c, opts| {
614                        Some(indent_or_insert_tab(b, c, opts.tab_to_spaces, opts.indent_size))
615                    })
616                };
617                if any { ApplyResult::Changed } else { ApplyResult::Ignored }
618            }
619
620            // Clipboard
621            Key::Character(s) if ctrl && s.as_str().eq_ignore_ascii_case("c") => {
622                if let Some(text) = self.selected_text() {
623                    clipboard.set(&text);
624                    ApplyResult::CursorMoved
625                } else {
626                    ApplyResult::Ignored
627                }
628            }
629            Key::Character(s) if ctrl && s.as_str().eq_ignore_ascii_case("x") => {
630                if let Some(text) = self.selected_text() {
631                    clipboard.set(&text);
632                    let d = replace_selection(&mut self.buffer, &mut self.cursor, "");
633                    self.undo.push(d);
634                    ApplyResult::Changed
635                } else {
636                    ApplyResult::Ignored
637                }
638            }
639            Key::Character(s) if ctrl && s.as_str().eq_ignore_ascii_case("v") => {
640                let Some(text) = clipboard.get() else {
641                    return ApplyResult::Ignored;
642                };
643                if text.is_empty() {
644                    return ApplyResult::Ignored;
645                }
646                // En single-line, los `\n` del clipboard se aplanan.
647                let to_insert = if self.options.single_line {
648                    text.replace(['\n', '\r'], " ")
649                } else {
650                    text
651                };
652                let d = replace_selection(&mut self.buffer, &mut self.cursor, &to_insert);
653                self.undo.push(d);
654                ApplyResult::Changed
655            }
656
657            // Undo / Redo
658            Key::Character(s) if ctrl && s.as_str().eq_ignore_ascii_case("z") => {
659                let did = if extending {
660                    self.undo.redo(&mut self.buffer, &mut self.cursor)
661                } else {
662                    self.undo.undo(&mut self.buffer, &mut self.cursor)
663                };
664                if did { ApplyResult::Changed } else { ApplyResult::Ignored }
665            }
666            Key::Character(s) if ctrl && s.as_str().eq_ignore_ascii_case("y") => {
667                let did = self.undo.redo(&mut self.buffer, &mut self.cursor);
668                if did { ApplyResult::Changed } else { ApplyResult::Ignored }
669            }
670
671            // Inserción de chars imprimibles vía event.text (respeta IME +
672            // layouts no-US). Ignoramos cuando ctrl/meta están activos
673            // para no comernos Ctrl+S, Ctrl+C, etc. (eso lo hace el
674            // caller registrando shortcuts).
675            _ => {
676                if ctrl {
677                    return ApplyResult::Ignored;
678                }
679                let Some(text) = event.text.as_ref() else {
680                    return ApplyResult::Ignored;
681                };
682                if text.is_empty() || text.chars().any(|c| c.is_control()) {
683                    return ApplyResult::Ignored;
684                }
685                let text = text.clone();
686                self.apply_edit_all(|b, c, _opts| Some(replace_selection(b, c, &text)));
687                ApplyResult::Changed
688            }
689        }
690    }
691
692    // ----- Multi-cursor helpers -----
693
694    /// Aplica un movimiento (no edita el buffer) a todos los cursores:
695    /// primary + extras. Después dedupa para evitar cursores que terminan
696    /// en el mismo punto.
697    fn apply_move_all<F>(&mut self, mut f: F)
698    where
699        F: FnMut(&Buffer, &mut Cursor),
700    {
701        f(&self.buffer, &mut self.cursor);
702        for c in &mut self.extra_cursors {
703            f(&self.buffer, c);
704        }
705        self.dedupe_cursors();
706    }
707
708    /// Aplica una edición (que puede modificar el buffer) a todos los
709    /// cursores. Procesa en orden de offset descendente para que las
710    /// ediciones tempranas no desplacen las posiciones de las
711    /// posteriores. Devuelve `true` si al menos uno produjo un delta.
712    /// Cada delta también genera un `tree_sitter::InputEdit` que va a
713    /// `pending_input_edits` para alimentar el incremental parsing.
714    fn apply_edit_all<F>(&mut self, mut f: F) -> bool
715    where
716        F: FnMut(&mut Buffer, &mut Cursor, &EditorOptions) -> Option<crate::ops::EditDelta>,
717    {
718        let mut all: Vec<(Option<usize>, usize)> = Vec::with_capacity(1 + self.extra_cursors.len());
719        let p_off = self.buffer.pos_to_offset(self.cursor.caret.line, self.cursor.caret.col);
720        all.push((None, p_off));
721        for (i, c) in self.extra_cursors.iter().enumerate() {
722            let off = self.buffer.pos_to_offset(c.caret.line, c.caret.col);
723            all.push((Some(i), off));
724        }
725        all.sort_by_key(|(_, off)| std::cmp::Reverse(*off));
726
727        let opts = self.options;
728        let mut any = false;
729        for (which, _) in all {
730            let cursor: &mut Cursor = match which {
731                None => &mut self.cursor,
732                Some(i) => &mut self.extra_cursors[i],
733            };
734            // Pre-edit positions del start del delta — necesitamos las
735            // coordenadas BYTE del buffer ANTES de la edición.
736            let start_char = self.buffer.pos_to_offset(cursor.caret.line, cursor.caret.col);
737            // Pero si hay selección, el start real es el min de la sel.
738            let (sel_start, _) = cursor.selection_range(&self.buffer);
739            let start_char = start_char.min(sel_start);
740            let start_byte = self.buffer.char_to_byte(start_char);
741            let start_line = self.buffer.char_to_line(start_char);
742            let start_col_byte = start_byte - self.buffer.line_to_byte(start_line);
743            let pre_pt = tree_sitter::Point { row: start_line, column: start_col_byte };
744
745            if let Some(d) = f(&mut self.buffer, cursor, &opts) {
746                let edit = compute_input_edit(start_byte, pre_pt, &d);
747                self.pending_input_edits.borrow_mut().push(edit);
748                self.undo.push(d);
749                any = true;
750            }
751        }
752        self.dedupe_cursors();
753        any
754    }
755
756    /// Elimina cursores extras que están en la misma posición que el
757    /// primary o que otros extras (después de una edición pueden
758    /// converger).
759    fn dedupe_cursors(&mut self) {
760        let primary = self.cursor.caret;
761        let mut seen: Vec<Pos> = vec![primary];
762        self.extra_cursors.retain(|c| {
763            if seen.contains(&c.caret) {
764                false
765            } else {
766                seen.push(c.caret);
767                true
768            }
769        });
770    }
771}
772
773/// Si `cursor.caret.line` cae sobre una línea presente en `guards`,
774/// mueve el caret a la línea no-guarda más cercana siguiendo `dir`:
775///
776/// - `dir > 0` → busca primero abajo, luego arriba.
777/// - `dir < 0` → busca primero arriba, luego abajo.
778/// - `dir == 0` → equivalente a `dir > 0`.
779///
780/// Colapsa la selección y reposiciona el `col` clampeado al ancho de
781/// la línea destino. `guards` debe estar ordenado ascendente; el
782/// chequeo usa `binary_search`. Si TODAS las líneas son guardas, no
783/// puede hacer nada y el caret queda donde está.
784fn snap_cursor_off_guard(
785    cursor: &mut Cursor,
786    buffer: &Buffer,
787    guards: &[usize],
788    dir: i32,
789) {
790    let n = buffer.len_lines();
791    if n == 0 || guards.is_empty() {
792        return;
793    }
794    let line = cursor.caret.line.min(n - 1);
795    if guards.binary_search(&line).is_err() {
796        return;
797    }
798    // Orden de búsqueda: primero la dirección preferida, luego la opuesta.
799    let primary: i32 = if dir < 0 { -1 } else { 1 };
800    let secondary: i32 = -primary;
801    for d in [primary, secondary] {
802        let mut probe = line as i32 + d;
803        while probe >= 0 && (probe as usize) < n {
804            let p = probe as usize;
805            if guards.binary_search(&p).is_err() {
806                let col = cursor.desired_col.min(buffer.line_len_chars(p));
807                cursor.caret = Pos::new(p, col);
808                cursor.anchor = None;
809                return;
810            }
811            probe += d;
812        }
813    }
814    // Todas las líneas son guardas — no podemos hacer nada útil.
815}
816
817/// Convierte un `EditDelta` + posiciones pre-edit a un `InputEdit` de
818/// tree-sitter. tree-sitter trabaja en bytes y `Point { row, column_byte }`;
819/// el editor trabaja en chars (y col_byte para esto).
820///
821/// `start_byte` y `start_point` son las coords del inicio del delta
822/// ANTES del cambio (el caller las captura).
823fn compute_input_edit(
824    start_byte: usize,
825    start_point: tree_sitter::Point,
826    delta: &crate::ops::EditDelta,
827) -> tree_sitter::InputEdit {
828    let removed_bytes = delta.removed.len();
829    let inserted_bytes = delta.inserted.len();
830
831    let old_end_byte = start_byte + removed_bytes;
832    let new_end_byte = start_byte + inserted_bytes;
833
834    let old_end_point = advance_point(start_point, &delta.removed);
835    let new_end_point = advance_point(start_point, &delta.inserted);
836
837    tree_sitter::InputEdit {
838        start_byte,
839        old_end_byte,
840        new_end_byte,
841        start_position: start_point,
842        old_end_position: old_end_point,
843        new_end_position: new_end_point,
844    }
845}
846
847/// Avanza un Point por el contenido de `text`: cuenta `\n` para filas,
848/// bytes de la última línea para columna.
849fn advance_point(start: tree_sitter::Point, text: &str) -> tree_sitter::Point {
850    let newlines = text.bytes().filter(|b| *b == b'\n').count();
851    if newlines == 0 {
852        tree_sitter::Point {
853            row: start.row,
854            column: start.column + text.len(),
855        }
856    } else {
857        let after_last_nl = text.rsplit('\n').next().unwrap_or("").len();
858        tree_sitter::Point {
859            row: start.row + newlines,
860            column: after_last_nl,
861        }
862    }
863}
864
865/// Resultado de `apply_key`. El caller usa esto para decidir si
866/// rebuildear el view o ignorar.
867#[derive(Debug, Clone, Copy, PartialEq, Eq)]
868pub enum ApplyResult {
869    /// La tecla cambió el buffer (o sea, hay edición persistible).
870    Changed,
871    /// Sólo se movió el cursor — el view se redibuja, pero el `source`
872    /// del notebook no cambia.
873    CursorMoved,
874    /// La tecla no aplicaba al editor.
875    Ignored,
876}
877
878impl ApplyResult {
879    pub fn changed(self) -> bool {
880        matches!(self, ApplyResult::Changed)
881    }
882    pub fn touched(self) -> bool {
883        !matches!(self, ApplyResult::Ignored)
884    }
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890    use llimphi_ui::Modifiers;
891
892    fn ev(named: NamedKey, shift: bool, ctrl: bool) -> KeyEvent {
893        KeyEvent {
894            key: Key::Named(named),
895            state: KeyState::Pressed,
896            text: None,
897            modifiers: Modifiers { shift, ctrl, alt: false, meta: false },
898            repeat: false,
899        }
900    }
901    fn evtext(s: &str, shift: bool, ctrl: bool) -> KeyEvent {
902        KeyEvent {
903            key: Key::Character(s.into()),
904            state: KeyState::Pressed,
905            text: Some(s.to_owned()),
906            modifiers: Modifiers { shift, ctrl, alt: false, meta: false },
907            repeat: false,
908        }
909    }
910
911    #[test]
912    fn escribir_chars_inserta() {
913        let mut s = EditorState::new();
914        s.apply_key(&evtext("h", false, false));
915        s.apply_key(&evtext("i", false, false));
916        assert_eq!(s.text(), "hi");
917    }
918
919    #[test]
920    fn enter_con_indent_auto() {
921        let mut s = EditorState::new();
922        s.set_text("    hola");
923        s.cursor = Cursor::at(0, 8);
924        s.apply_key(&ev(NamedKey::Enter, false, false));
925        assert_eq!(s.text(), "    hola\n    ");
926    }
927
928    #[test]
929    fn enter_en_single_line_ignorado() {
930        let mut s = EditorState::with_options(EditorOptions {
931            single_line: true,
932            ..Default::default()
933        });
934        s.set_text("a");
935        s.cursor = Cursor::at(0, 1);
936        let r = s.apply_key(&ev(NamedKey::Enter, false, false));
937        assert_eq!(r, ApplyResult::Ignored);
938        assert_eq!(s.text(), "a");
939    }
940
941    #[test]
942    fn tab_inserta_indent() {
943        let mut s = EditorState::new();
944        s.apply_key(&ev(NamedKey::Tab, false, false));
945        assert_eq!(s.text(), "  "); // indent_size por defecto = 2
946    }
947
948    #[test]
949    fn shift_tab_dedenta() {
950        let mut s = EditorState::new();
951        s.set_text("    hola");
952        s.cursor = Cursor::at(0, 4);
953        s.apply_key(&ev(NamedKey::Tab, true, false));
954        // indent_size=2 → quita 2 espacios
955        assert_eq!(s.text(), "  hola");
956    }
957
958    #[test]
959    fn ctrl_z_y_ctrl_y_son_undo_redo() {
960        let mut s = EditorState::new();
961        s.apply_key(&evtext("a", false, false));
962        s.apply_key(&evtext("b", false, false));
963        assert_eq!(s.text(), "ab");
964        s.apply_key(&evtext("z", false, true));
965        assert_eq!(s.text(), "a");
966        s.apply_key(&evtext("y", false, true));
967        assert_eq!(s.text(), "ab");
968    }
969
970    #[test]
971    fn ctrl_shift_z_es_redo() {
972        let mut s = EditorState::new();
973        s.apply_key(&evtext("a", false, false));
974        s.apply_key(&evtext("z", false, true));
975        assert!(s.is_empty());
976        s.apply_key(&evtext("z", true, true));
977        assert_eq!(s.text(), "a");
978    }
979
980    #[test]
981    fn ctrl_arrow_left_salta_palabra() {
982        let mut s = EditorState::new();
983        s.set_text("hola mundo");
984        s.cursor = Cursor::at(0, 10);
985        s.apply_key(&ev(NamedKey::ArrowLeft, false, true));
986        assert_eq!(s.cursor.caret, Pos::new(0, 5)); // inicio de "mundo"
987        s.apply_key(&ev(NamedKey::ArrowLeft, false, true));
988        assert_eq!(s.cursor.caret, Pos::new(0, 0)); // inicio de "hola"
989    }
990
991    #[test]
992    fn shift_arrow_selecciona_y_chars_reemplazan() {
993        let mut s = EditorState::new();
994        s.set_text("abc");
995        s.cursor = Cursor::at(0, 0);
996        s.apply_key(&ev(NamedKey::ArrowRight, true, false));
997        s.apply_key(&ev(NamedKey::ArrowRight, true, false));
998        assert!(s.cursor.has_selection());
999        s.apply_key(&evtext("X", false, false));
1000        assert_eq!(s.text(), "Xc");
1001    }
1002
1003    #[test]
1004    fn ctrl_chars_se_ignoran_en_input_normal() {
1005        // Ctrl+S no debería insertar "s".
1006        let mut s = EditorState::new();
1007        let r = s.apply_key(&evtext("s", false, true));
1008        assert_eq!(r, ApplyResult::Ignored);
1009        assert!(s.is_empty());
1010    }
1011
1012    #[test]
1013    fn ctrl_c_copia_la_seleccion_al_clipboard() {
1014        use crate::clipboard::MemClipboard;
1015        let mut s = EditorState::new();
1016        s.set_text("hola mundo");
1017        s.cursor = Cursor {
1018            anchor: Some(Pos::new(0, 0)),
1019            caret: Pos::new(0, 4),
1020            desired_col: 4,
1021        };
1022        let mut clip = MemClipboard::new();
1023        let r = s.apply_key_with_clipboard(&evtext("c", false, true), &mut clip);
1024        assert_eq!(r, ApplyResult::CursorMoved);
1025        assert_eq!(clip.get().as_deref(), Some("hola"));
1026        // El buffer no cambia.
1027        assert_eq!(s.text(), "hola mundo");
1028    }
1029
1030    #[test]
1031    fn ctrl_x_corta_y_borra() {
1032        use crate::clipboard::MemClipboard;
1033        let mut s = EditorState::new();
1034        s.set_text("hola mundo");
1035        s.cursor = Cursor {
1036            anchor: Some(Pos::new(0, 0)),
1037            caret: Pos::new(0, 5),
1038            desired_col: 5,
1039        };
1040        let mut clip = MemClipboard::new();
1041        let r = s.apply_key_with_clipboard(&evtext("x", false, true), &mut clip);
1042        assert_eq!(r, ApplyResult::Changed);
1043        assert_eq!(clip.get().as_deref(), Some("hola "));
1044        assert_eq!(s.text(), "mundo");
1045    }
1046
1047    #[test]
1048    fn ctrl_v_pega_en_el_caret() {
1049        use crate::clipboard::MemClipboard;
1050        let mut s = EditorState::new();
1051        s.set_text("ab");
1052        s.cursor = Cursor::at(0, 1);
1053        let mut clip = MemClipboard::with("XYZ");
1054        s.apply_key_with_clipboard(&evtext("v", false, true), &mut clip);
1055        assert_eq!(s.text(), "aXYZb");
1056    }
1057
1058    #[test]
1059    fn ctrl_v_aplana_newlines_en_single_line() {
1060        use crate::clipboard::MemClipboard;
1061        let mut s = EditorState::with_options(EditorOptions {
1062            single_line: true,
1063            ..Default::default()
1064        });
1065        let mut clip = MemClipboard::with("a\nb\nc");
1066        s.apply_key_with_clipboard(&evtext("v", false, true), &mut clip);
1067        assert_eq!(s.text(), "a b c");
1068    }
1069
1070    #[test]
1071    fn ensure_caret_visible_scrollea_hacia_abajo() {
1072        let mut s = EditorState::new();
1073        let lines: String = (0..100).map(|n| format!("line {n}\n")).collect();
1074        s.set_text(&lines);
1075        s.cursor = Cursor::at(50, 0);
1076        s.ensure_caret_visible(20);
1077        // Caret en línea 50, visible_lines = 20 → scroll = 50 - 19 = 31.
1078        assert_eq!(s.scroll_offset, 31);
1079        // El caret debe estar dentro del viewport.
1080        assert!(s.cursor.caret.line >= s.scroll_offset);
1081        assert!(s.cursor.caret.line < s.scroll_offset + 20);
1082    }
1083
1084    #[test]
1085    fn ensure_caret_visible_scrollea_hacia_arriba() {
1086        let mut s = EditorState::new();
1087        let lines: String = (0..100).map(|n| format!("line {n}\n")).collect();
1088        s.set_text(&lines);
1089        s.scroll_offset = 50;
1090        s.cursor = Cursor::at(5, 0);
1091        s.ensure_caret_visible(20);
1092        assert_eq!(s.scroll_offset, 5);
1093    }
1094
1095    #[test]
1096    fn ensure_caret_visible_no_mueve_si_ya_visible() {
1097        let mut s = EditorState::new();
1098        let lines: String = (0..50).map(|n| format!("line {n}\n")).collect();
1099        s.set_text(&lines);
1100        s.scroll_offset = 10;
1101        s.cursor = Cursor::at(15, 0);
1102        s.ensure_caret_visible(20);
1103        assert_eq!(s.scroll_offset, 10);
1104    }
1105
1106    #[test]
1107    fn input_edits_se_acumulan_y_drenan_en_highlight() {
1108        use crate::highlight::Language;
1109        let mut s = EditorState::new();
1110        s.set_text("fn main() {}");
1111        // Set_text invalida pero NO pushea InputEdit (es replace_all).
1112        // Después de una edit normal, sí debería haber 1 pending.
1113        s.cursor = Cursor::at(0, 12);
1114        s.apply_key(&evtext("x", false, false));
1115        assert_eq!(s.pending_input_edits.borrow().len(), 1);
1116        // El parse drena los pending.
1117        let _ = s.highlighted_spans(Language::Rust);
1118        assert!(s.pending_input_edits.borrow().is_empty());
1119    }
1120
1121    #[test]
1122    fn input_edit_multilinea_calcula_rows_correctamente() {
1123        let mut s = EditorState::new();
1124        s.set_text("ab");
1125        s.cursor = Cursor::at(0, 2);
1126        s.apply_key(&ev(NamedKey::Enter, false, false));
1127        let edits = s.pending_input_edits.borrow().clone();
1128        assert_eq!(edits.len(), 1);
1129        let e = &edits[0];
1130        // Insertó "\n" (auto-indent vacío porque no había indent) →
1131        // new_end_position debe estar en row=1, col=0.
1132        assert_eq!(e.start_byte, 2);
1133        assert_eq!(e.new_end_position.row, 1);
1134        assert_eq!(e.new_end_position.column, 0);
1135    }
1136
1137    #[test]
1138    fn edit_seq_se_incrementa_solo_con_cambios() {
1139        let mut s = EditorState::new();
1140        let seq0 = s.edit_seq;
1141        s.apply_key(&ev(NamedKey::ArrowRight, false, false)); // CursorMoved
1142        assert_eq!(s.edit_seq, seq0, "movimiento no debería bumpear");
1143        s.apply_key(&evtext("a", false, false)); // Changed
1144        assert!(s.edit_seq > seq0);
1145    }
1146
1147    #[test]
1148    fn highlight_cache_reuse_cuando_seq_no_cambia() {
1149        use crate::highlight::Language;
1150        let mut s = EditorState::new();
1151        s.set_text("fn main() {}");
1152        let _ = s.highlighted_spans(Language::Rust);
1153        let seq_before = s.edit_seq;
1154        let _ = s.highlighted_spans(Language::Rust);
1155        // Sin edición → seq igual → cache hit (no asserción directa
1156        // posible sin mock, pero al menos el seq no cambia).
1157        assert_eq!(s.edit_seq, seq_before);
1158    }
1159
1160    #[test]
1161    fn multi_cursor_insert_aplica_a_todos() {
1162        let mut s = EditorState::new();
1163        s.set_text("ab\ncd\nef");
1164        // Cursor primary al final de "ab", extras al final de "cd" y "ef".
1165        s.cursor = Cursor::at(0, 2);
1166        s.add_cursor_at(1, 2);
1167        s.add_cursor_at(2, 2);
1168        s.apply_key(&evtext("!", false, false));
1169        assert_eq!(s.text(), "ab!\ncd!\nef!");
1170    }
1171
1172    #[test]
1173    fn multi_cursor_backspace_aplica_a_todos() {
1174        let mut s = EditorState::new();
1175        s.set_text("ab\ncd\nef");
1176        s.cursor = Cursor::at(0, 2);
1177        s.add_cursor_at(1, 2);
1178        s.add_cursor_at(2, 2);
1179        s.apply_key(&ev(NamedKey::Backspace, false, false));
1180        assert_eq!(s.text(), "a\nc\ne");
1181    }
1182
1183    #[test]
1184    fn dedupe_cursors_remueve_solapados() {
1185        let mut s = EditorState::new();
1186        s.set_text("abc");
1187        s.cursor = Cursor::at(0, 1);
1188        s.add_cursor_at(0, 1); // exacto primary → no se agrega
1189        s.add_cursor_at(0, 2);
1190        // El primer add no agregó nada; el segundo sí.
1191        assert_eq!(s.extra_cursors.len(), 1);
1192    }
1193
1194    #[test]
1195    fn collapse_to_primary_descarta_extras() {
1196        let mut s = EditorState::new();
1197        s.set_text("abc");
1198        s.cursor = Cursor::at(0, 0);
1199        s.add_cursor_at(0, 1);
1200        s.add_cursor_at(0, 2);
1201        assert!(s.has_multi_cursor());
1202        s.collapse_to_primary();
1203        assert!(!s.has_multi_cursor());
1204    }
1205
1206    #[test]
1207    fn highlight_cache_invalida_con_cambio_de_lenguaje() {
1208        use crate::highlight::Language;
1209        let mut s = EditorState::new();
1210        s.set_text("def f(): pass");
1211        let py = s.highlighted_spans(Language::Python);
1212        let rs = s.highlighted_spans(Language::Rust);
1213        // Distinto lenguaje → spans distintos (al menos el conteo o
1214        // las categorías difieren).
1215        assert!(py != rs || s.is_empty());
1216    }
1217
1218    #[test]
1219    fn scroll_by_clampea_a_rango_valido() {
1220        let mut s = EditorState::new();
1221        let lines: String = (0..10).map(|n| format!("line {n}\n")).collect();
1222        s.set_text(&lines);
1223        s.scroll_by(-100);
1224        assert_eq!(s.scroll_offset, 0);
1225        s.scroll_by(1000);
1226        assert!(s.scroll_offset < 11);
1227    }
1228
1229    fn estado_con_guardas(texto: &str, guards: Vec<usize>) -> EditorState {
1230        let mut s = EditorState::new();
1231        s.set_text(texto);
1232        s.set_guard_lines(guards);
1233        s
1234    }
1235
1236    #[test]
1237    fn guarda_set_caret_at_en_linea_vacia_salta_hacia_abajo() {
1238        // "abc\n\ndef" → líneas: "abc", "", "def". La 1 es guarda.
1239        let mut s = estado_con_guardas("abc\n\ndef", vec![1]);
1240        s.set_caret_at(1, 0);
1241        // El caret no puede quedar en la línea 1 (guarda) — salta a 2.
1242        assert_eq!(s.cursor.caret, Pos::new(2, 0));
1243    }
1244
1245    #[test]
1246    fn guarda_sin_linea_abajo_salta_arriba() {
1247        // Todas las líneas después de la 0 son guardas: el snap solo
1248        // puede ir hacia arriba.
1249        let mut s = estado_con_guardas("abc\n\n", vec![1, 2]);
1250        s.set_caret_at(1, 0);
1251        assert_eq!(s.cursor.caret.line, 0);
1252    }
1253
1254    #[test]
1255    fn guarda_arrow_down_atraviesa_la_separacion() {
1256        let mut s = estado_con_guardas("abc\n\ndef", vec![1]);
1257        s.cursor = Cursor::at(0, 0);
1258        // Down debería terminar en línea 2, no en la 1 (guarda).
1259        s.apply_key(&ev(NamedKey::ArrowDown, false, false));
1260        assert_eq!(s.cursor.caret.line, 2);
1261    }
1262
1263    #[test]
1264    fn guarda_arrow_up_atraviesa_la_separacion() {
1265        let mut s = estado_con_guardas("abc\n\ndef", vec![1]);
1266        s.cursor = Cursor::at(2, 1);
1267        s.apply_key(&ev(NamedKey::ArrowUp, false, false));
1268        assert_eq!(s.cursor.caret.line, 0);
1269    }
1270
1271    #[test]
1272    fn set_text_limpia_guardas() {
1273        // Tras `set_text`, las guardas anteriores ya no son válidas:
1274        // el caller las repuebla. La función las limpia.
1275        let mut s = estado_con_guardas("abc\n\ndef", vec![1]);
1276        assert_eq!(s.guard_lines, vec![1]);
1277        s.set_text("nuevo");
1278        assert!(s.guard_lines.is_empty());
1279    }
1280
1281    #[test]
1282    fn sin_guardas_set_caret_at_en_blank_se_queda() {
1283        // Con `guard_lines` vacío, comportamiento clásico: el caret
1284        // puede caer en cualquier línea sin snap.
1285        let mut s = EditorState::new();
1286        s.set_text("abc\n\ndef");
1287        s.set_caret_at(1, 0);
1288        assert_eq!(s.cursor.caret, Pos::new(1, 0));
1289    }
1290
1291    #[test]
1292    fn guarda_shift_arrow_extiende_seleccion_a_traves() {
1293        // Con selección viva atravesando la guarda, NO snapear: el
1294        // usuario está seleccionando texto multi-zona.
1295        let mut s = estado_con_guardas("abc\n\ndef", vec![1]);
1296        s.cursor = Cursor::at(0, 3);
1297        s.apply_key(&ev(NamedKey::ArrowDown, true, false));
1298        // El caret puede quedar en la línea 1 mientras hay selección
1299        // viva — el snap se inhibe.
1300        assert!(s.cursor.has_selection());
1301        assert_eq!(s.cursor.caret.line, 1);
1302    }
1303
1304    #[test]
1305    fn set_guard_lines_ordena_y_deduplica() {
1306        let mut s = EditorState::new();
1307        s.set_text("a\nb\nc\nd\ne");
1308        s.set_guard_lines(vec![3, 1, 1, 3]);
1309        assert_eq!(s.guard_lines, vec![1, 3]);
1310    }
1311
1312    #[test]
1313    fn guarda_no_es_solo_blank_puede_ser_cualquiera() {
1314        // Una guarda no tiene que ser una línea vacía — el widget no
1315        // mira el contenido, sólo el índice. Una línea con texto
1316        // marcada como guarda igual repele al caret.
1317        let mut s = EditorState::new();
1318        s.set_text("aaa\nbbb\nccc");
1319        s.set_guard_lines(vec![1]); // línea "bbb" es guarda
1320        s.set_caret_at(1, 0);
1321        assert!(s.cursor.caret.line != 1);
1322    }
1323
1324    #[test]
1325    fn ime_commit_inserta_como_tecleo() {
1326        // El caso español: el dead-key compone "á" y llega como Commit.
1327        let mut s = EditorState::new();
1328        s.apply_key(&evtext("c", false, false));
1329        let r = s.apply_ime_event(&ImeEvent::Commit("á".into()));
1330        assert_eq!(r, ApplyResult::Changed);
1331        assert_eq!(s.text(), "cá");
1332        assert!(s.preedit.is_none());
1333    }
1334
1335    #[test]
1336    fn ime_preedit_no_toca_el_buffer() {
1337        let mut s = EditorState::new();
1338        s.set_text("ab");
1339        s.cursor = Cursor::at(0, 2);
1340        let r = s.apply_ime_event(&ImeEvent::Preedit { text: "´".into(), cursor: None });
1341        assert_eq!(r, ApplyResult::CursorMoved);
1342        // El buffer sigue intacto; el preedit vive aparte.
1343        assert_eq!(s.text(), "ab");
1344        assert_eq!(s.preedit.as_ref().map(|p| p.text.as_str()), Some("´"));
1345        // El Commit reemplaza la composición e inserta el char final.
1346        s.apply_ime_event(&ImeEvent::Commit("é".into()));
1347        assert_eq!(s.text(), "abé");
1348        assert!(s.preedit.is_none());
1349    }
1350
1351    #[test]
1352    fn ime_preedit_vacio_y_disabled_cierran_la_composicion() {
1353        let mut s = EditorState::new();
1354        s.apply_ime_event(&ImeEvent::Preedit { text: "ㅎ".into(), cursor: None });
1355        assert!(s.preedit.is_some());
1356        // Un Preedit vacío cancela sin confirmar.
1357        let r = s.apply_ime_event(&ImeEvent::Preedit { text: String::new(), cursor: None });
1358        assert_eq!(r, ApplyResult::CursorMoved);
1359        assert!(s.preedit.is_none());
1360        // Disabled sobre algo en composición también limpia.
1361        s.apply_ime_event(&ImeEvent::Preedit { text: "ㅎ".into(), cursor: None });
1362        s.apply_ime_event(&ImeEvent::Disabled);
1363        assert!(s.preedit.is_none());
1364    }
1365
1366    #[test]
1367    fn ime_commit_reemplaza_seleccion() {
1368        let mut s = EditorState::new();
1369        s.set_text("abc");
1370        s.cursor = Cursor {
1371            anchor: Some(Pos::new(0, 0)),
1372            caret: Pos::new(0, 2),
1373            desired_col: 2,
1374        };
1375        s.apply_ime_event(&ImeEvent::Commit("ñ".into()));
1376        assert_eq!(s.text(), "ñc");
1377    }
1378
1379    #[test]
1380    fn ctrl_c_sin_seleccion_es_ignorado() {
1381        use crate::clipboard::MemClipboard;
1382        let mut s = EditorState::new();
1383        s.set_text("hola");
1384        s.cursor = Cursor::at(0, 4);
1385        let mut clip = MemClipboard::new();
1386        let r = s.apply_key_with_clipboard(&evtext("c", false, true), &mut clip);
1387        assert_eq!(r, ApplyResult::Ignored);
1388        assert!(clip.get().is_none());
1389    }
1390}