tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 92dd0e5bfb2ca4456006da99437f4100e156d856
widgets/text-editor-core/src/buffer.rs
History
Who touched what
raw
feat: llimphi standalone — framework UI soberano extraído del monorepo
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}