typ_panel_editor/lib.rs
1use std::any::Any;
2use std::path::Path;
3
4use anyhow::Result;
5use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
6use ratatui::buffer::Buffer;
7use ratatui::layout::Rect;
8use ratatui::style::Style;
9use ratatui::text::Line;
10use ratatui::widgets::{Block, Paragraph, Widget};
11use typ_buffer::{
12 EditKind, LineEnding, Position, SearchQuery, Selection, Selections, TextBuffer,
13 display_to_grapheme_col, grapheme_to_display_col,
14};
15use typ_core::{KeyChord, Panel, PanelEvent, RenderContext};
16
17pub mod actions;
18pub mod gutter;
19mod occurrence;
20pub mod render;
21
22use crate::gutter::Gutter;
23
24/// Columns a tab occupies, and the width one level of indent inserts.
25///
26/// Public because the status bar states it on screen — `Spaces: 4` — and a
27/// value shown to the user that the shower has to guess at is how the shown
28/// value and the real one drift apart. `.editorconfig` and indent detection at
29/// M2.5 replace the constant, not its readers.
30pub const TAB_WIDTH: usize = 4;
31
32/// Lines beyond the viewport a bracket search may walk before giving up.
33///
34/// A partner just off-screen is worth finding — scrolling one line should not
35/// make a highlight appear from nothing. A partner four hundred lines away is
36/// not: nobody is reading both ends at once, and the scan would be on the
37/// keystroke path. See `typ-buffer/src/brackets.rs`.
38const BRACKET_SEARCH_MARGIN: usize = 64;
39
40pub struct EditorPanel {
41 pub(crate) buffer: TextBuffer,
42 /// Never a bare cursor: a caret is an empty selection, so every editing
43 /// path is written once and works for one cursor or thirty.
44 pub(crate) selections: Selections,
45 pub(crate) top_line: usize,
46 /// Leftmost *display* column drawn. Display, not grapheme: a line of CJK
47 /// scrolls by cells the way it is drawn, not by characters.
48 pub(crate) left_col: usize,
49 /// Display column the cursor "wants", preserved across vertical movement
50 /// so passing through short lines does not permanently lose the column.
51 pub(crate) goal_col: Option<usize>,
52 pub(crate) height: usize,
53 /// Learned at render time, beside `height`: a panel does not know its size
54 /// until it is asked to draw.
55 pub(crate) width: usize,
56 /// Where the current drag began, so a drag extends from the press rather
57 /// than from wherever the cursor happened to be.
58 drag_anchor: Option<Position>,
59 /// The last cell clicked, so a second click in the same place can mean
60 /// "select the word" without a double-click timer.
61 last_click: Option<Position>,
62 /// The gutter. Owned by the panel because its width is a function of this
63 /// buffer's line count, and that width narrows the text area.
64 pub(crate) gutter: Gutter,
65}
66
67impl EditorPanel {
68 // Mirrors TextBuffer::from_str: infallible construction, so the FromStr
69 // trait's Result shape would misrepresent it.
70 #[allow(clippy::should_implement_trait)]
71 pub fn from_str(s: &str) -> Self {
72 Self::new(TextBuffer::from_str(s))
73 }
74
75 pub fn from_path(path: &Path) -> Result<Self> {
76 Ok(Self::new(TextBuffer::from_path(path)?))
77 }
78
79 /// An empty editor over a file that does not exist yet.
80 pub fn new_at(path: &Path) -> Self {
81 Self::new(TextBuffer::new_at(path))
82 }
83
84 fn new(buffer: TextBuffer) -> Self {
85 Self {
86 buffer,
87 selections: Selections::default(),
88 top_line: 0,
89 left_col: 0,
90 goal_col: None,
91 height: 0,
92 width: 0,
93 drag_anchor: None,
94 last_click: None,
95 gutter: Gutter::default(),
96 }
97 }
98
99 pub fn selections(&self) -> &Selections {
100 &self.selections
101 }
102
103 /// The primary head — where the terminal cursor is drawn.
104 pub fn cursor(&self) -> Position {
105 self.selections.primary().head
106 }
107
108 /// Set selections directly. Test-only: production code goes through
109 /// actions, so every path a user can take is one a test can take.
110 #[doc(hidden)]
111 pub fn set_selections_for_test(&mut self, list: Vec<Selection>) {
112 assert!(!list.is_empty(), "selections are never empty");
113 let mut selections = Selections::single(list[0]);
114 for selection in &list[1..] {
115 selections.push(*selection);
116 }
117 self.selections = selections;
118 }
119
120 pub fn top_line(&self) -> usize {
121 self.top_line
122 }
123
124 pub fn left_col(&self) -> usize {
125 self.left_col
126 }
127
128 pub fn save(&mut self) -> Result<()> {
129 self.buffer.save()
130 }
131
132 /// Whether the file on disk is byte-for-byte what this buffer holds.
133 ///
134 /// This is what makes our own save not come back as an external change: the
135 /// watcher reports the write we just made, and the answer here is yes, so
136 /// nothing happens. No mtime bookkeeping, and no window in which a
137 /// remembered timestamp is stale.
138 ///
139 /// A file that cannot be read at all counts as differing — it has usually
140 /// just been deleted, which the caller needs to hear about.
141 pub fn matches_disk(&self) -> bool {
142 let Some(path) = self.buffer.path() else {
143 return false;
144 };
145 match std::fs::read_to_string(path) {
146 // `text_as_saved`, not `text`: the rope holds LF and a CRLF file on
147 // disk would never compare equal, so every save of a Windows file
148 // would report itself back as an external change.
149 Ok(disk) => disk == self.buffer.text_as_saved(),
150 Err(_) => false,
151 }
152 }
153
154 /// Replace the buffer with what is on disk, keeping the cursor where it can
155 /// still go.
156 ///
157 /// Undo history does not survive: it describes edits against a rope that no
158 /// longer exists, and offering to undo your way back into a file somebody
159 /// else rewrote is worse than starting clean.
160 pub fn reload(&mut self) -> Result<()> {
161 let Some(path) = self.buffer.path().map(Path::to_path_buf) else {
162 return Ok(());
163 };
164 let selections = self.selections.clone();
165 let top_line = self.top_line;
166
167 self.buffer = TextBuffer::from_path(&path)?;
168 self.selections = selections;
169 self.clamp_selections();
170 self.top_line = top_line.min(self.last_line());
171 Ok(())
172 }
173
174 /// Line contents without the trailing newline.
175 pub fn line_text(&self, line: usize) -> String {
176 self.buffer.line_text(line)
177 }
178
179 pub fn line_count(&self) -> usize {
180 self.buffer.line_count()
181 }
182
183 // The app asks through these rather than reaching into `self.buffer`. A
184 // panel's internals are not application state — the same rule
185 // `RenderContext` enforces pointing the other way.
186
187 pub fn path(&self) -> Option<&Path> {
188 self.buffer.path()
189 }
190
191 /// The file's name with no dirty marker on it.
192 ///
193 /// `title()` is what a panel border shows and carries the `*`; the status
194 /// bar draws that state as colour instead, so it needs the bare name.
195 pub fn file_name(&self) -> String {
196 self.buffer
197 .path()
198 .and_then(|p| p.file_name())
199 .and_then(|n| n.to_str())
200 .unwrap_or("untitled")
201 .to_string()
202 }
203
204 pub fn is_dirty(&self) -> bool {
205 self.buffer.is_dirty()
206 }
207
208 pub fn line_ending(&self) -> LineEnding {
209 self.buffer.line_ending()
210 }
211
212 /// Collapse to a single caret at `at`, clearing the goal column.
213 ///
214 /// Every place the old single-cursor code assigned to `self.cursor` now
215 /// goes through here, which is what keeps the selection set the only
216 /// source of truth. Task 7 replaces these callers with actions.
217 pub(crate) fn set_caret(&mut self, at: Position) {
218 // Placing the caret ends the undo run, the same as a motion does. This
219 // is the mouse's half of that rule: click away mid-word and the next
220 // thing typed is a new undo step.
221 self.buffer.undo_boundary();
222 self.selections.set_single(Selection::caret(at));
223 self.goal_col = None;
224 }
225
226 /// Cells the gutter occupies for this buffer.
227 pub(crate) fn gutter_width(&self) -> usize {
228 self.gutter.width(self.buffer.line_count())
229 }
230
231 /// The area inside the border, before the gutter is taken out of it.
232 fn inner_area(area: Rect) -> Rect {
233 Block::bordered().inner(area)
234 }
235
236 /// The text area: inside the border, and to the right of the gutter.
237 ///
238 /// This is an instance method rather than a free function precisely because
239 /// the gutter's width depends on the buffer. Three callers convert between
240 /// screen cells and buffer positions — `render`, `handle_mouse` and
241 /// `cursor_position` — and every one of them must subtract the same number.
242 /// Routing all three through here is what stops a click landing
243 /// `gutter_width` graphemes to the left of the pointer, which is a failure
244 /// no test of the gutter's own output would catch.
245 fn text_area(&self, area: Rect) -> Rect {
246 let inner = Self::inner_area(area);
247 let gutter = (self.gutter_width() as u16).min(inner.width);
248 Rect {
249 x: inner.x + gutter,
250 width: inner.width - gutter,
251 ..inner
252 }
253 }
254
255 /// The gutter's own area, to the left of the text.
256 fn gutter_area(&self, area: Rect) -> Rect {
257 let inner = Self::inner_area(area);
258 Rect {
259 width: (self.gutter_width() as u16).min(inner.width),
260 ..inner
261 }
262 }
263
264 pub(crate) fn line_grapheme_count(&self, line: usize) -> usize {
265 self.buffer.line_grapheme_count(line)
266 }
267
268 pub(crate) fn last_line(&self) -> usize {
269 self.buffer.line_count().saturating_sub(1)
270 }
271
272 /// Keep the cursor inside the viewport after any movement.
273 pub(crate) fn scroll_to_cursor(&mut self) {
274 let cursor = self.cursor();
275
276 if self.height > 0 {
277 if cursor.line < self.top_line {
278 self.top_line = cursor.line;
279 } else if cursor.line >= self.top_line + self.height {
280 self.top_line = cursor.line - self.height + 1;
281 }
282 }
283
284 if self.width > 0 {
285 let col = self.cursor_display_col(cursor);
286 if col < self.left_col {
287 self.left_col = col;
288 } else if col >= self.left_col + self.width {
289 // Keep the cursor one column inside the right edge so the
290 // character being typed is visible rather than flush against
291 // the border.
292 self.left_col = col + 1 - self.width;
293 }
294 }
295 }
296
297 /// The display column a cursor sits at, tabs expanded.
298 fn cursor_display_col(&self, cursor: Position) -> usize {
299 self.buffer.with_line_str(cursor.line, |line| {
300 grapheme_to_display_col(line, cursor.col, TAB_WIDTH)
301 })
302 }
303
304 /// Rows a page motion covers. Before the first frame the height is unknown,
305 /// so fall back to a screenful rather than moving nowhere.
306 pub(crate) fn page(&self) -> usize {
307 self.height.max(1)
308 }
309
310 /// Every match in the buffer.
311 ///
312 /// The app asks through here rather than reaching into `self.buffer`: a
313 /// panel's internals are not application state, which is the same rule
314 /// `RenderContext` enforces pointing the other way.
315 ///
316 /// ponytail: this scans the whole buffer — 5.4–8.7 ms on a 50k-line file,
317 /// re-measured at v0.2.3 against a 16 ms keystroke budget. Fine for
318 /// answering Enter, too slow to run on every keystroke, and the one budget
319 /// in the project with less than an order of magnitude of headroom
320 /// (gap-analysis defect 38).
321 ///
322 /// `Ctrl+D` already avoids it: `TextBuffer::find_next` searches from the
323 /// cursor and stops at the first hit, at 3.89 µs per press. An incremental
324 /// search box wants the same shape — viewport first, the rest completed off
325 /// the render thread. See `typ-buffer/tests/perf.rs`.
326 pub fn buffer_find_all(&self, query: &SearchQuery) -> Vec<Selection> {
327 self.buffer.find_all(query)
328 }
329
330 /// Select a range and scroll it into view.
331 pub fn select_range(&mut self, selection: Selection) {
332 self.selections.set_single(selection);
333 self.goal_col = None;
334 self.scroll_to_cursor();
335 }
336
337 /// Put the caret at the start of a line and centre it in the viewport.
338 ///
339 /// Centred rather than merely scrolled into view: `scroll_to_cursor` moves
340 /// the minimum, which after a jump leaves the target line on whichever edge
341 /// it entered from. That is technically visible and useless — you jumped
342 /// there to read *around* it, and half the context is off-screen.
343 ///
344 /// Out-of-range clamps to the last line. Someone typing 9999 means the end
345 /// of the file, and erroring at them is pedantry rather than correctness.
346 pub fn goto_line(&mut self, line: usize) {
347 let line = line.min(self.last_line());
348 self.set_caret(Position { line, col: 0 });
349
350 if self.height > 0 {
351 // Saturating: near the top of the file there is nothing above to
352 // scroll into, and the first screenful is its own context.
353 self.top_line = line.saturating_sub(self.height / 2);
354 }
355 self.scroll_to_cursor();
356 }
357
358 /// Replace every match, as one undo step. Returns how many.
359 pub fn replace_all(&mut self, query: &SearchQuery, replacement: &str) -> usize {
360 let hits = self.buffer.find_all(query);
361 if hits.is_empty() {
362 return 0;
363 }
364
365 // `Other`, so a replace-all is always its own undo step and never folds
366 // into a run of typing that happened either side of it.
367 self.buffer
368 .begin_edit_group(EditKind::Other, &self.selections);
369 // Backwards, so each replacement leaves the earlier hits' positions
370 // untouched — the same reason multi-caret edits run in reverse.
371 for hit in hits.iter().rev() {
372 let (start, end) = hit.range();
373 self.buffer.replace_range(start, end, replacement);
374 }
375 self.buffer.end_edit_group();
376
377 self.clamp_selections();
378 hits.len()
379 }
380
381 /// Pull every selection back inside the text.
382 ///
383 /// Only replace-all needs this. Undo and redo restore selections that were
384 /// recorded against the very rope being restored, so they are in range by
385 /// construction; a replace rewrites text underneath selections that were
386 /// never recorded anywhere.
387 fn clamp_selections(&mut self) {
388 let last_line = self.last_line();
389 let buffer = &self.buffer;
390 let clamp = |p: Position| {
391 let line = p.line.min(last_line);
392 Position {
393 line,
394 col: p.col.min(buffer.line_grapheme_count(line)),
395 }
396 };
397 let clamped: Vec<Selection> = self
398 .selections
399 .iter()
400 .map(|s| Selection {
401 anchor: clamp(s.anchor),
402 head: clamp(s.head),
403 })
404 .collect();
405 self.set_selections(clamped);
406 self.goal_col = None;
407 }
408}
409
410impl Panel for EditorPanel {
411 fn name(&self) -> &'static str {
412 "editor"
413 }
414
415 fn title(&self) -> String {
416 let name = self
417 .buffer
418 .path()
419 .and_then(|p| p.file_name())
420 .and_then(|n| n.to_str())
421 .unwrap_or("untitled")
422 .to_string();
423 if self.buffer.is_dirty() {
424 format!("{name} *")
425 } else {
426 name
427 }
428 }
429
430 fn render(&mut self, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
431 let border = if ctx.is_focused {
432 ctx.theme.border_focused
433 } else {
434 ctx.theme.border
435 };
436 let block = Block::bordered()
437 .border_style(Style::default().fg(border))
438 .title(self.title());
439 block.render(area, buf);
440
441 let text_area = self.text_area(area);
442 let gutter_area = self.gutter_area(area);
443
444 // Height and width are learned here, and the width is the *text* width:
445 // horizontal scrolling measures against the columns text can occupy,
446 // not against the ones the gutter has already taken.
447 self.height = text_area.height as usize;
448 self.width = text_area.width as usize;
449
450 let line_count = self.buffer.line_count();
451 let end = (self.top_line + self.height).min(line_count);
452 let selections: Vec<Selection> = self.selections.iter().copied().collect();
453 let left_col = self.left_col;
454 let cursor_line = self.cursor().line;
455
456 // The gutter is furniture, not text: it is drawn into its own area and
457 // never windowed by `left_col`, so scrolling a long line sideways moves
458 // the code and leaves the numbers standing.
459 let gutter_lines: Vec<Line> = (self.top_line..end)
460 .map(|i| {
461 Line::from(
462 self.gutter
463 .render_line(i, cursor_line, line_count, ctx.theme),
464 )
465 })
466 .collect();
467 Paragraph::new(gutter_lines)
468 .style(
469 Style::default()
470 .fg(ctx.theme.gutter_fg)
471 .bg(ctx.theme.gutter_bg),
472 )
473 .render(gutter_area, buf);
474
475 // Once per frame, not once per line: the match depends on the cursor,
476 // and the search is bounded by the viewport plus a margin so a bracket
477 // whose partner is off-screen costs a bounded walk rather than a scan of
478 // the file.
479 let primary = self.selections.primary();
480 let brackets = typ_buffer::brackets::match_at(
481 &self.buffer,
482 primary.head,
483 self.height + BRACKET_SEARCH_MARGIN,
484 );
485 let text_width = text_area.width as usize;
486
487 let lines: Vec<Line> = (self.top_line..end)
488 .map(|i| {
489 // Only carets tint their line; a line carrying a real selection
490 // is already saying where the user is.
491 let cursor_line = self
492 .selections
493 .iter()
494 .any(|s| s.is_empty() && s.head.line == i);
495 let style = crate::render::LineStyle {
496 line: i,
497 left_col,
498 width: text_width,
499 tab_width: TAB_WIDTH,
500 selections: &selections,
501 primary,
502 cursor_line,
503 brackets,
504 theme: ctx.theme,
505 };
506 self.buffer
507 .with_line_str(i, |text| crate::render::styled_line(text, &style))
508 })
509 .collect();
510 Paragraph::new(lines)
511 .style(Style::default().fg(ctx.theme.fg).bg(ctx.theme.bg))
512 .render(text_area, buf);
513 }
514
515 fn apply_action(&mut self, action: typ_core::Action) -> Option<Vec<PanelEvent>> {
516 self.perform(action)
517 }
518
519 fn cursor_position(&self, panel_area: Rect) -> Option<(u16, u16)> {
520 let inner = self.text_area(panel_area);
521 let cursor = self.cursor();
522 let row = cursor.line.checked_sub(self.top_line)?;
523 if row >= inner.height as usize {
524 return None;
525 }
526 // Scrolled off the left edge is as invisible as scrolled off the right,
527 // so both answer None rather than clamping to an edge the cursor is not
528 // actually at.
529 let col = self.cursor_display_col(cursor).checked_sub(self.left_col)?;
530 if col >= inner.width as usize {
531 return None;
532 }
533 Some((inner.x + col as u16, inner.y + row as u16))
534 }
535
536 /// The editor has no raw-key behavior left.
537 ///
538 /// Every key that does anything here is a keymap row resolving to an
539 /// `Action`, which is the invariant the whole milestone exists to establish:
540 /// a primitive reachable only from a key handler is invisible to the
541 /// command palette and to the vim layer. The M1-era arms that used to live
542 /// here were the last thing violating it.
543 fn handle_key(&mut self, _chord: KeyChord) -> Vec<PanelEvent> {
544 Vec::new()
545 }
546
547 fn handle_mouse(&mut self, event: MouseEvent, panel_area: Rect) -> Vec<PanelEvent> {
548 let at = |panel: &Self, event: &MouseEvent| {
549 // The text area, gutter already subtracted — so a click in the
550 // gutter saturates to column 0 and selects the line its number
551 // labels, which is what clicking a line number means everywhere.
552 let inner = panel.text_area(panel_area);
553 let row = event.row.saturating_sub(inner.y) as usize;
554 // Both offsets apply: a click is at a screen cell, and the text
555 // under it is `top_line` rows down and `left_col` columns across.
556 let col = event.column.saturating_sub(inner.x) as usize + panel.left_col;
557 let line = (panel.top_line + row).min(panel.last_line());
558 Position {
559 line,
560 col: panel
561 .buffer
562 .with_line_str(line, |text| display_to_grapheme_col(text, col, TAB_WIDTH)),
563 }
564 };
565
566 match event.kind {
567 MouseEventKind::Down(MouseButton::Left) => {
568 let position = at(self, &event);
569
570 if event.modifiers.contains(KeyModifiers::ALT) {
571 // Alt+click stacks a cursor: the mouse half of
572 // multi-cursor, with Action::AddCursor as the keyboard half.
573 self.selections.push(Selection::caret(position));
574 self.last_click = Some(position);
575 self.drag_anchor = Some(position);
576 return vec![PanelEvent::NeedsRedraw];
577 }
578
579 if self.last_click == Some(position) {
580 // A second click in the same cell selects the word under
581 // it. No timing check: clicking the same cell twice is
582 // deliberate, and a double-click timer would put a clock on
583 // the render path to distinguish two things a user does not
584 // confuse.
585 let text = self.buffer.line_text(position.line);
586 if let Some((start, end)) = typ_buffer::word_at(&text, position.col) {
587 self.selections.set_single(Selection {
588 anchor: Position {
589 line: position.line,
590 col: start,
591 },
592 head: Position {
593 line: position.line,
594 col: end,
595 },
596 });
597 self.drag_anchor = None;
598 self.goal_col = None;
599 return vec![PanelEvent::NeedsRedraw];
600 }
601 }
602
603 self.set_caret(position);
604 self.drag_anchor = Some(position);
605 self.last_click = Some(position);
606 vec![PanelEvent::NeedsRedraw]
607 }
608
609 MouseEventKind::Drag(MouseButton::Left) => {
610 let Some(anchor) = self.drag_anchor else {
611 // A drag with no press behind it is not ours: it belongs to
612 // whatever panel the press landed in.
613 return Vec::new();
614 };
615 let head = at(self, &event);
616 self.selections.set_single(Selection { anchor, head });
617 self.goal_col = None;
618 vec![PanelEvent::NeedsRedraw]
619 }
620
621 MouseEventKind::Up(MouseButton::Left) => {
622 self.drag_anchor = None;
623 Vec::new()
624 }
625
626 // Invariant 8 — mouse and keyboard are peers. A clipboard reachable
627 // only from the keyboard is half a feature.
628 //
629 // Right-click *inside* a selection copies it and leaves it standing.
630 // Outside one it does nothing: the alternative is copying whatever
631 // happens to be selected elsewhere, which silently replaces the
632 // clipboard on a misclick.
633 MouseEventKind::Down(MouseButton::Right) => {
634 let position = at(self, &event);
635 let inside = self
636 .selections
637 .iter()
638 .any(|s| !s.is_empty() && s.range().0 <= position && position < s.range().1);
639 if !inside {
640 return Vec::new();
641 }
642 self.perform(typ_core::Action::Copy).unwrap_or_default()
643 }
644
645 // Middle-click pastes at the pointer, the X11 convention every
646 // terminal user already has in their hands.
647 MouseEventKind::Down(MouseButton::Middle) => {
648 let position = at(self, &event);
649 self.set_caret(position);
650 self.last_click = Some(position);
651 self.perform(typ_core::Action::Paste).unwrap_or_default()
652 }
653
654 _ => Vec::new(),
655 }
656 }
657
658 fn handle_scroll(&mut self, delta: i32, _panel_area: Rect) -> Vec<PanelEvent> {
659 let max_top = self.buffer.line_count().saturating_sub(self.height.max(1));
660 self.top_line = (self.top_line as i64 + delta as i64).clamp(0, max_top as i64) as usize;
661 vec![PanelEvent::NeedsRedraw]
662 }
663
664 fn needs_close_confirmation(&self) -> Option<String> {
665 self.buffer
666 .is_dirty()
667 .then(|| "Unsaved changes. Close anyway?".to_string())
668 }
669
670 fn as_any(&self) -> &dyn Any {
671 self
672 }
673 fn as_any_mut(&mut self) -> &mut dyn Any {
674 self
675 }
676}