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}