tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
1//! Buffer del editor — fachada fina con las conversiones de coordenadas
2//! que el resto del crate usa, sobre UNA de dos espaldas del mismo
3//! contrato:
4//!
5//! - **ropey** (feature `ropey`, default): rope de verdad, O(log n) —
6//! el escritorio, archivos grandes.
7//! - **la soga** (sin la feature): `String` + índice de líneas, `no_std`
8//! + alloc, O(n) por edición — la jaula WASM de wawa, donde lo que se
9//! edita cabe en memoria y no hay `std`.
10//!
11//! La semántica es idéntica por contrato (la de `Rope::lines()`), y los
12//! tests de este módulo corren contra la espalda que esté puesta:
13//! `cargo test -p llimphi-widget-text-editor-core` prueba ropey,
14//! `cargo test -p llimphi-widget-text-editor-core --no-default-features`
15//! prueba la soga.
16//!
17//! Coordenadas:
18//! - `char_offset`: índice de carácter (no byte) en el buffer entero.
19//! - `(line, col)`: línea (0-based) + columna en chars dentro de esa línea.
20//!
21//! Convenciones:
22//! - Las líneas son las que define `Rope::lines()` — un `\n` separa
23//! líneas; la última línea puede o no terminar en `\n` (en cuyo caso
24//! hay una línea vacía extra después).
25//! - `col` cuenta chars, no graphemes ni bytes. Para CJK ancho doble
26//! el render decidirá el ancho visual; el cursor avanza en chars.
27
28use alloc::string::{String, ToString};
29use alloc::vec::Vec;
30
31// ---------------------------------------------------------------------------
32// La espalda ropey (escritorio).
33// ---------------------------------------------------------------------------
34
35#[cfg(feature = "ropey")]
36mod nucleo {
37 use super::{String, ToString};
38 use ropey::Rope;
39
40 #[derive(Debug, Clone)]
41 pub(super) struct Nucleo {
42 rope: Rope,
43 }
44
45 impl Nucleo {
46 pub fn new() -> Self {
47 Self { rope: Rope::new() }
48 }
49
50 pub fn from_str(s: &str) -> Self {
51 Self { rope: Rope::from_str(s) }
52 }
53
54 pub fn text(&self) -> String {
55 self.rope.to_string()
56 }
57
58 pub fn len_chars(&self) -> usize {
59 self.rope.len_chars()
60 }
61
62 pub fn len_lines(&self) -> usize {
63 self.rope.len_lines()
64 }
65
66 /// La línea `n` con su `\n` terminal si no es la última.
67 /// Precondición del caller: `n < len_lines()`.
68 pub fn line_string(&self, n: usize) -> String {
69 self.rope.line(n).to_string()
70 }
71
72 /// Chars de la línea `n` SIN contar el `\n` terminal.
73 /// Precondición del caller: `n < len_lines()`.
74 pub fn line_len_chars(&self, n: usize) -> usize {
75 let line = self.rope.line(n);
76 let mut len = line.len_chars();
77 if len > 0 && line.char(len - 1) == '\n' {
78 len -= 1;
79 }
80 len
81 }
82
83 /// El largo de **todas** las líneas, de un solo recorrido.
84 ///
85 /// No es `(0..n).map(line_len_chars)`: cada `rope.line(n)` es una
86 /// búsqueda `O(log n)` en el árbol, y el iterador secuencial no. Con
87 /// 100.000 líneas la diferencia es de decenas de milisegundos, que es
88 /// justo el orden en el que un rearmado por tecla se siente.
89 pub fn line_lens(&self) -> Vec<usize> {
90 self.rope
91 .lines()
92 .map(|l| {
93 let n = l.len_chars();
94 if n > 0 && l.char(n - 1) == '\n' { n - 1 } else { n }
95 })
96 .collect()
97 }
98
99 /// Precondición del caller: `off <= len_chars()`.
100 pub fn char_to_line(&self, off: usize) -> usize {
101 self.rope.char_to_line(off)
102 }
103
104 /// Precondición del caller: `n <= len_lines()`.
105 pub fn line_to_char(&self, n: usize) -> usize {
106 self.rope.line_to_char(n)
107 }
108
109 /// Precondición del caller: `off <= len_chars()`.
110 pub fn char_to_byte(&self, off: usize) -> usize {
111 self.rope.char_to_byte(off)
112 }
113
114 /// Precondición del caller: `n <= len_lines()`.
115 pub fn line_to_byte(&self, n: usize) -> usize {
116 self.rope.line_to_byte(n)
117 }
118
119 /// Precondición del caller: `off < len_chars()`.
120 pub fn char_at(&self, off: usize) -> char {
121 self.rope.char(off)
122 }
123
124 /// Precondición del caller: `s <= e <= len_chars()`.
125 pub fn slice(&self, s: usize, e: usize) -> String {
126 self.rope.slice(s..e).to_string()
127 }
128
129 /// Precondición del caller: `off <= len_chars()`.
130 pub fn insert(&mut self, off: usize, s: &str) {
131 self.rope.insert(off, s);
132 }
133
134 /// Precondición del caller: `s <= e <= len_chars()`.
135 pub fn remove(&mut self, s: usize, e: usize) {
136 self.rope.remove(s..e);
137 }
138 }
139}
140
141// ---------------------------------------------------------------------------
142// La soga (no_std): String + índice de arranques de línea.
143//
144// El índice guarda, por línea, su `(char_start, byte_start)`. Se
145// reconstruye entero en cada edición — O(n), y está bien: la soga existe
146// para editar lo que cabe en una jaula (notas, config, celdas), no para
147// abrir un log de 200 MB. Quien necesite eso está en el escritorio, con
148// ropey.
149// ---------------------------------------------------------------------------
150
151#[cfg(not(feature = "ropey"))]
152mod nucleo {
153 use super::{String, ToString, Vec};
154
155 #[derive(Debug, Clone)]
156 pub(super) struct Nucleo {
157 texto: String,
158 /// `(char_start, byte_start)` de cada línea. Nunca vacío: el
159 /// buffer vacío tiene una línea (la 0, vacía) — igual que ropey.
160 lineas: Vec<(usize, usize)>,
161 n_chars: usize,
162 }
163
164 impl Nucleo {
165 pub fn new() -> Self {
166 Self::from_str("")
167 }
168
169 pub fn from_str(s: &str) -> Self {
170 let mut n = Self {
171 texto: s.to_string(),
172 lineas: Vec::new(),
173 n_chars: 0,
174 };
175 n.reindexar();
176 n
177 }
178
179 /// Reconstruye el índice de líneas y el conteo de chars. La única
180 /// función O(n) sobre el texto entero; TODO lo demás la consulta.
181 fn reindexar(&mut self) {
182 self.lineas.clear();
183 self.lineas.push((0, 0));
184 let mut ci = 0usize;
185 for (bi, c) in self.texto.char_indices() {
186 ci += 1;
187 if c == '\n' {
188 self.lineas.push((ci, bi + 1));
189 }
190 }
191 self.n_chars = ci;
192 }
193
194 pub fn text(&self) -> String {
195 self.texto.clone()
196 }
197
198 pub fn len_chars(&self) -> usize {
199 self.n_chars
200 }
201
202 pub fn len_lines(&self) -> usize {
203 self.lineas.len()
204 }
205
206 /// Rango de bytes `[inicio..fin)` de la línea `n` (con su `\n`).
207 fn bytes_de_linea(&self, n: usize) -> (usize, usize) {
208 let ini = self.lineas[n].1;
209 let fin = match self.lineas.get(n + 1) {
210 Some(&(_, b)) => b,
211 None => self.texto.len(),
212 };
213 (ini, fin)
214 }
215
216 pub fn line_string(&self, n: usize) -> String {
217 let (ini, fin) = self.bytes_de_linea(n);
218 self.texto[ini..fin].to_string()
219 }
220
221 pub fn line_len_chars(&self, n: usize) -> usize {
222 let fin_chars = match self.lineas.get(n + 1) {
223 // La línea siguiente arranca después del `\n` de ésta.
224 Some(&(c, _)) => c - 1,
225 None => self.n_chars,
226 };
227 fin_chars - self.lineas[n].0
228 }
229
230 /// El largo de todas las líneas. Acá el índice ya está armado, así
231 /// que es una resta por línea.
232 pub fn line_lens(&self) -> Vec<usize> {
233 (0..self.lineas.len()).map(|n| self.line_len_chars(n)).collect()
234 }
235
236 pub fn char_to_line(&self, off: usize) -> usize {
237 // Última línea cuyo char_start <= off. `partition_point`
238 // devuelve el primer índice donde el predicado falla.
239 self.lineas.partition_point(|&(c, _)| c <= off) - 1
240 }
241
242 pub fn line_to_char(&self, n: usize) -> usize {
243 match self.lineas.get(n) {
244 Some(&(c, _)) => c,
245 // ropey permite `line_to_char(len_lines()) == len_chars()`.
246 None => self.n_chars,
247 }
248 }
249
250 pub fn char_to_byte(&self, off: usize) -> usize {
251 let linea = self.char_to_line(off);
252 let (cs, bs) = self.lineas[linea];
253 // Caminar chars desde el arranque de la línea — corto: una línea.
254 let mut b = bs;
255 let mut c = cs;
256 let mut iter = self.texto[bs..].chars();
257 while c < off {
258 // `off <= len_chars` garantiza que el char existe.
259 let ch = iter.next().expect("char_to_byte: offset fuera del texto");
260 b += ch.len_utf8();
261 c += 1;
262 }
263 b
264 }
265
266 pub fn line_to_byte(&self, n: usize) -> usize {
267 match self.lineas.get(n) {
268 Some(&(_, b)) => b,
269 None => self.texto.len(),
270 }
271 }
272
273 pub fn char_at(&self, off: usize) -> char {
274 let b = self.char_to_byte(off);
275 self.texto[b..]
276 .chars()
277 .next()
278 .expect("char_at: offset fuera del texto")
279 }
280
281 pub fn slice(&self, s: usize, e: usize) -> String {
282 let bs = self.char_to_byte(s);
283 let be = self.char_to_byte(e);
284 self.texto[bs..be].to_string()
285 }
286
287 pub fn insert(&mut self, off: usize, s: &str) {
288 let b = self.char_to_byte(off);
289 self.texto.insert_str(b, s);
290 self.reindexar();
291 }
292
293 pub fn remove(&mut self, s: usize, e: usize) {
294 let bs = self.char_to_byte(s);
295 let be = self.char_to_byte(e);
296 self.texto.replace_range(bs..be, "");
297 self.reindexar();
298 }
299 }
300}
301
302use nucleo::Nucleo;
303
304#[derive(Debug, Clone)]
305pub struct Buffer {
306 rope: Nucleo,
307}
308
309impl Default for Buffer {
310 fn default() -> Self {
311 Self::new()
312 }
313}
314
315impl Buffer {
316 pub fn new() -> Self {
317 Self { rope: Nucleo::new() }
318 }
319
320 pub fn from_str(s: &str) -> Self {
321 Self { rope: Nucleo::from_str(s) }
322 }
323
324 pub fn text(&self) -> String {
325 self.rope.text()
326 }
327
328 pub fn len_chars(&self) -> usize {
329 self.rope.len_chars()
330 }
331
332 pub fn len_lines(&self) -> usize {
333 self.rope.len_lines().max(1)
334 }
335
336 pub fn is_empty(&self) -> bool {
337 self.rope.len_chars() == 0
338 }
339
340 /// Devuelve la línea `n` como `String` (incluye su trailing `\n` si
341 /// no es la última). Si `n` está fuera de rango devuelve `""`.
342 pub fn line(&self, n: usize) -> String {
343 if n >= self.rope.len_lines() {
344 return String::new();
345 }
346 self.rope.line_string(n)
347 }
348
349 /// Cantidad de chars en la línea `n` **sin contar** el `\n` terminal.
350 pub fn line_len_chars(&self, n: usize) -> usize {
351 if n >= self.rope.len_lines() {
352 return 0;
353 }
354 self.rope.line_len_chars(n)
355 }
356
357 /// El largo (en chars, sin el `\n`) de **todas** las líneas, de un solo
358 /// recorrido. Para quien necesita el perfil entero del archivo —el mapa de
359 /// ajuste de línea, el minimapa— y no una línea suelta.
360 pub fn line_lens(&self) -> Vec<usize> {
361 self.rope.line_lens()
362 }
363
364 /// Convierte `char_offset` global a `(line, col)`.
365 pub fn offset_to_pos(&self, offset: usize) -> (usize, usize) {
366 let off = offset.min(self.rope.len_chars());
367 let line = self.rope.char_to_line(off);
368 let line_start = self.rope.line_to_char(line);
369 (line, off - line_start)
370 }
371
372 /// Convierte `(line, col)` a `char_offset`. Clampea `line` y `col`
373 /// para no panicear con coordenadas fuera de rango.
374 pub fn pos_to_offset(&self, line: usize, col: usize) -> usize {
375 let line = line.min(self.rope.len_lines().saturating_sub(1));
376 let line_start = self.rope.line_to_char(line);
377 let line_chars = self.line_len_chars(line);
378 let col = col.min(line_chars);
379 line_start + col
380 }
381
382 /// Carácter en `char_offset`. `None` si está fuera de rango.
383 pub fn char_at(&self, offset: usize) -> Option<char> {
384 if offset >= self.rope.len_chars() {
385 return None;
386 }
387 Some(self.rope.char_at(offset))
388 }
389
390 /// Slice `[start..end)` como `String`. Clampea para no panicear.
391 pub fn slice(&self, start: usize, end: usize) -> String {
392 let len = self.rope.len_chars();
393 let s = start.min(len);
394 let e = end.min(len).max(s);
395 self.rope.slice(s, e)
396 }
397
398 /// Inserta `s` en `offset`. Clampea `offset`.
399 pub fn insert(&mut self, offset: usize, s: &str) {
400 let off = offset.min(self.rope.len_chars());
401 self.rope.insert(off, s);
402 }
403
404 /// Borra `[start..end)`. Clampea ambos.
405 pub fn delete(&mut self, start: usize, end: usize) {
406 let len = self.rope.len_chars();
407 let s = start.min(len);
408 let e = end.min(len).max(s);
409 if s == e {
410 return;
411 }
412 self.rope.remove(s, e);
413 }
414
415 pub fn set_text(&mut self, s: &str) {
416 self.rope = Nucleo::from_str(s);
417 }
418
419 pub fn replace_all(&mut self, s: &str) {
420 self.set_text(s);
421 }
422
423 /// Convierte char_offset → byte_offset. tree-sitter trabaja en bytes
424 /// (UTF-8); el editor en chars. Esto las conecta.
425 pub fn char_to_byte(&self, char_offset: usize) -> usize {
426 let off = char_offset.min(self.rope.len_chars());
427 self.rope.char_to_byte(off)
428 }
429
430 /// Línea (0-based) que contiene el char_offset dado.
431 pub fn char_to_line(&self, char_offset: usize) -> usize {
432 let off = char_offset.min(self.rope.len_chars());
433 self.rope.char_to_line(off)
434 }
435
436 /// Byte_offset del primer char de la línea `n`.
437 pub fn line_to_byte(&self, line: usize) -> usize {
438 let line = line.min(self.rope.len_lines());
439 self.rope.line_to_byte(line)
440 }
441
442 /// Devuelve el rango `[start_col..col)` que contiene el "word" actual
443 /// — desde el último carácter no-de-palabra hasta `col`, en la línea
444 /// `line`. Útil para autocompletion (smart-replace del prefijo).
445 pub fn current_word_prefix(&self, line: usize, col: usize) -> (usize, String) {
446 let line_text = self.line(line);
447 let chars: Vec<char> = line_text
448 .chars()
449 .filter(|c| *c != '\n')
450 .collect();
451 let end = col.min(chars.len());
452 let mut start = end;
453 while start > 0 && is_word_char(chars[start - 1]) {
454 start -= 1;
455 }
456 let prefix: String = chars[start..end].iter().collect();
457 (start, prefix)
458 }
459}
460
461fn is_word_char(c: char) -> bool {
462 c.is_alphanumeric() || c == '_'
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
470 fn empty_buffer_has_one_line() {
471 let b = Buffer::new();
472 assert_eq!(b.len_lines(), 1);
473 assert_eq!(b.line_len_chars(0), 0);
474 }
475
476 #[test]
477 fn pos_offset_roundtrip() {
478 let b = Buffer::from_str("hola\nmundo\nfin");
479 let cases = [(0usize, 0usize), (0, 4), (1, 0), (1, 5), (2, 3)];
480 for (line, col) in cases {
481 let off = b.pos_to_offset(line, col);
482 assert_eq!(b.offset_to_pos(off), (line, col));
483 }
484 }
485
486 #[test]
487 fn line_len_excludes_trailing_newline() {
488 let b = Buffer::from_str("hola\nfin");
489 assert_eq!(b.line_len_chars(0), 4); // "hola" sin \n
490 assert_eq!(b.line_len_chars(1), 3); // "fin"
491 }
492
493 #[test]
494 fn insert_and_delete_modify_text() {
495 let mut b = Buffer::from_str("ab");
496 b.insert(1, "X");
497 assert_eq!(b.text(), "aXb");
498 b.delete(1, 2);
499 assert_eq!(b.text(), "ab");
500 }
501
502 #[test]
503 fn slice_clampea() {
504 let b = Buffer::from_str("hola");
505 assert_eq!(b.slice(0, 100), "hola");
506 assert_eq!(b.slice(50, 100), "");
507 assert_eq!(b.slice(2, 1), ""); // end < start clampea
508 }
509
510 #[test]
511 fn current_word_prefix_basic() {
512 let b = Buffer::from_str("let hola_mundo = 1;");
513 // Caret en col 14 (después de la 'o' de "hola_mundo").
514 let (start, p) = b.current_word_prefix(0, 14);
515 assert_eq!(start, 4);
516 assert_eq!(p, "hola_mundo");
517 }
518
519 #[test]
520 fn current_word_prefix_en_inicio_es_vacio() {
521 let b = Buffer::from_str("hola");
522 let (start, p) = b.current_word_prefix(0, 0);
523 assert_eq!(start, 0);
524 assert!(p.is_empty());
525 }
526
527 #[test]
528 fn current_word_prefix_caret_despues_de_no_word() {
529 let b = Buffer::from_str("foo.bar");
530 let (start, p) = b.current_word_prefix(0, 4);
531 // El '.' no es word; el prefijo empieza ahí.
532 assert_eq!(start, 4);
533 assert!(p.is_empty());
534 }
535
536 #[test]
537 fn pos_to_offset_clampea_col() {
538 let b = Buffer::from_str("ab\ncd");
539 // col fuera de rango → fin de línea
540 assert_eq!(b.pos_to_offset(0, 99), 2);
541 assert_eq!(b.pos_to_offset(1, 99), 5);
542 }
543
544 // --- Contrato de espaldas: estos casos clavan la semántica que las
545 // --- DOS espaldas (ropey y soga) deben compartir. Corren contra la
546 // --- que esté compilada.
547
548 #[test]
549 fn texto_con_newline_final_tiene_linea_vacia_extra() {
550 // Semántica de `Rope::lines()`: "a\n" son DOS líneas ("a\n", "").
551 let b = Buffer::from_str("hola\n");
552 assert_eq!(b.len_lines(), 2);
553 assert_eq!(b.line(0), "hola\n");
554 assert_eq!(b.line(1), "");
555 assert_eq!(b.line_len_chars(1), 0);
556 }
557
558 #[test]
559 fn char_to_line_en_el_borde() {
560 let b = Buffer::from_str("ab\ncd");
561 assert_eq!(b.char_to_line(0), 0);
562 assert_eq!(b.char_to_line(2), 0); // el '\n' pertenece a la línea 0
563 assert_eq!(b.char_to_line(3), 1); // primer char de "cd"
564 assert_eq!(b.char_to_line(5), 1); // len_chars → última línea
565 }
566
567 #[test]
568 fn char_byte_multibyte() {
569 // "ñ" = 2 bytes, "中" = 3 bytes: chars ≠ bytes.
570 let b = Buffer::from_str("ñ中x\nfin");
571 assert_eq!(b.len_chars(), 7);
572 assert_eq!(b.char_to_byte(0), 0);
573 assert_eq!(b.char_to_byte(1), 2); // después de "ñ"
574 assert_eq!(b.char_to_byte(2), 5); // después de "中"
575 assert_eq!(b.char_to_byte(3), 6); // después de "x"
576 assert_eq!(b.line_to_byte(1), 7); // "fin" arranca tras el '\n'
577 assert_eq!(b.char_at(1), Some('中'));
578 assert_eq!(b.slice(0, 2), "ñ中");
579 }
580
581 #[test]
582 fn editar_multibyte_conserva_indices() {
583 let mut b = Buffer::from_str("aña\nbé");
584 b.insert(2, "中");
585 assert_eq!(b.text(), "añ中a\nbé");
586 assert_eq!(b.line_len_chars(0), 4);
587 b.delete(0, 3);
588 assert_eq!(b.text(), "a\nbé");
589 assert_eq!(b.len_lines(), 2);
590 assert_eq!(b.line_len_chars(1), 2);
591 }
592
593 #[test]
594 fn line_to_byte_permite_len_lines() {
595 // ropey permite pedir la línea después de la última: devuelve el
596 // final del texto. La soga debe hacer lo mismo.
597 let b = Buffer::from_str("ab\ncd");
598 assert_eq!(b.line_to_byte(2), 5);
599 }
600}