tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definitions

⎇ main widgets/text-editor-core/src/cursor.rs Rust · 535 lines · 18.2 KB History Who touched what raw

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! Cursor + selección. Coordenadas en `(line, col)` (col en chars).
2//!
3//! Un [`Cursor`] tiene siempre una posición `caret` y opcionalmente un
4//! `anchor`: si están en distintos puntos, hay una **selección**.
5//! Movimiento sin `shift` colapsa la selección al caret nuevo;
6//! movimiento con `shift` extiende desde el `anchor`.
7
8
9use crate::buffer::Buffer;
10
11fn is_word(c: char) -> bool {
12    c.is_alphanumeric() || c == '_'
13}
14/// "Hueco" entre palabras para el salto por palabra (Ctrl+←/→): espacios **y
15/// puntuación** (`.` `,` `/` `(` …) cuentan igual, así el caret aterriza en el
16/// arranque de la próxima palabra en vez de frenar en un signo suelto — que es
17/// lo que molestaba. El `\n` NO es hueco: el salto por palabra se detiene en fin
18/// de línea (como antes).
19fn is_gap(c: char) -> bool {
20    c != '\n' && !is_word(c)
21}
22
23/// Clase de un char para la selección por doble-click: una palabra, un
24/// run de espacios, un salto de línea o puntuación suelta. Dos chars de
25/// la misma clase pertenecen a la misma "unidad" seleccionable.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum CharClass {
28    Word,
29    Space,
30    Newline,
31    Punct,
32}
33
34fn classify(c: char) -> CharClass {
35    if c == '\n' {
36        CharClass::Newline
37    } else if is_word(c) {
38        CharClass::Word
39    } else if c.is_whitespace() {
40        CharClass::Space
41    } else {
42        CharClass::Punct
43    }
44}
45
46/// Posición lógica del cursor — (línea, columna en chars).
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
48pub struct Pos {
49    pub line: usize,
50    pub col: usize,
51}
52
53impl Pos {
54    pub const fn new(line: usize, col: usize) -> Self {
55        Self { line, col }
56    }
57    pub const ORIGIN: Pos = Pos { line: 0, col: 0 };
58}
59
60/// Selección activa (anchor + caret). El rango efectivo es
61/// `(min(anchor,caret), max(anchor,caret))` en orden de offset.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct Selection {
64    pub anchor: Pos,
65    pub caret: Pos,
66}
67
68impl Selection {
69    pub fn new(anchor: Pos, caret: Pos) -> Self {
70        Self { anchor, caret }
71    }
72    pub fn is_empty(&self) -> bool {
73        self.anchor == self.caret
74    }
75}
76
77/// Cursor: caret + (opcional) anchor cuando hay selección.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct Cursor {
80    pub caret: Pos,
81    pub anchor: Option<Pos>,
82    /// Columna "deseada" — preserva la posición horizontal al saltar
83    /// entre líneas de distinto largo. Se setea al mover horizontal
84    /// y se respeta al mover vertical.
85    pub desired_col: usize,
86}
87
88impl Default for Cursor {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl Cursor {
95    pub fn new() -> Self {
96        Self { caret: Pos::ORIGIN, anchor: None, desired_col: 0 }
97    }
98
99    pub fn at(line: usize, col: usize) -> Self {
100        Self { caret: Pos::new(line, col), anchor: None, desired_col: col }
101    }
102
103    pub fn selection(&self) -> Option<Selection> {
104        self.anchor.map(|a| Selection::new(a, self.caret))
105    }
106
107    pub fn has_selection(&self) -> bool {
108        self.anchor.map_or(false, |a| a != self.caret)
109    }
110
111    /// Rango efectivo `(start, end)` en `char_offset` global. Si no hay
112    /// selección, ambos son el caret.
113    pub fn selection_range(&self, buf: &Buffer) -> (usize, usize) {
114        let caret_off = buf.pos_to_offset(self.caret.line, self.caret.col);
115        match self.anchor {
116            None => (caret_off, caret_off),
117            Some(a) => {
118                let anchor_off = buf.pos_to_offset(a.line, a.col);
119                if anchor_off <= caret_off {
120                    (anchor_off, caret_off)
121                } else {
122                    (caret_off, anchor_off)
123                }
124            }
125        }
126    }
127
128    /// Colapsa la selección dejando el caret donde está.
129    pub fn collapse(&mut self) {
130        self.anchor = None;
131    }
132
133    /// Asegura que `anchor = caret` si `extending` es true y no había
134    /// anchor; si es false, colapsa.
135    pub fn set_extending(&mut self, extending: bool) {
136        match (extending, self.anchor) {
137            (true, None) => self.anchor = Some(self.caret),
138            (true, Some(_)) => {}
139            (false, _) => self.anchor = None,
140        }
141    }
142
143    // ----- Movimiento por chars -----
144
145    pub fn move_left(&mut self, buf: &Buffer, extending: bool) {
146        self.set_extending(extending);
147        if self.caret.col > 0 {
148            self.caret.col -= 1;
149        } else if self.caret.line > 0 {
150            self.caret.line -= 1;
151            self.caret.col = buf.line_len_chars(self.caret.line);
152        }
153        self.desired_col = self.caret.col;
154    }
155
156    pub fn move_right(&mut self, buf: &Buffer, extending: bool) {
157        self.set_extending(extending);
158        let line_len = buf.line_len_chars(self.caret.line);
159        if self.caret.col < line_len {
160            self.caret.col += 1;
161        } else if self.caret.line + 1 < buf.len_lines() {
162            self.caret.line += 1;
163            self.caret.col = 0;
164        }
165        self.desired_col = self.caret.col;
166    }
167
168    pub fn move_up(&mut self, buf: &Buffer, extending: bool) {
169        self.set_extending(extending);
170        if self.caret.line == 0 {
171            self.caret.col = 0;
172        } else {
173            self.caret.line -= 1;
174            self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line));
175        }
176    }
177
178    pub fn move_down(&mut self, buf: &Buffer, extending: bool) {
179        self.set_extending(extending);
180        if self.caret.line + 1 >= buf.len_lines() {
181            self.caret.col = buf.line_len_chars(self.caret.line);
182        } else {
183            self.caret.line += 1;
184            self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line));
185        }
186    }
187
188    /// Home "inteligente": el primer Home lleva el caret al primer carácter
189    /// no-blanco de la línea (el inicio real del texto, salteando la
190    /// indentación); un segundo Home —ya parado ahí— va a la columna 0. Es
191    /// el comportamiento de VS Code / Sublime / la mayoría de los editores.
192    pub fn move_home(&mut self, buf: &Buffer, extending: bool) {
193        self.set_extending(extending);
194        let line = buf.line(self.caret.line);
195        let indent = line
196            .chars()
197            .take_while(|c| c.is_whitespace() && *c != '\n')
198            .count()
199            .min(buf.line_len_chars(self.caret.line));
200        // Si ya estamos en el primer no-blanco, el toggle va a col 0; si no
201        // (incluida col 0), va al primer no-blanco.
202        let target = if self.caret.col == indent { 0 } else { indent };
203        self.caret.col = target;
204        self.desired_col = target;
205    }
206
207    pub fn move_end(&mut self, buf: &Buffer, extending: bool) {
208        self.set_extending(extending);
209        self.caret.col = buf.line_len_chars(self.caret.line);
210        self.desired_col = self.caret.col;
211    }
212
213    pub fn move_page_up(&mut self, buf: &Buffer, extending: bool, page: usize) {
214        self.set_extending(extending);
215        self.caret.line = self.caret.line.saturating_sub(page);
216        self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line));
217    }
218
219    pub fn move_page_down(&mut self, buf: &Buffer, extending: bool, page: usize) {
220        self.set_extending(extending);
221        self.caret.line = (self.caret.line + page).min(buf.len_lines().saturating_sub(1));
222        self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line));
223    }
224
225    pub fn move_doc_start(&mut self, _buf: &Buffer, extending: bool) {
226        self.set_extending(extending);
227        self.caret = Pos::ORIGIN;
228        self.desired_col = 0;
229    }
230
231    pub fn move_doc_end(&mut self, buf: &Buffer, extending: bool) {
232        self.set_extending(extending);
233        let last_line = buf.len_lines().saturating_sub(1);
234        self.caret = Pos::new(last_line, buf.line_len_chars(last_line));
235        self.desired_col = self.caret.col;
236    }
237
238    // ----- Word movement -----
239
240    /// Movimiento por palabra a la izquierda — salta el hueco (espacios +
241    /// puntuación), después los caracteres de palabra (alfanumérico + `_`). Se
242    /// detiene en fin de línea (`\n` no es hueco).
243    pub fn move_word_left(&mut self, buf: &Buffer, extending: bool) {
244        self.set_extending(extending);
245        let mut off = buf.pos_to_offset(self.caret.line, self.caret.col);
246        while off > 0 && buf.char_at(off - 1).map_or(false, is_gap) {
247            off -= 1;
248        }
249        while off > 0 && buf.char_at(off - 1).map_or(false, is_word) {
250            off -= 1;
251        }
252        let (l, c) = buf.offset_to_pos(off);
253        self.caret = Pos::new(l, c);
254        self.desired_col = c;
255    }
256
257    pub fn move_word_right(&mut self, buf: &Buffer, extending: bool) {
258        self.set_extending(extending);
259        let len = buf.len_chars();
260        let mut off = buf.pos_to_offset(self.caret.line, self.caret.col);
261        while off < len && buf.char_at(off).map_or(false, is_word) {
262            off += 1;
263        }
264        while off < len && buf.char_at(off).map_or(false, is_gap) {
265            off += 1;
266        }
267        let (l, c) = buf.offset_to_pos(off);
268        self.caret = Pos::new(l, c);
269        self.desired_col = c;
270    }
271
272    // ----- Selección por palabra / párrafo (doble / triple click) -----
273
274    /// Selecciona la "palabra" bajo `pos` (doble-click): ancla al inicio y
275    /// caret al fin del run de chars de la misma clase que `pos`. Sobre una
276    /// palabra toma el run alfanumérico; sobre whitespace, el run de
277    /// espacios; sobre puntuación, el run de puntuación. Si el click cae
278    /// justo después de una palabra, prefiere la palabra. No-op de
279    /// selección (sólo caret) si la línea está vacía o estamos al final.
280    pub fn select_word(&mut self, buf: &Buffer, pos: Pos) {
281        let line = pos.line.min(buf.len_lines().saturating_sub(1));
282        let col = pos.col.min(buf.line_len_chars(line));
283        let off = buf.pos_to_offset(line, col);
284        let len = buf.len_chars();
285        let class_at = |o: usize| buf.char_at(o).map(classify);
286
287        // Clase de referencia: el char en `off`; pero si ahí hay algo que no
288        // es palabra y el char anterior sí lo es, preferí la palabra (click
289        // pegado al borde derecho de una palabra).
290        let class = match class_at(off) {
291            Some(CharClass::Word) => Some(CharClass::Word),
292            other => {
293                if off > 0 && class_at(off - 1) == Some(CharClass::Word) {
294                    Some(CharClass::Word)
295                } else {
296                    other.or_else(|| off.checked_sub(1).and_then(class_at))
297                }
298            }
299        };
300        let Some(class) = class else {
301            self.set_caret(buf, Pos::new(line, col));
302            return;
303        };
304        if class == CharClass::Newline {
305            self.set_caret(buf, Pos::new(line, col));
306            return;
307        }
308        let mut start = off;
309        while start > 0 && class_at(start - 1) == Some(class) {
310            start -= 1;
311        }
312        let mut end = off;
313        while end < len && class_at(end) == Some(class) {
314            end += 1;
315        }
316        if start == end {
317            // `off` quedó en el borde derecho del run: incluí el char previo.
318            start = off.saturating_sub(1);
319        }
320        let (sl, sc) = buf.offset_to_pos(start);
321        let (el, ec) = buf.offset_to_pos(end);
322        self.anchor = Some(Pos::new(sl, sc));
323        self.caret = Pos::new(el, ec);
324        self.desired_col = ec;
325    }
326
327    /// Selecciona el párrafo que contiene `pos` (triple-click): bloque de
328    /// líneas no-vacías consecutivas, delimitado por líneas en blanco (las
329    /// que separan átomos con `\n\n`). Ancla al inicio de la primera línea,
330    /// caret al fin de la última. Sobre una línea separadora, selecciona
331    /// sólo esa línea.
332    pub fn select_paragraph(&mut self, buf: &Buffer, pos: Pos) {
333        let n = buf.len_lines();
334        if n == 0 {
335            return;
336        }
337        let line = pos.line.min(n - 1);
338        let is_blank = |l: usize| buf.line(l).trim().is_empty();
339        if is_blank(line) {
340            self.anchor = Some(Pos::new(line, 0));
341            self.caret = Pos::new(line, buf.line_len_chars(line));
342            self.desired_col = self.caret.col;
343            return;
344        }
345        let mut start = line;
346        while start > 0 && !is_blank(start - 1) {
347            start -= 1;
348        }
349        let mut end = line;
350        while end + 1 < n && !is_blank(end + 1) {
351            end += 1;
352        }
353        self.anchor = Some(Pos::new(start, 0));
354        self.caret = Pos::new(end, buf.line_len_chars(end));
355        self.desired_col = self.caret.col;
356    }
357
358    // ----- Setters -----
359
360    pub fn set_caret(&mut self, buf: &Buffer, pos: Pos) {
361        let line = pos.line.min(buf.len_lines().saturating_sub(1));
362        let col = pos.col.min(buf.line_len_chars(line));
363        self.caret = Pos::new(line, col);
364        self.desired_col = col;
365        self.anchor = None;
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    fn buf() -> Buffer {
374        Buffer::from_str("hola\nmundo\nfin")
375    }
376
377    #[test]
378    fn cursor_new_is_origin() {
379        let c = Cursor::new();
380        assert_eq!(c.caret, Pos::ORIGIN);
381        assert!(!c.has_selection());
382    }
383
384    #[test]
385    fn move_right_atraviesa_lineas() {
386        let b = buf();
387        let mut c = Cursor::at(0, 4); // fin de "hola"
388        c.move_right(&b, false);
389        assert_eq!(c.caret, Pos::new(1, 0)); // inicio de "mundo"
390    }
391
392    #[test]
393    fn move_left_retrocede_a_linea_anterior() {
394        let b = buf();
395        let mut c = Cursor::at(1, 0);
396        c.move_left(&b, false);
397        assert_eq!(c.caret, Pos::new(0, 4));
398    }
399
400    #[test]
401    fn move_up_preserva_desired_col() {
402        let b = Buffer::from_str("abcdefgh\nxy\nlmnop");
403        let mut c = Cursor::at(0, 7);
404        c.move_down(&b, false);
405        // "xy" sólo tiene 2 chars; el cursor se pega a col=2
406        assert_eq!(c.caret, Pos::new(1, 2));
407        // pero al bajar de nuevo, el desired (7) reanima.
408        c.move_down(&b, false);
409        assert_eq!(c.caret, Pos::new(2, 5)); // "lmnop" tiene 5
410    }
411
412    #[test]
413    fn shift_arrow_inicia_seleccion() {
414        let b = buf();
415        let mut c = Cursor::at(0, 0);
416        c.move_right(&b, true);
417        c.move_right(&b, true);
418        assert!(c.has_selection());
419        let (s, e) = c.selection_range(&b);
420        assert_eq!((s, e), (0, 2));
421    }
422
423    #[test]
424    fn arrow_sin_shift_colapsa() {
425        let b = buf();
426        let mut c = Cursor::at(0, 0);
427        c.move_right(&b, true);
428        c.move_right(&b, true);
429        c.move_right(&b, false);
430        assert!(!c.has_selection());
431    }
432
433    #[test]
434    fn home_end_son_locales_a_la_linea() {
435        let b = buf();
436        let mut c = Cursor::at(1, 2);
437        c.move_home(&b, false);
438        assert_eq!(c.caret, Pos::new(1, 0));
439        c.move_end(&b, false);
440        assert_eq!(c.caret, Pos::new(1, 5));
441    }
442
443    #[test]
444    fn doc_start_y_end() {
445        let b = buf();
446        let mut c = Cursor::at(1, 2);
447        c.move_doc_end(&b, false);
448        assert_eq!(c.caret, Pos::new(2, 3));
449        c.move_doc_start(&b, false);
450        assert_eq!(c.caret, Pos::ORIGIN);
451    }
452
453    #[test]
454    fn salto_de_palabra_no_frena_en_puntuacion() {
455        // "foo.bar baz" — Ctrl+→ NO debe frenar en el punto: salta de palabra
456        // a palabra (foo → bar → baz), tratando el `.` como hueco.
457        let b = Buffer::from_str("foo.bar baz");
458        let mut c = Cursor::new(); // (0,0), antes de "foo"
459        c.move_word_right(&b, false);
460        assert_eq!(c.caret, Pos::new(0, 4), "esperaba el arranque de 'bar', no el punto (col 3)");
461        c.move_word_right(&b, false);
462        assert_eq!(c.caret, Pos::new(0, 8), "esperaba el arranque de 'baz'");
463    }
464
465    #[test]
466    fn salto_de_palabra_izquierda_tambien_cruza_puntuacion() {
467        let b = Buffer::from_str("hola, mundo");
468        let mut c = Cursor::at(0, 11); // fin de "mundo"
469        c.move_word_left(&b, false);
470        assert_eq!(c.caret, Pos::new(0, 6), "esperaba el arranque de 'mundo'");
471        c.move_word_left(&b, false);
472        // Cruza la coma y el espacio sin frenar en la coma (col 4).
473        assert_eq!(c.caret, Pos::new(0, 0), "esperaba el arranque de 'hola'");
474    }
475
476    #[test]
477    fn salto_de_palabra_se_detiene_en_fin_de_linea() {
478        // El `\n` NO es hueco: un salto por palabra no cruza el renglón.
479        let b = Buffer::from_str("uno\ndos");
480        let mut c = Cursor::new();
481        c.move_word_right(&b, false);
482        assert_eq!(c.caret, Pos::new(0, 3), "se queda en fin de la línea 0, no salta a 'dos'");
483    }
484
485    #[test]
486    fn select_word_toma_la_palabra_completa() {
487        let b = Buffer::from_str("hola mundo cruel");
488        let mut c = Cursor::new();
489        // click en medio de "mundo" (col 7).
490        c.select_word(&b, Pos::new(0, 7));
491        let (s, e) = c.selection_range(&b);
492        assert_eq!(b.slice(s, e), "mundo");
493    }
494
495    #[test]
496    fn select_word_borde_derecho_prefiere_palabra() {
497        let b = Buffer::from_str("hola mundo");
498        let mut c = Cursor::new();
499        // click justo después de la "a" de "hola" (col 4 = el espacio).
500        c.select_word(&b, Pos::new(0, 4));
501        let (s, e) = c.selection_range(&b);
502        assert_eq!(b.slice(s, e), "hola");
503    }
504
505    #[test]
506    fn select_paragraph_entre_lineas_en_blanco() {
507        let b = Buffer::from_str("uno\ndos\n\ntres\ncuatro\n\ncinco");
508        let mut c = Cursor::new();
509        // click en "tres" (línea 3) → párrafo "tres\ncuatro".
510        c.select_paragraph(&b, Pos::new(3, 1));
511        let (s, e) = c.selection_range(&b);
512        assert_eq!(b.slice(s, e), "tres\ncuatro");
513    }
514
515    #[test]
516    fn smart_home_alterna_indent_y_col0() {
517        let b = Buffer::from_str("    hola mundo");
518        let mut c = Cursor::at(0, 9); // en medio del texto
519        c.move_home(&b, false);
520        assert_eq!(c.caret.col, 4); // primer no-blanco
521        c.move_home(&b, false);
522        assert_eq!(c.caret.col, 0); // segundo Home → col 0
523        c.move_home(&b, false);
524        assert_eq!(c.caret.col, 4); // y vuelve al no-blanco
525    }
526
527    #[test]
528    fn select_paragraph_primer_bloque() {
529        let b = Buffer::from_str("uno\ndos\n\ntres");
530        let mut c = Cursor::new();
531        c.select_paragraph(&b, Pos::new(0, 0));
532        let (s, e) = c.selection_range(&b);
533        assert_eq!(b.slice(s, e), "uno\ndos");
534    }
535}