tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ 92dd0e5bfb2ca4456006da99437f4100e156d856 widgets/text-editor-core/src/buffer.rs Rust · 255 lines · 7.7 KB History Who touched what raw

feat: llimphi standalone — framework UI soberano extraído del monorepo e65e9cc62 · Sergio ·

1//! Buffer del editor — wrapper fino sobre [`ropey::Rope`] con las
2//! conversiones de coordenadas que el resto del crate usa.
3//!
4//! Coordenadas:
5//! - `char_offset`: índice de carácter (no byte) en el buffer entero.
6//! - `(line, col)`: línea (0-based) + columna en chars dentro de esa línea.
7//!
8//! Convenciones:
9//! - Las líneas son las que define `Rope::lines()` — un `\n` separa
10//!   líneas; la última línea puede o no terminar en `\n` (en cuyo caso
11//!   hay una línea vacía extra después).
12//! - `col` cuenta chars, no graphemes ni bytes. Para CJK ancho doble
13//!   el render decidirá el ancho visual; el cursor avanza en chars.
14
15use ropey::Rope;
16
17#[derive(Debug, Clone)]
18pub struct Buffer {
19    rope: Rope,
20}
21
22impl Default for Buffer {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl Buffer {
29    pub fn new() -> Self {
30        Self { rope: Rope::new() }
31    }
32
33    pub fn from_str(s: &str) -> Self {
34        Self { rope: Rope::from_str(s) }
35    }
36
37    pub fn text(&self) -> String {
38        self.rope.to_string()
39    }
40
41    pub fn len_chars(&self) -> usize {
42        self.rope.len_chars()
43    }
44
45    pub fn len_lines(&self) -> usize {
46        self.rope.len_lines().max(1)
47    }
48
49    pub fn is_empty(&self) -> bool {
50        self.rope.len_chars() == 0
51    }
52
53    /// Devuelve la línea `n` como `String` (incluye su trailing `\n` si
54    /// no es la última). Si `n` está fuera de rango devuelve `""`.
55    pub fn line(&self, n: usize) -> String {
56        if n >= self.rope.len_lines() {
57            return String::new();
58        }
59        self.rope.line(n).to_string()
60    }
61
62    /// Cantidad de chars en la línea `n` **sin contar** el `\n` terminal.
63    pub fn line_len_chars(&self, n: usize) -> usize {
64        if n >= self.rope.len_lines() {
65            return 0;
66        }
67        let line = self.rope.line(n);
68        let mut len = line.len_chars();
69        // Quitamos el `\n` final si lo hay.
70        if len > 0 && line.char(len - 1) == '\n' {
71            len -= 1;
72        }
73        len
74    }
75
76    /// Convierte `char_offset` global a `(line, col)`.
77    pub fn offset_to_pos(&self, offset: usize) -> (usize, usize) {
78        let off = offset.min(self.rope.len_chars());
79        let line = self.rope.char_to_line(off);
80        let line_start = self.rope.line_to_char(line);
81        (line, off - line_start)
82    }
83
84    /// Convierte `(line, col)` a `char_offset`. Clampea `line` y `col`
85    /// para no panicear con coordenadas fuera de rango.
86    pub fn pos_to_offset(&self, line: usize, col: usize) -> usize {
87        let line = line.min(self.rope.len_lines().saturating_sub(1));
88        let line_start = self.rope.line_to_char(line);
89        let line_chars = self.line_len_chars(line);
90        let col = col.min(line_chars);
91        line_start + col
92    }
93
94    /// Carácter en `char_offset`. `None` si está fuera de rango.
95    pub fn char_at(&self, offset: usize) -> Option<char> {
96        if offset >= self.rope.len_chars() {
97            return None;
98        }
99        Some(self.rope.char(offset))
100    }
101
102    /// Slice `[start..end)` como `String`. Clampea para no panicear.
103    pub fn slice(&self, start: usize, end: usize) -> String {
104        let len = self.rope.len_chars();
105        let s = start.min(len);
106        let e = end.min(len).max(s);
107        self.rope.slice(s..e).to_string()
108    }
109
110    /// Inserta `s` en `offset`. Clampea `offset`.
111    pub fn insert(&mut self, offset: usize, s: &str) {
112        let off = offset.min(self.rope.len_chars());
113        self.rope.insert(off, s);
114    }
115
116    /// Borra `[start..end)`. Clampea ambos.
117    pub fn delete(&mut self, start: usize, end: usize) {
118        let len = self.rope.len_chars();
119        let s = start.min(len);
120        let e = end.min(len).max(s);
121        if s == e {
122            return;
123        }
124        self.rope.remove(s..e);
125    }
126
127    pub fn set_text(&mut self, s: &str) {
128        self.rope = Rope::from_str(s);
129    }
130
131    pub fn replace_all(&mut self, s: &str) {
132        self.set_text(s);
133    }
134
135    /// Convierte char_offset → byte_offset. tree-sitter trabaja en bytes
136    /// (UTF-8); el editor en chars. Esto las conecta.
137    pub fn char_to_byte(&self, char_offset: usize) -> usize {
138        let off = char_offset.min(self.rope.len_chars());
139        self.rope.char_to_byte(off)
140    }
141
142    /// Línea (0-based) que contiene el char_offset dado.
143    pub fn char_to_line(&self, char_offset: usize) -> usize {
144        let off = char_offset.min(self.rope.len_chars());
145        self.rope.char_to_line(off)
146    }
147
148    /// Byte_offset del primer char de la línea `n`.
149    pub fn line_to_byte(&self, line: usize) -> usize {
150        let line = line.min(self.rope.len_lines());
151        self.rope.line_to_byte(line)
152    }
153
154    /// Devuelve el rango `[start_col..col)` que contiene el "word" actual
155    /// — desde el último carácter no-de-palabra hasta `col`, en la línea
156    /// `line`. Útil para autocompletion (smart-replace del prefijo).
157    pub fn current_word_prefix(&self, line: usize, col: usize) -> (usize, String) {
158        let line_text = self.line(line);
159        let chars: Vec<char> = line_text
160            .chars()
161            .filter(|c| *c != '\n')
162            .collect();
163        let end = col.min(chars.len());
164        let mut start = end;
165        while start > 0 && is_word_char(chars[start - 1]) {
166            start -= 1;
167        }
168        let prefix: String = chars[start..end].iter().collect();
169        (start, prefix)
170    }
171}
172
173fn is_word_char(c: char) -> bool {
174    c.is_alphanumeric() || c == '_'
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn empty_buffer_has_one_line() {
183        let b = Buffer::new();
184        assert_eq!(b.len_lines(), 1);
185        assert_eq!(b.line_len_chars(0), 0);
186    }
187
188    #[test]
189    fn pos_offset_roundtrip() {
190        let b = Buffer::from_str("hola\nmundo\nfin");
191        let cases = [(0usize, 0usize), (0, 4), (1, 0), (1, 5), (2, 3)];
192        for (line, col) in cases {
193            let off = b.pos_to_offset(line, col);
194            assert_eq!(b.offset_to_pos(off), (line, col));
195        }
196    }
197
198    #[test]
199    fn line_len_excludes_trailing_newline() {
200        let b = Buffer::from_str("hola\nfin");
201        assert_eq!(b.line_len_chars(0), 4); // "hola" sin \n
202        assert_eq!(b.line_len_chars(1), 3); // "fin"
203    }
204
205    #[test]
206    fn insert_and_delete_modify_text() {
207        let mut b = Buffer::from_str("ab");
208        b.insert(1, "X");
209        assert_eq!(b.text(), "aXb");
210        b.delete(1, 2);
211        assert_eq!(b.text(), "ab");
212    }
213
214    #[test]
215    fn slice_clampea() {
216        let b = Buffer::from_str("hola");
217        assert_eq!(b.slice(0, 100), "hola");
218        assert_eq!(b.slice(50, 100), "");
219        assert_eq!(b.slice(2, 1), ""); // end < start clampea
220    }
221
222    #[test]
223    fn current_word_prefix_basic() {
224        let b = Buffer::from_str("let hola_mundo = 1;");
225        // Caret en col 14 (después de la 'o' de "hola_mundo").
226        let (start, p) = b.current_word_prefix(0, 14);
227        assert_eq!(start, 4);
228        assert_eq!(p, "hola_mundo");
229    }
230
231    #[test]
232    fn current_word_prefix_en_inicio_es_vacio() {
233        let b = Buffer::from_str("hola");
234        let (start, p) = b.current_word_prefix(0, 0);
235        assert_eq!(start, 0);
236        assert!(p.is_empty());
237    }
238
239    #[test]
240    fn current_word_prefix_caret_despues_de_no_word() {
241        let b = Buffer::from_str("foo.bar");
242        let (start, p) = b.current_word_prefix(0, 4);
243        // El '.' no es word; el prefijo empieza ahí.
244        assert_eq!(start, 4);
245        assert!(p.is_empty());
246    }
247
248    #[test]
249    fn pos_to_offset_clampea_col() {
250        let b = Buffer::from_str("ab\ncd");
251        // col fuera de rango → fin de línea
252        assert_eq!(b.pos_to_offset(0, 99), 2);
253        assert_eq!(b.pos_to_offset(1, 99), 5);
254    }
255}