tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 92dd0e5bfb2ca4456006da99437f4100e156d856
widgets/text-editor-core/src/ops.rs
Historia
Quién tocó qué
crudo
feat: llimphi standalone — framework UI soberano extraído del monorepo
1//! Operaciones de edición. Cada una toma `&mut Buffer + &mut Cursor` y
2//! devuelve un [`EditDelta`] reversible que la pila de undo guarda.
3//!
4//! El delta es minimal: el rango `[start..end)` que se reemplazó + el
5//! texto que estaba antes + el texto nuevo. Aplicado en reversa,
6//! restaura el estado anterior exactamente.
7
8use crate::buffer::Buffer;
9use crate::cursor::{Cursor, Pos};
10
11/// Delta atómico de edición — útil para undo/redo y log de cambios.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct EditDelta {
14 pub start: usize,
15 pub removed: String,
16 pub inserted: String,
17 /// Caret antes de la operación (para restaurarlo en undo).
18 pub cursor_before: Cursor,
19 /// Caret después de la operación.
20 pub cursor_after: Cursor,
21}
22
23impl EditDelta {
24 /// Aplica el delta a `(buf, cursor)`.
25 pub fn apply(&self, buf: &mut Buffer, cursor: &mut Cursor) {
26 let end = self.start + self.removed.chars().count();
27 buf.delete(self.start, end);
28 if !self.inserted.is_empty() {
29 buf.insert(self.start, &self.inserted);
30 }
31 *cursor = self.cursor_after;
32 }
33
34 /// Aplica el inverso (undo).
35 pub fn undo(&self, buf: &mut Buffer, cursor: &mut Cursor) {
36 let end = self.start + self.inserted.chars().count();
37 buf.delete(self.start, end);
38 if !self.removed.is_empty() {
39 buf.insert(self.start, &self.removed);
40 }
41 *cursor = self.cursor_before;
42 }
43}
44
45/// Genera la string de indentación según la config.
46pub fn indent_str(tab_to_spaces: bool, indent_size: usize) -> String {
47 if tab_to_spaces {
48 " ".repeat(indent_size)
49 } else {
50 "\t".to_string()
51 }
52}
53
54/// Reemplaza la selección activa por `text`. Si no hay selección,
55/// inserta `text` en el caret. Devuelve el delta resultante.
56pub fn replace_selection(
57 buf: &mut Buffer,
58 cursor: &mut Cursor,
59 text: &str,
60) -> EditDelta {
61 let before = *cursor;
62 let (start, end) = cursor.selection_range(buf);
63 let removed = buf.slice(start, end);
64
65 if start != end {
66 buf.delete(start, end);
67 }
68 if !text.is_empty() {
69 buf.insert(start, text);
70 }
71
72 let new_off = start + text.chars().count();
73 let (line, col) = buf.offset_to_pos(new_off);
74 cursor.caret = Pos::new(line, col);
75 cursor.desired_col = col;
76 cursor.anchor = None;
77
78 EditDelta {
79 start,
80 removed,
81 inserted: text.to_string(),
82 cursor_before: before,
83 cursor_after: *cursor,
84 }
85}
86
87/// Borra hacia atrás (Backspace). Si hay selección, la borra; si no,
88/// borra el char antes del caret. Devuelve `None` si no había nada que
89/// borrar (cursor al inicio + sin selección).
90pub fn delete_backward(buf: &mut Buffer, cursor: &mut Cursor) -> Option<EditDelta> {
91 if cursor.has_selection() {
92 return Some(replace_selection(buf, cursor, ""));
93 }
94 let before = *cursor;
95 let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col);
96 if caret_off == 0 {
97 return None;
98 }
99 let removed = buf.slice(caret_off - 1, caret_off);
100 buf.delete(caret_off - 1, caret_off);
101 let (line, col) = buf.offset_to_pos(caret_off - 1);
102 cursor.caret = Pos::new(line, col);
103 cursor.desired_col = col;
104 Some(EditDelta {
105 start: caret_off - 1,
106 removed,
107 inserted: String::new(),
108 cursor_before: before,
109 cursor_after: *cursor,
110 })
111}
112
113/// Borra hacia adelante (Delete).
114pub fn delete_forward(buf: &mut Buffer, cursor: &mut Cursor) -> Option<EditDelta> {
115 if cursor.has_selection() {
116 return Some(replace_selection(buf, cursor, ""));
117 }
118 let before = *cursor;
119 let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col);
120 if caret_off >= buf.len_chars() {
121 return None;
122 }
123 let removed = buf.slice(caret_off, caret_off + 1);
124 buf.delete(caret_off, caret_off + 1);
125 Some(EditDelta {
126 start: caret_off,
127 removed,
128 inserted: String::new(),
129 cursor_before: before,
130 cursor_after: *cursor,
131 })
132}
133
134/// Inserta un salto de línea con **indentación automática**: copia los
135/// whitespace iniciales del renglón actual al renglón nuevo.
136pub fn insert_newline_auto_indent(buf: &mut Buffer, cursor: &mut Cursor) -> EditDelta {
137 let current_line = buf.line(cursor.caret.line);
138 let indent: String = current_line
139 .chars()
140 .take_while(|c| *c == ' ' || *c == '\t')
141 .collect();
142 let text = format!("\n{indent}");
143 replace_selection(buf, cursor, &text)
144}
145
146/// Inserta un tab (o `indent_size` spaces según config). Si hay
147/// selección **multilínea**, indenta cada línea de la selección.
148pub fn indent_or_insert_tab(
149 buf: &mut Buffer,
150 cursor: &mut Cursor,
151 tab_to_spaces: bool,
152 indent_size: usize,
153) -> EditDelta {
154 let indent = indent_str(tab_to_spaces, indent_size);
155
156 // Sin selección o selección en una sola línea → inserta indent.
157 let multi_line = match cursor.selection() {
158 Some(sel) => sel.anchor.line != sel.caret.line,
159 None => false,
160 };
161 if !multi_line {
162 return replace_selection(buf, cursor, &indent);
163 }
164
165 // Selección multilínea: indenta cada línea afectada por el rango.
166 let before = *cursor;
167 let sel = cursor.selection().expect("multi_line implica selección");
168 let first = sel.anchor.line.min(sel.caret.line);
169 let last = sel.anchor.line.max(sel.caret.line);
170
171 let mut start_global = buf.pos_to_offset(first, 0);
172 let removed = String::new();
173 let mut inserted = String::new();
174 for line in first..=last {
175 let line_start = buf.pos_to_offset(line, 0);
176 buf.insert(line_start, &indent);
177 inserted.push_str(&indent);
178 let _ = start_global; // (sin uso; se mantiene por simetría)
179 start_global = buf.pos_to_offset(first, 0);
180 }
181
182 // Mantenemos la selección extendida sobre las líneas indentadas.
183 let n_added = indent.chars().count();
184 let new_anchor = Pos::new(sel.anchor.line, sel.anchor.col + n_added);
185 let new_caret = Pos::new(sel.caret.line, sel.caret.col + n_added);
186 cursor.anchor = Some(new_anchor);
187 cursor.caret = new_caret;
188 cursor.desired_col = new_caret.col;
189
190 EditDelta {
191 start: start_global,
192 removed,
193 inserted,
194 cursor_before: before,
195 cursor_after: *cursor,
196 }
197}
198
199/// Quita un nivel de indent del renglón actual (o de cada línea si hay
200/// selección multilínea). Devuelve `None` si nada cambió.
201pub fn dedent(
202 buf: &mut Buffer,
203 cursor: &mut Cursor,
204 tab_to_spaces: bool,
205 indent_size: usize,
206) -> Option<EditDelta> {
207 let before = *cursor;
208 let (first, last) = match cursor.selection() {
209 Some(sel) => (
210 sel.anchor.line.min(sel.caret.line),
211 sel.anchor.line.max(sel.caret.line),
212 ),
213 None => (cursor.caret.line, cursor.caret.line),
214 };
215
216 let mut total_removed = 0usize;
217 let mut removed_text = String::new();
218 let start_offset = buf.pos_to_offset(first, 0);
219
220 for line in first..=last {
221 let line_str = buf.line(line);
222 let mut n = 0usize;
223 let mut chars = line_str.chars();
224 if tab_to_spaces {
225 for _ in 0..indent_size {
226 if chars.next() == Some(' ') {
227 n += 1;
228 } else {
229 break;
230 }
231 }
232 } else if chars.next() == Some('\t') {
233 n = 1;
234 }
235 if n == 0 {
236 continue;
237 }
238 let line_start = buf.pos_to_offset(line, 0);
239 removed_text.push_str(&buf.slice(line_start, line_start + n));
240 buf.delete(line_start, line_start + n);
241 total_removed += n;
242 }
243
244 if total_removed == 0 {
245 return None;
246 }
247
248 // Cursor: clampea col al nuevo line_len.
249 let caret_line = cursor.caret.line;
250 let caret_col = cursor
251 .caret
252 .col
253 .saturating_sub(if caret_line >= first && caret_line <= last {
254 // Cuánto se removió de esta línea (varía); aproximamos al
255 // common case de mismo n por línea. Si fuera distinto el
256 // visual queda OK porque clampea.
257 removed_text.chars().count() / (last - first + 1).max(1)
258 } else {
259 0
260 });
261 cursor.caret.col = caret_col.min(buf.line_len_chars(caret_line));
262 cursor.desired_col = cursor.caret.col;
263
264 if let Some(anchor) = cursor.anchor.as_mut() {
265 if anchor.line >= first && anchor.line <= last {
266 anchor.col = anchor
267 .col
268 .saturating_sub(removed_text.chars().count() / (last - first + 1).max(1))
269 .min(buf.line_len_chars(anchor.line));
270 }
271 }
272
273 Some(EditDelta {
274 start: start_offset,
275 removed: removed_text,
276 inserted: String::new(),
277 cursor_before: before,
278 cursor_after: *cursor,
279 })
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn replace_selection_sin_seleccion_inserta() {
288 let mut b = Buffer::from_str("ab");
289 let mut c = Cursor::at(0, 1);
290 let d = replace_selection(&mut b, &mut c, "X");
291 assert_eq!(b.text(), "aXb");
292 assert_eq!(c.caret, Pos::new(0, 2));
293 assert_eq!(d.removed, "");
294 assert_eq!(d.inserted, "X");
295 }
296
297 #[test]
298 fn replace_selection_con_seleccion_reemplaza() {
299 let mut b = Buffer::from_str("hola mundo");
300 let mut c = Cursor { caret: Pos::new(0, 9), anchor: Some(Pos::new(0, 5)), desired_col: 9 };
301 replace_selection(&mut b, &mut c, "luna");
302 assert_eq!(b.text(), "hola lunao");
303 }
304
305 #[test]
306 fn backspace_borra_char() {
307 let mut b = Buffer::from_str("hola");
308 let mut c = Cursor::at(0, 4);
309 delete_backward(&mut b, &mut c);
310 assert_eq!(b.text(), "hol");
311 assert_eq!(c.caret, Pos::new(0, 3));
312 }
313
314 #[test]
315 fn backspace_en_inicio_no_hace_nada() {
316 let mut b = Buffer::from_str("a");
317 let mut c = Cursor::at(0, 0);
318 assert!(delete_backward(&mut b, &mut c).is_none());
319 }
320
321 #[test]
322 fn delete_forward_borra_char() {
323 let mut b = Buffer::from_str("ab");
324 let mut c = Cursor::at(0, 0);
325 delete_forward(&mut b, &mut c);
326 assert_eq!(b.text(), "b");
327 }
328
329 #[test]
330 fn newline_copia_indent_del_renglon_anterior() {
331 let mut b = Buffer::from_str(" hola");
332 let mut c = Cursor::at(0, 8);
333 insert_newline_auto_indent(&mut b, &mut c);
334 assert_eq!(b.text(), " hola\n ");
335 assert_eq!(c.caret, Pos::new(1, 4));
336 }
337
338 #[test]
339 fn tab_inserta_spaces() {
340 let mut b = Buffer::from_str("ab");
341 let mut c = Cursor::at(0, 1);
342 indent_or_insert_tab(&mut b, &mut c, true, 4);
343 assert_eq!(b.text(), "a b");
344 assert_eq!(c.caret, Pos::new(0, 5));
345 }
346
347 #[test]
348 fn tab_con_seleccion_multilinea_indenta_cada_linea() {
349 let mut b = Buffer::from_str("a\nb\nc");
350 let mut c = Cursor {
351 anchor: Some(Pos::new(0, 0)),
352 caret: Pos::new(2, 1),
353 desired_col: 1,
354 };
355 indent_or_insert_tab(&mut b, &mut c, true, 2);
356 assert_eq!(b.text(), " a\n b\n c");
357 }
358
359 #[test]
360 fn dedent_quita_indent_del_renglon() {
361 let mut b = Buffer::from_str(" hola");
362 let mut c = Cursor::at(0, 8);
363 dedent(&mut b, &mut c, true, 4);
364 assert_eq!(b.text(), "hola");
365 }
366
367 #[test]
368 fn dedent_sin_indent_devuelve_none() {
369 let mut b = Buffer::from_str("hola");
370 let mut c = Cursor::at(0, 0);
371 assert!(dedent(&mut b, &mut c, true, 4).is_none());
372 }
373
374 #[test]
375 fn delta_undo_restaura_estado() {
376 let mut b = Buffer::from_str("hola");
377 let mut c = Cursor::at(0, 4);
378 let d = replace_selection(&mut b, &mut c, "!");
379 assert_eq!(b.text(), "hola!");
380 d.undo(&mut b, &mut c);
381 assert_eq!(b.text(), "hola");
382 assert_eq!(c.caret, Pos::new(0, 4));
383 }
384}