Skip to main content

typ_panel_editor/
actions.rs

1//! `Action` → editor behavior.
2//!
3//! Every mutation of the editor lives here or is called from here. Nothing in
4//! `handle_key` touches the buffer, which is what keeps the keymap, the future
5//! command palette, and the future vim layer able to reach the same behavior.
6
7use typ_buffer::{
8    EditKind, Position, Selection, Shift, TextBuffer, display_to_grapheme_col,
9    grapheme_to_display_col, next_word_boundary, previous_word_boundary,
10};
11use typ_core::{Action, Direction, Motion, PanelEvent};
12use unicode_segmentation::UnicodeSegmentation;
13
14use crate::{EditorPanel, TAB_WIDTH};
15
16impl EditorPanel {
17    /// Move one selection according to a motion.
18    ///
19    /// `extend` decides whether the anchor follows. A plain move from a
20    /// non-empty selection collapses toward the direction of travel rather
21    /// than moving from the head, which is the behavior everyone arriving from
22    /// a GUI editor has in their fingers.
23    fn move_selection(&self, selection: Selection, motion: Motion, extend: bool) -> Selection {
24        if !extend && !selection.is_empty() {
25            let collapse_to = match motion {
26                Motion::Left | Motion::WordLeft | Motion::LineStart | Motion::DocumentStart => {
27                    Some(selection.range().0)
28                }
29                Motion::Right | Motion::WordRight | Motion::LineEnd | Motion::DocumentEnd => {
30                    Some(selection.range().1)
31                }
32                // Vertical motions move from the head rather than collapsing to
33                // an end: up and down have no "direction of travel" along the
34                // selection to collapse toward.
35                _ => None,
36            };
37            if let Some(target) = collapse_to {
38                return Selection::caret(target);
39            }
40        }
41
42        let head = self.moved_position(selection.head, motion);
43        Selection {
44            anchor: if extend { selection.anchor } else { head },
45            head,
46        }
47    }
48
49    fn moved_position(&self, from: Position, motion: Motion) -> Position {
50        let last_line = self.last_line();
51
52        match motion {
53            Motion::Left => {
54                if from.col > 0 {
55                    Position {
56                        line: from.line,
57                        col: from.col - 1,
58                    }
59                } else if from.line > 0 {
60                    Position {
61                        line: from.line - 1,
62                        col: self.line_grapheme_count(from.line - 1),
63                    }
64                } else {
65                    from
66                }
67            }
68            Motion::Right => {
69                if from.col < self.line_grapheme_count(from.line) {
70                    Position {
71                        line: from.line,
72                        col: from.col + 1,
73                    }
74                } else if from.line < last_line {
75                    Position {
76                        line: from.line + 1,
77                        col: 0,
78                    }
79                } else {
80                    from
81                }
82            }
83            Motion::Up => self.vertical(from, -1),
84            Motion::Down => self.vertical(from, 1),
85            Motion::PageUp => self.vertical(from, -(self.page() as i64)),
86            Motion::PageDown => self.vertical(from, self.page() as i64),
87            Motion::WordLeft => {
88                if from.col == 0 {
89                    if from.line == 0 {
90                        from
91                    } else {
92                        Position {
93                            line: from.line - 1,
94                            col: self.line_grapheme_count(from.line - 1),
95                        }
96                    }
97                } else {
98                    let text = self.buffer.line_text(from.line);
99                    Position {
100                        line: from.line,
101                        col: previous_word_boundary(&text, from.col),
102                    }
103                }
104            }
105            Motion::WordRight => {
106                if from.col >= self.line_grapheme_count(from.line) {
107                    if from.line >= last_line {
108                        from
109                    } else {
110                        Position {
111                            line: from.line + 1,
112                            col: 0,
113                        }
114                    }
115                } else {
116                    let text = self.buffer.line_text(from.line);
117                    Position {
118                        line: from.line,
119                        col: next_word_boundary(&text, from.col),
120                    }
121                }
122            }
123            Motion::LineStart => Position {
124                line: from.line,
125                col: 0,
126            },
127            Motion::LineEnd => Position {
128                line: from.line,
129                col: self.line_grapheme_count(from.line),
130            },
131            Motion::DocumentStart => Position { line: 0, col: 0 },
132            Motion::DocumentEnd => Position {
133                line: last_line,
134                col: self.line_grapheme_count(last_line),
135            },
136        }
137    }
138
139    /// Vertical movement, preserving the goal column through short lines.
140    fn vertical(&self, from: Position, delta: i64) -> Position {
141        let goal = self.goal_col.unwrap_or_else(|| {
142            grapheme_to_display_col(&self.buffer.line_text(from.line), from.col, TAB_WIDTH)
143        });
144        let line = (from.line as i64 + delta).clamp(0, self.last_line() as i64) as usize;
145        let col = display_to_grapheme_col(&self.buffer.line_text(line), goal, TAB_WIDTH);
146        Position { line, col }
147    }
148
149    /// Replace the selection set, preserving order and the primary.
150    pub(crate) fn set_selections(&mut self, list: Vec<Selection>) {
151        let mut iter = list.into_iter();
152        let first = iter.next().expect("selections are never empty");
153        self.selections.set_single(first);
154        for selection in iter {
155            self.selections.push(selection);
156        }
157    }
158
159    /// Apply one described edit per selection, keeping every other selection
160    /// pointing at the text it was aimed at.
161    ///
162    /// The closure *describes* an edit as a range plus its replacement rather
163    /// than performing it. That is what makes multi-cursor correct: an edit
164    /// shifts every position after it, so the positions a later selection was
165    /// built from are stale the moment an earlier edit lands. Describing first
166    /// lets this function apply the edits in order and carry the accumulated
167    /// shift forward, which is the same job a text editor's change-mapping does
168    /// and is not something each action should reimplement.
169    fn edit_at_each_selection(
170        &mut self,
171        kind: EditKind,
172        describe: impl Fn(Selection, &TextBuffer) -> Edit,
173    ) -> Option<Vec<PanelEvent>> {
174        // Describing happens entirely before the first mutation, so the closure
175        // can borrow the buffer directly. The previous version copied every line
176        // in the file into a Vec<String> to dodge a borrow that was never a
177        // conflict — 50k allocations per keystroke to avoid a compile error that
178        // does not occur.
179        let described: Vec<Edit> = self
180            .selections
181            .iter()
182            .map(|s| describe(*s, &self.buffer))
183            .collect();
184
185        // One snapshot for the whole group, so a thirty-caret edit is one undo
186        // step rather than thirty — and consecutive edits of the same kind fold
187        // into the run already open, so typing a word is one step too.
188        self.buffer.begin_edit_group(kind, &self.selections);
189
190        let mut shift = Shift::default();
191        let mut heads: Vec<Position> = Vec::with_capacity(described.len());
192        for edit in described {
193            let start = shift.apply(edit.start);
194            let end = shift.apply(edit.end);
195            self.buffer.replace_range(start, end, &edit.text);
196
197            let after = position_after(start, &edit.text);
198            shift.record(edit.end.line, end, after);
199            heads.push(after);
200        }
201
202        self.buffer.end_edit_group();
203
204        self.set_selections(heads.into_iter().map(Selection::caret).collect());
205        self.goal_col = None;
206        self.scroll_to_cursor();
207        Some(vec![PanelEvent::NeedsRedraw])
208    }
209
210    /// The entry point every consumer uses. `None` means this panel does not
211    /// handle the action, so the app should try it.
212    pub fn perform(&mut self, action: Action) -> Option<Vec<PanelEvent>> {
213        // Anything that is not an edit ends the undo run. "Undo what I just
214        // typed" means the text typed since the cursor last moved, so the
215        // boundary belongs on every action that is not itself an edit — one
216        // place, rather than remembered at each of them.
217        if !matches!(
218            action,
219            Action::InsertChar(_) | Action::InsertNewline | Action::Delete { .. }
220        ) {
221            self.buffer.undo_boundary();
222        }
223
224        match action {
225            Action::Move { motion, extend } => {
226                let vertical = matches!(
227                    motion,
228                    Motion::Up | Motion::Down | Motion::PageUp | Motion::PageDown
229                );
230                if vertical {
231                    // Latch the goal from where the cursor is *now*, before
232                    // moving. Recomputing it afterwards would store the column
233                    // the motion just clamped to, so one pass through a short
234                    // line would narrow the goal permanently — the exact bug
235                    // this field exists to prevent.
236                    if self.goal_col.is_none() {
237                        let cursor = self.cursor();
238                        self.goal_col = Some(grapheme_to_display_col(
239                            &self.buffer.line_text(cursor.line),
240                            cursor.col,
241                            TAB_WIDTH,
242                        ));
243                    }
244                } else {
245                    self.goal_col = None;
246                }
247
248                // Read every selection before writing any: `move_selection`
249                // borrows self immutably, and the write needs it mutably.
250                let moved: Vec<Selection> = self
251                    .selections
252                    .iter()
253                    .map(|s| self.move_selection(*s, motion, extend))
254                    .collect();
255                self.set_selections(moved);
256                self.scroll_to_cursor();
257                Some(vec![PanelEvent::NeedsRedraw])
258            }
259            Action::InsertChar(c) => {
260                let text = c.to_string();
261                self.edit_at_each_selection(EditKind::Insert, move |selection, _buffer| {
262                    let (start, end) = selection.range();
263                    Edit {
264                        start,
265                        end,
266                        text: text.clone(),
267                    }
268                })
269            }
270
271            // `Other`, not `Insert`: a newline ends the typing run, so undo
272            // after Enter takes back the line rather than the paragraph.
273            Action::InsertNewline => {
274                self.edit_at_each_selection(EditKind::Other, |selection, _buffer| {
275                    let (start, end) = selection.range();
276                    Edit {
277                        start,
278                        end,
279                        text: "\n".to_string(),
280                    }
281                })
282            }
283
284            Action::Delete { direction, by_word } => {
285                self.edit_at_each_selection(EditKind::Delete, move |selection, buffer| {
286                    // A non-empty selection is the target, whichever key was
287                    // pressed.
288                    if !selection.is_empty() {
289                        let (start, end) = selection.range();
290                        return Edit::delete(start, end);
291                    }
292
293                    let head = selection.head;
294                    // One line, not every line: a word boundary never reaches
295                    // past the line it is on.
296                    let line_len = buffer.line_grapheme_count(head.line);
297
298                    match direction {
299                        Direction::Backward => {
300                            if head.col > 0 {
301                                let target = if by_word {
302                                    buffer.with_line_str(head.line, |line| {
303                                        previous_word_boundary(line, head.col)
304                                    })
305                                } else {
306                                    head.col - 1
307                                };
308                                Edit::delete(
309                                    Position {
310                                        line: head.line,
311                                        col: target,
312                                    },
313                                    head,
314                                )
315                            } else if head.line > 0 {
316                                // Join with the previous line: delete the
317                                // newline between them.
318                                let previous = head.line - 1;
319                                let col = buffer.line_grapheme_count(previous);
320                                Edit::delete(
321                                    Position {
322                                        line: previous,
323                                        col,
324                                    },
325                                    head,
326                                )
327                            } else {
328                                Edit::nothing(head)
329                            }
330                        }
331                        Direction::Forward => {
332                            if head.col < line_len {
333                                let target = if by_word {
334                                    buffer.with_line_str(head.line, |line| {
335                                        next_word_boundary(line, head.col)
336                                    })
337                                } else {
338                                    head.col + 1
339                                };
340                                Edit::delete(
341                                    head,
342                                    Position {
343                                        line: head.line,
344                                        col: target,
345                                    },
346                                )
347                            } else if head.line + 1 < buffer.line_count() {
348                                // At the end of a line, pull the next one up.
349                                Edit::delete(
350                                    head,
351                                    Position {
352                                        line: head.line + 1,
353                                        col: 0,
354                                    },
355                                )
356                            } else {
357                                Edit::nothing(head)
358                            }
359                        }
360                    }
361                })
362            }
363
364            Action::Undo => {
365                // No clamping: these selections were valid against this exact
366                // rope when they were recorded, which is also why undo puts the
367                // cursor back where the edit was made rather than wherever the
368                // clamp happened to land it.
369                if let Some(restored) = self.buffer.undo(&self.selections) {
370                    self.selections = restored;
371                    self.goal_col = None;
372                }
373                self.scroll_to_cursor();
374                Some(vec![PanelEvent::NeedsRedraw])
375            }
376
377            Action::Redo => {
378                if let Some(restored) = self.buffer.redo(&self.selections) {
379                    self.selections = restored;
380                    self.goal_col = None;
381                }
382                self.scroll_to_cursor();
383                Some(vec![PanelEvent::NeedsRedraw])
384            }
385
386            Action::SelectAll => {
387                let last = self.last_line();
388                self.selections.set_single(Selection {
389                    anchor: Position { line: 0, col: 0 },
390                    head: Position {
391                        line: last,
392                        col: self.line_grapheme_count(last),
393                    },
394                });
395                self.goal_col = None;
396                Some(vec![PanelEvent::NeedsRedraw])
397            }
398
399            Action::SelectLine => {
400                let line = self.cursor().line;
401                // Without the newline: selecting it would make the next
402                // keystroke eat the line break, which is not what "select this
403                // line" means to anyone.
404                self.selections.set_single(Selection {
405                    anchor: Position { line, col: 0 },
406                    head: Position {
407                        line,
408                        col: self.line_grapheme_count(line),
409                    },
410                });
411                self.goal_col = None;
412                Some(vec![PanelEvent::NeedsRedraw])
413            }
414
415            Action::CollapseSelections => {
416                self.selections.collapse_to_heads();
417                self.goal_col = None;
418                self.scroll_to_cursor();
419                Some(vec![PanelEvent::NeedsRedraw])
420            }
421
422            Action::AddCursor(direction) => {
423                let from = self.selections.primary().head;
424                let target_line = match direction {
425                    Direction::Backward => from.line.checked_sub(1),
426                    Direction::Forward => {
427                        let next = from.line + 1;
428                        (next <= self.last_line()).then_some(next)
429                    }
430                };
431                let Some(line) = target_line else {
432                    // At the edge of the document there is nowhere to add one.
433                    // Some(vec![]) rather than None: the action was handled and
434                    // simply had nothing to do, so the app must not retry it as
435                    // an app action.
436                    return Some(Vec::new());
437                };
438                let col = from.col.min(self.line_grapheme_count(line));
439                self.selections
440                    .push(Selection::caret(Position { line, col }));
441                self.scroll_to_cursor();
442                Some(vec![PanelEvent::NeedsRedraw])
443            }
444
445            // Not this panel's business. The app tries it next.
446            _ => None,
447        }
448    }
449}
450
451/// One edit, described rather than performed: replace `start..end` with `text`.
452///
453/// An empty range inserts and an empty text deletes, so every editing action
454/// reduces to this one shape and the position mapping only has to understand
455/// one thing.
456struct Edit {
457    start: Position,
458    end: Position,
459    text: String,
460}
461
462impl Edit {
463    fn delete(start: Position, end: Position) -> Self {
464        Self {
465            start,
466            end,
467            text: String::new(),
468        }
469    }
470
471    /// An edit that changes nothing, for a caret with nowhere to go — the
472    /// start of the buffer for backspace, the end for delete.
473    fn nothing(at: Position) -> Self {
474        Self {
475            start: at,
476            end: at,
477            text: String::new(),
478        }
479    }
480}
481
482/// Where a position ends up once `text` has been inserted at `start`.
483fn position_after(start: Position, text: &str) -> Position {
484    let mut line = start.line;
485    let mut col = start.col;
486    for grapheme in text.graphemes(true) {
487        if grapheme == "\n" || grapheme == "\r\n" {
488            line += 1;
489            col = 0;
490        } else {
491            col += 1;
492        }
493    }
494    Position { line, col }
495}