tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
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}