tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ dba855e446ca2bd4e7d146bdc168fb232cad15a4
widgets/text-editor/src/state.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
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}