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}