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 unicode_segmentation::UnicodeSegmentation;
8
9use typ_buffer::{
10    EditKind, Position, Selection, Shift, TextBuffer, clipboard, display_to_grapheme_col,
11    grapheme_to_display_col, next_word_boundary, previous_word_boundary,
12};
13use typ_core::{Action, Direction, Motion, PanelEvent};
14
15use crate::{EditorPanel, TAB_WIDTH};
16
17impl EditorPanel {
18    /// Move one selection according to a motion.
19    ///
20    /// `extend` decides whether the anchor follows. A plain move from a
21    /// non-empty selection collapses toward the direction of travel rather
22    /// than moving from the head, which is the behavior everyone arriving from
23    /// a GUI editor has in their fingers.
24    fn move_selection(&self, selection: Selection, motion: Motion, extend: bool) -> Selection {
25        if !extend && !selection.is_empty() {
26            let collapse_to = match motion {
27                Motion::Left | Motion::WordLeft | Motion::LineStart | Motion::DocumentStart => {
28                    Some(selection.range().0)
29                }
30                Motion::Right | Motion::WordRight | Motion::LineEnd | Motion::DocumentEnd => {
31                    Some(selection.range().1)
32                }
33                // Vertical motions move from the head rather than collapsing to
34                // an end: up and down have no "direction of travel" along the
35                // selection to collapse toward.
36                _ => None,
37            };
38            if let Some(target) = collapse_to {
39                return Selection::caret(target);
40            }
41        }
42
43        let head = self.moved_position(selection.head, motion);
44        Selection {
45            anchor: if extend { selection.anchor } else { head },
46            head,
47        }
48    }
49
50    fn moved_position(&self, from: Position, motion: Motion) -> Position {
51        let last_line = self.last_line();
52
53        match motion {
54            Motion::Left => {
55                if from.col > 0 {
56                    Position {
57                        line: from.line,
58                        col: from.col - 1,
59                    }
60                } else if from.line > 0 {
61                    Position {
62                        line: from.line - 1,
63                        col: self.line_grapheme_count(from.line - 1),
64                    }
65                } else {
66                    from
67                }
68            }
69            Motion::Right => {
70                if from.col < self.line_grapheme_count(from.line) {
71                    Position {
72                        line: from.line,
73                        col: from.col + 1,
74                    }
75                } else if from.line < last_line {
76                    Position {
77                        line: from.line + 1,
78                        col: 0,
79                    }
80                } else {
81                    from
82                }
83            }
84            Motion::Up => self.vertical(from, -1),
85            Motion::Down => self.vertical(from, 1),
86            Motion::PageUp => self.vertical(from, -(self.page() as i64)),
87            Motion::PageDown => self.vertical(from, self.page() as i64),
88            Motion::WordLeft => {
89                if from.col == 0 {
90                    if from.line == 0 {
91                        from
92                    } else {
93                        Position {
94                            line: from.line - 1,
95                            col: self.line_grapheme_count(from.line - 1),
96                        }
97                    }
98                } else {
99                    let text = self.buffer.line_text(from.line);
100                    Position {
101                        line: from.line,
102                        col: previous_word_boundary(&text, from.col),
103                    }
104                }
105            }
106            Motion::WordRight => {
107                if from.col >= self.line_grapheme_count(from.line) {
108                    if from.line >= last_line {
109                        from
110                    } else {
111                        Position {
112                            line: from.line + 1,
113                            col: 0,
114                        }
115                    }
116                } else {
117                    let text = self.buffer.line_text(from.line);
118                    Position {
119                        line: from.line,
120                        col: next_word_boundary(&text, from.col),
121                    }
122                }
123            }
124            Motion::LineStart => Position {
125                line: from.line,
126                col: 0,
127            },
128            Motion::LineEnd => Position {
129                line: from.line,
130                col: self.line_grapheme_count(from.line),
131            },
132            Motion::DocumentStart => Position { line: 0, col: 0 },
133            Motion::DocumentEnd => Position {
134                line: last_line,
135                col: self.line_grapheme_count(last_line),
136            },
137        }
138    }
139
140    /// Vertical movement, preserving the goal column through short lines.
141    fn vertical(&self, from: Position, delta: i64) -> Position {
142        let goal = self.goal_col.unwrap_or_else(|| {
143            grapheme_to_display_col(&self.buffer.line_text(from.line), from.col, TAB_WIDTH)
144        });
145        let line = (from.line as i64 + delta).clamp(0, self.last_line() as i64) as usize;
146        let col = display_to_grapheme_col(&self.buffer.line_text(line), goal, TAB_WIDTH);
147        Position { line, col }
148    }
149
150    /// Every selection's text, joined by newlines.
151    ///
152    /// Newlines rather than nothing, because the counterpart paste splits on
153    /// them to hand one line back to each cursor. Joining with the empty string
154    /// would make a three-cursor copy indistinguishable from one long word.
155    fn selected_text(&self) -> String {
156        self.selections
157            .iter()
158            .filter(|s| !s.is_empty())
159            .map(|s| {
160                let (start, end) = s.range();
161                self.buffer.text_in_range(start, end)
162            })
163            .collect::<Vec<_>>()
164            .join("\n")
165    }
166
167    /// Replace the selection set, preserving order and the primary.
168    pub(crate) fn set_selections(&mut self, list: Vec<Selection>) {
169        let mut iter = list.into_iter();
170        let first = iter.next().expect("selections are never empty");
171        self.selections.set_single(first);
172        for selection in iter {
173            self.selections.push(selection);
174        }
175    }
176
177    /// Apply one described edit per selection, keeping every other selection
178    /// pointing at the text it was aimed at.
179    ///
180    /// The closure *describes* an edit as a range plus its replacement rather
181    /// than performing it. That is what makes multi-cursor correct: an edit
182    /// shifts every position after it, so the positions a later selection was
183    /// built from are stale the moment an earlier edit lands. Describing first
184    /// lets this function apply the edits in order and carry the accumulated
185    /// shift forward, which is the same job a text editor's change-mapping does
186    /// and is not something each action should reimplement.
187    fn edit_at_each_selection(
188        &mut self,
189        kind: EditKind,
190        describe: impl Fn(Selection, &TextBuffer) -> Edit,
191    ) -> Option<Vec<PanelEvent>> {
192        // Describing happens entirely before the first mutation, so the closure
193        // can borrow the buffer directly. The previous version copied every line
194        // in the file into a Vec<String> to dodge a borrow that was never a
195        // conflict — 50k allocations per keystroke to avoid a compile error that
196        // does not occur.
197        let described: Vec<Edit> = self
198            .selections
199            .iter()
200            .map(|s| describe(*s, &self.buffer))
201            .collect();
202
203        // One snapshot for the whole group, so a thirty-caret edit is one undo
204        // step rather than thirty — and consecutive edits of the same kind fold
205        // into the run already open, so typing a word is one step too.
206        self.buffer.begin_edit_group(kind, &self.selections);
207
208        let mut shift = Shift::default();
209        let mut heads: Vec<Position> = Vec::with_capacity(described.len());
210        for edit in described {
211            let start = shift.apply(edit.start);
212            let end = shift.apply(edit.end);
213            self.buffer.replace_range(start, end, &edit.text);
214
215            let after = position_after(start, &edit.text);
216            shift.record(edit.end.line, end, after);
217            heads.push(after);
218        }
219
220        self.buffer.end_edit_group();
221
222        self.set_selections(heads.into_iter().map(Selection::caret).collect());
223        self.goal_col = None;
224        self.scroll_to_cursor();
225        Some(vec![PanelEvent::NeedsRedraw])
226    }
227
228    /// Add or remove one indent level on every line a selection touches.
229    ///
230    /// This does not go through `edit_at_each_selection`, and the reason is the
231    /// difference between the two operations. That one edits *at* each
232    /// selection and collapses the result to carets, which is right for typing
233    /// and wrong here: an indent must leave the selection standing so the user
234    /// can press Tab again. It also works per *line* rather than per selection,
235    /// so two cursors on one line indent it once.
236    ///
237    /// Edits run last line first, so every earlier line's offsets stay valid
238    /// without a shift map — the edits are disjoint and each sits at the start
239    /// of its own line, which is a much smaller problem than the general one.
240    fn shift_lines(&mut self, indent: bool) -> Option<Vec<PanelEvent>> {
241        let mut lines: Vec<usize> = Vec::new();
242        for selection in self.selections.iter() {
243            let (start, end) = selection.range();
244            // A selection ending at column 0 has nothing of that line in it, so
245            // the line is not part of the block. Including it is the classic
246            // off-by-one that indents a line the user cannot see selected.
247            let last = if end.col == 0 && end.line > start.line {
248                end.line - 1
249            } else {
250                end.line
251            };
252            lines.extend(start.line..=last);
253        }
254        lines.sort_unstable();
255        lines.dedup();
256
257        // How each line's columns move. Only affected lines appear.
258        let mut deltas: Vec<(usize, isize)> = Vec::new();
259        for &line in &lines {
260            let delta = self.buffer.with_line_str(line, |text| {
261                if indent {
262                    // Indenting a blank line leaves trailing whitespace and
263                    // achieves nothing else.
264                    if text.trim().is_empty() {
265                        return 0;
266                    }
267                    TAB_WIDTH as isize
268                } else if text.starts_with('\t') {
269                    -1
270                } else {
271                    // A partial level goes to zero rather than to minus one.
272                    -(text
273                        .chars()
274                        .take(TAB_WIDTH)
275                        .take_while(|c| *c == ' ')
276                        .count() as isize)
277                }
278            });
279            if delta != 0 {
280                deltas.push((line, delta));
281            }
282        }
283
284        if deltas.is_empty() {
285            // Handled, nothing to do — not "unhandled", which would send the
286            // action on to the app and eventually to a raw key.
287            return Some(Vec::new());
288        }
289
290        // `Other`: an indent is never part of a typing run.
291        self.buffer
292            .begin_edit_group(EditKind::Other, &self.selections);
293        for &(line, delta) in deltas.iter().rev() {
294            let start = Position { line, col: 0 };
295            if delta > 0 {
296                self.buffer
297                    .replace_range(start, start, &" ".repeat(delta as usize));
298            } else {
299                let end = Position {
300                    line,
301                    col: (-delta) as usize,
302                };
303                self.buffer.replace_range(start, end, "");
304            }
305        }
306        self.buffer.end_edit_group();
307
308        // Move every selection by its own line's delta, so the selection ends
309        // up around the same text it started around.
310        let shifted: Vec<Selection> = self
311            .selections
312            .iter()
313            .map(|selection| {
314                let move_position = |p: Position| {
315                    let delta = deltas
316                        .iter()
317                        .find(|(line, _)| *line == p.line)
318                        .map_or(0, |(_, d)| *d);
319                    Position {
320                        line: p.line,
321                        col: p.col.saturating_add_signed(delta),
322                    }
323                };
324                Selection {
325                    anchor: move_position(selection.anchor),
326                    head: move_position(selection.head),
327                }
328            })
329            .collect();
330        self.set_selections(shifted);
331        self.goal_col = None;
332        self.scroll_to_cursor();
333        Some(vec![PanelEvent::NeedsRedraw])
334    }
335
336    /// The entry point every consumer uses. `None` means this panel does not
337    /// handle the action, so the app should try it.
338    pub fn perform(&mut self, action: Action) -> Option<Vec<PanelEvent>> {
339        // Anything that is not an edit ends the undo run. "Undo what I just
340        // typed" means the text typed since the cursor last moved, so the
341        // boundary belongs on every action that is not itself an edit — one
342        // place, rather than remembered at each of them.
343        if !matches!(
344            action,
345            Action::InsertChar(_) | Action::InsertNewline | Action::Delete { .. }
346        ) {
347            self.buffer.undo_boundary();
348        }
349
350        match action {
351            Action::Move { motion, extend } => {
352                let vertical = matches!(
353                    motion,
354                    Motion::Up | Motion::Down | Motion::PageUp | Motion::PageDown
355                );
356                if vertical {
357                    // Latch the goal from where the cursor is *now*, before
358                    // moving. Recomputing it afterwards would store the column
359                    // the motion just clamped to, so one pass through a short
360                    // line would narrow the goal permanently — the exact bug
361                    // this field exists to prevent.
362                    if self.goal_col.is_none() {
363                        let cursor = self.cursor();
364                        self.goal_col = Some(grapheme_to_display_col(
365                            &self.buffer.line_text(cursor.line),
366                            cursor.col,
367                            TAB_WIDTH,
368                        ));
369                    }
370                } else {
371                    self.goal_col = None;
372                }
373
374                // Read every selection before writing any: `move_selection`
375                // borrows self immutably, and the write needs it mutably.
376                let moved: Vec<Selection> = self
377                    .selections
378                    .iter()
379                    .map(|s| self.move_selection(*s, motion, extend))
380                    .collect();
381                self.set_selections(moved);
382                self.scroll_to_cursor();
383                Some(vec![PanelEvent::NeedsRedraw])
384            }
385            Action::InsertChar(c) => {
386                let text = c.to_string();
387                self.edit_at_each_selection(EditKind::Insert, move |selection, _buffer| {
388                    let (start, end) = selection.range();
389                    Edit {
390                        start,
391                        end,
392                        text: text.clone(),
393                    }
394                })
395            }
396
397            // `Other`, not `Insert`: a newline ends the typing run, so undo
398            // after Enter takes back the line rather than the paragraph.
399            Action::InsertNewline => {
400                self.edit_at_each_selection(EditKind::Other, |selection, _buffer| {
401                    let (start, end) = selection.range();
402                    Edit {
403                        start,
404                        end,
405                        text: "\n".to_string(),
406                    }
407                })
408            }
409
410            Action::Delete { direction, by_word } => {
411                self.edit_at_each_selection(EditKind::Delete, move |selection, buffer| {
412                    // A non-empty selection is the target, whichever key was
413                    // pressed.
414                    if !selection.is_empty() {
415                        let (start, end) = selection.range();
416                        return Edit::delete(start, end);
417                    }
418
419                    let head = selection.head;
420                    // One line, not every line: a word boundary never reaches
421                    // past the line it is on.
422                    let line_len = buffer.line_grapheme_count(head.line);
423
424                    match direction {
425                        Direction::Backward => {
426                            if head.col > 0 {
427                                let target = if by_word {
428                                    buffer.with_line_str(head.line, |line| {
429                                        previous_word_boundary(line, head.col)
430                                    })
431                                } else {
432                                    head.col - 1
433                                };
434                                Edit::delete(
435                                    Position {
436                                        line: head.line,
437                                        col: target,
438                                    },
439                                    head,
440                                )
441                            } else if head.line > 0 {
442                                // Join with the previous line: delete the
443                                // newline between them.
444                                let previous = head.line - 1;
445                                let col = buffer.line_grapheme_count(previous);
446                                Edit::delete(
447                                    Position {
448                                        line: previous,
449                                        col,
450                                    },
451                                    head,
452                                )
453                            } else {
454                                Edit::nothing(head)
455                            }
456                        }
457                        Direction::Forward => {
458                            if head.col < line_len {
459                                let target = if by_word {
460                                    buffer.with_line_str(head.line, |line| {
461                                        next_word_boundary(line, head.col)
462                                    })
463                                } else {
464                                    head.col + 1
465                                };
466                                Edit::delete(
467                                    head,
468                                    Position {
469                                        line: head.line,
470                                        col: target,
471                                    },
472                                )
473                            } else if head.line + 1 < buffer.line_count() {
474                                // At the end of a line, pull the next one up.
475                                Edit::delete(
476                                    head,
477                                    Position {
478                                        line: head.line + 1,
479                                        col: 0,
480                                    },
481                                )
482                            } else {
483                                Edit::nothing(head)
484                            }
485                        }
486                    }
487                })
488            }
489
490            Action::Undo => {
491                // No clamping: these selections were valid against this exact
492                // rope when they were recorded, which is also why undo puts the
493                // cursor back where the edit was made rather than wherever the
494                // clamp happened to land it.
495                if let Some(restored) = self.buffer.undo(&self.selections) {
496                    self.selections = restored;
497                    self.goal_col = None;
498                }
499                self.scroll_to_cursor();
500                Some(vec![PanelEvent::NeedsRedraw])
501            }
502
503            Action::Redo => {
504                if let Some(restored) = self.buffer.redo(&self.selections) {
505                    self.selections = restored;
506                    self.goal_col = None;
507                }
508                self.scroll_to_cursor();
509                Some(vec![PanelEvent::NeedsRedraw])
510            }
511
512            Action::SelectAll => {
513                let last = self.last_line();
514                self.selections.set_single(Selection {
515                    anchor: Position { line: 0, col: 0 },
516                    head: Position {
517                        line: last,
518                        col: self.line_grapheme_count(last),
519                    },
520                });
521                self.goal_col = None;
522                Some(vec![PanelEvent::NeedsRedraw])
523            }
524
525            Action::SelectLine => {
526                let line = self.cursor().line;
527                // Without the newline: selecting it would make the next
528                // keystroke eat the line break, which is not what "select this
529                // line" means to anyone.
530                self.selections.set_single(Selection {
531                    anchor: Position { line, col: 0 },
532                    head: Position {
533                        line,
534                        col: self.line_grapheme_count(line),
535                    },
536                });
537                self.goal_col = None;
538                Some(vec![PanelEvent::NeedsRedraw])
539            }
540
541            Action::SelectNextOccurrence => self.select_next_occurrence(),
542            Action::SelectAllOccurrences => self.select_all_occurrences(),
543
544            Action::CollapseSelections => {
545                self.selections.collapse_to_heads();
546                self.goal_col = None;
547                self.scroll_to_cursor();
548                Some(vec![PanelEvent::NeedsRedraw])
549            }
550
551            Action::AddCursor(direction) => {
552                let from = self.selections.primary().head;
553                let target_line = match direction {
554                    Direction::Backward => from.line.checked_sub(1),
555                    Direction::Forward => {
556                        let next = from.line + 1;
557                        (next <= self.last_line()).then_some(next)
558                    }
559                };
560                let Some(line) = target_line else {
561                    // At the edge of the document there is nowhere to add one.
562                    // Some(vec![]) rather than None: the action was handled and
563                    // simply had nothing to do, so the app must not retry it as
564                    // an app action.
565                    return Some(Vec::new());
566                };
567                let col = from.col.min(self.line_grapheme_count(line));
568                self.selections
569                    .push(Selection::caret(Position { line, col }));
570                self.scroll_to_cursor();
571                Some(vec![PanelEvent::NeedsRedraw])
572            }
573
574            Action::Copy => {
575                let text = self.selected_text();
576                // Copying nothing must not wipe what is already held. Every
577                // editor in the field either copies the whole line or does
578                // nothing; doing nothing is the one that never surprises.
579                if !text.is_empty() {
580                    clipboard::set(&text);
581                }
582                Some(vec![PanelEvent::NeedsRedraw])
583            }
584
585            Action::Cut => {
586                let text = self.selected_text();
587                if text.is_empty() {
588                    return Some(Vec::new());
589                }
590                clipboard::set(&text);
591                // `Other`, so a cut always stands alone in the undo history
592                // rather than folding into a run of typing on either side.
593                self.edit_at_each_selection(EditKind::Other, |selection, _buffer| {
594                    let (start, end) = selection.range();
595                    Edit::delete(start, end)
596                })
597            }
598
599            Action::Paste => {
600                let text = clipboard::get();
601                if text.is_empty() {
602                    return Some(Vec::new());
603                }
604
605                // One clipboard line per cursor when the counts match, which is
606                // what makes a multi-cursor copy round-trip through a paste.
607                // VS Code and Sublime both do this, and without it a three-line
608                // copy stamps all three lines at all three cursors.
609                let lines: Vec<String> = text.lines().map(str::to_string).collect();
610                let distribute = lines.len() == self.selections.len() && self.selections.len() > 1;
611
612                let index = std::cell::Cell::new(0usize);
613                self.edit_at_each_selection(EditKind::Other, move |selection, _buffer| {
614                    let (start, end) = selection.range();
615                    let piece = if distribute {
616                        let i = index.get();
617                        index.set(i + 1);
618                        lines[i].clone()
619                    } else {
620                        text.clone()
621                    };
622                    Edit {
623                        start,
624                        end,
625                        text: piece,
626                    }
627                })
628            }
629
630            // Tab is two behaviours behind one key, which is what every editor
631            // in the field does. With nothing selected it inserts to the next
632            // tab stop, the way typing does. With a selection it shifts every
633            // line the selection touches, because that is what a block indent
634            // means — and inserting there would replace the selection instead.
635            Action::Indent => {
636                if self.selections.iter().all(Selection::is_empty) {
637                    self.edit_at_each_selection(EditKind::Other, |selection, buffer| {
638                        let head = selection.head;
639                        // From the *display* column, so a line containing tabs
640                        // lands on the same stop the renderer draws.
641                        let display = buffer.with_line_str(head.line, |line| {
642                            grapheme_to_display_col(line, head.col, TAB_WIDTH)
643                        });
644                        Edit {
645                            start: head,
646                            end: head,
647                            text: " ".repeat(TAB_WIDTH - (display % TAB_WIDTH)),
648                        }
649                    })
650                } else {
651                    self.shift_lines(true)
652                }
653            }
654
655            // Outdent always works on lines. Shift+Tab at a bare caret means
656            // "unindent this line", not "delete something to my left".
657            Action::Outdent => self.shift_lines(false),
658
659            // Not this panel's business. The app tries it next.
660            _ => None,
661        }
662    }
663}
664
665/// One edit, described rather than performed: replace `start..end` with `text`.
666///
667/// An empty range inserts and an empty text deletes, so every editing action
668/// reduces to this one shape and the position mapping only has to understand
669/// one thing.
670struct Edit {
671    start: Position,
672    end: Position,
673    text: String,
674}
675
676impl Edit {
677    fn delete(start: Position, end: Position) -> Self {
678        Self {
679            start,
680            end,
681            text: String::new(),
682        }
683    }
684
685    /// An edit that changes nothing, for a caret with nowhere to go — the
686    /// start of the buffer for backspace, the end for delete.
687    fn nothing(at: Position) -> Self {
688        Self {
689            start: at,
690            end: at,
691            text: String::new(),
692        }
693    }
694}
695
696/// Where a position ends up once `text` has been inserted at `start`.
697fn position_after(start: Position, text: &str) -> Position {
698    let mut line = start.line;
699    let mut col = start.col;
700    for grapheme in text.graphemes(true) {
701        if grapheme == "\n" || grapheme == "\r\n" {
702            line += 1;
703            col = 0;
704        } else {
705            col += 1;
706        }
707    }
708    Position { line, col }
709}