Skip to main content

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}