tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ 92dd0e5bfb2ca4456006da99437f4100e156d856 widgets/text-editor-core/src/ops.rs Rust · 384 líneas · 11.9 KB Historia Quién tocó qué crudo

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

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}