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, Position, SearchQuery, Selection, Selections, TextBuffer, display_to_grapheme_col,
13    grapheme_to_display_col,
14};
15use typ_core::{KeyChord, Panel, PanelEvent, RenderContext};
16
17pub mod actions;
18pub mod render;
19
20pub(crate) const TAB_WIDTH: usize = 4;
21
22pub struct EditorPanel {
23    pub(crate) buffer: TextBuffer,
24    /// Never a bare cursor: a caret is an empty selection, so every editing
25    /// path is written once and works for one cursor or thirty.
26    pub(crate) selections: Selections,
27    pub(crate) top_line: usize,
28    /// Leftmost *display* column drawn. Display, not grapheme: a line of CJK
29    /// scrolls by cells the way it is drawn, not by characters.
30    pub(crate) left_col: usize,
31    /// Display column the cursor "wants", preserved across vertical movement
32    /// so passing through short lines does not permanently lose the column.
33    pub(crate) goal_col: Option<usize>,
34    pub(crate) height: usize,
35    /// Learned at render time, beside `height`: a panel does not know its size
36    /// until it is asked to draw.
37    pub(crate) width: usize,
38    /// Where the current drag began, so a drag extends from the press rather
39    /// than from wherever the cursor happened to be.
40    drag_anchor: Option<Position>,
41    /// The last cell clicked, so a second click in the same place can mean
42    /// "select the word" without a double-click timer.
43    last_click: Option<Position>,
44}
45
46impl EditorPanel {
47    // Mirrors TextBuffer::from_str: infallible construction, so the FromStr
48    // trait's Result shape would misrepresent it.
49    #[allow(clippy::should_implement_trait)]
50    pub fn from_str(s: &str) -> Self {
51        Self::new(TextBuffer::from_str(s))
52    }
53
54    pub fn from_path(path: &Path) -> Result<Self> {
55        Ok(Self::new(TextBuffer::from_path(path)?))
56    }
57
58    fn new(buffer: TextBuffer) -> Self {
59        Self {
60            buffer,
61            selections: Selections::default(),
62            top_line: 0,
63            left_col: 0,
64            goal_col: None,
65            height: 0,
66            width: 0,
67            drag_anchor: None,
68            last_click: None,
69        }
70    }
71
72    pub fn selections(&self) -> &Selections {
73        &self.selections
74    }
75
76    /// The primary head — where the terminal cursor is drawn.
77    pub fn cursor(&self) -> Position {
78        self.selections.primary().head
79    }
80
81    /// Set selections directly. Test-only: production code goes through
82    /// actions, so every path a user can take is one a test can take.
83    #[doc(hidden)]
84    pub fn set_selections_for_test(&mut self, list: Vec<Selection>) {
85        assert!(!list.is_empty(), "selections are never empty");
86        let mut selections = Selections::single(list[0]);
87        for selection in &list[1..] {
88            selections.push(*selection);
89        }
90        self.selections = selections;
91    }
92
93    pub fn top_line(&self) -> usize {
94        self.top_line
95    }
96
97    pub fn left_col(&self) -> usize {
98        self.left_col
99    }
100
101    pub fn save(&mut self) -> Result<()> {
102        self.buffer.save()
103    }
104
105    /// Line contents without the trailing newline.
106    pub fn line_text(&self, line: usize) -> String {
107        self.buffer.line_text(line)
108    }
109
110    /// Collapse to a single caret at `at`, clearing the goal column.
111    ///
112    /// Every place the old single-cursor code assigned to `self.cursor` now
113    /// goes through here, which is what keeps the selection set the only
114    /// source of truth. Task 7 replaces these callers with actions.
115    pub(crate) fn set_caret(&mut self, at: Position) {
116        // Placing the caret ends the undo run, the same as a motion does. This
117        // is the mouse's half of that rule: click away mid-word and the next
118        // thing typed is a new undo step.
119        self.buffer.undo_boundary();
120        self.selections.set_single(Selection::caret(at));
121        self.goal_col = None;
122    }
123
124    /// The text area inside the panel's border.
125    fn text_area(area: Rect) -> Rect {
126        Block::bordered().inner(area)
127    }
128
129    pub(crate) fn line_grapheme_count(&self, line: usize) -> usize {
130        self.buffer.line_grapheme_count(line)
131    }
132
133    pub(crate) fn last_line(&self) -> usize {
134        self.buffer.line_count().saturating_sub(1)
135    }
136
137    /// Keep the cursor inside the viewport after any movement.
138    pub(crate) fn scroll_to_cursor(&mut self) {
139        let cursor = self.cursor();
140
141        if self.height > 0 {
142            if cursor.line < self.top_line {
143                self.top_line = cursor.line;
144            } else if cursor.line >= self.top_line + self.height {
145                self.top_line = cursor.line - self.height + 1;
146            }
147        }
148
149        if self.width > 0 {
150            let col = self.cursor_display_col(cursor);
151            if col < self.left_col {
152                self.left_col = col;
153            } else if col >= self.left_col + self.width {
154                // Keep the cursor one column inside the right edge so the
155                // character being typed is visible rather than flush against
156                // the border.
157                self.left_col = col + 1 - self.width;
158            }
159        }
160    }
161
162    /// The display column a cursor sits at, tabs expanded.
163    fn cursor_display_col(&self, cursor: Position) -> usize {
164        self.buffer.with_line_str(cursor.line, |line| {
165            grapheme_to_display_col(line, cursor.col, TAB_WIDTH)
166        })
167    }
168
169    /// Rows a page motion covers. Before the first frame the height is unknown,
170    /// so fall back to a screenful rather than moving nowhere.
171    pub(crate) fn page(&self) -> usize {
172        self.height.max(1)
173    }
174
175    /// Every match in the buffer.
176    ///
177    /// The app asks through here rather than reaching into `self.buffer`: a
178    /// panel's internals are not application state, which is the same rule
179    /// `RenderContext` enforces pointing the other way.
180    ///
181    /// ponytail: this scans the whole buffer, which is ~10 ms on a 50k-line
182    /// file — fine for answering Enter, too slow to run on every keystroke.
183    /// An incremental search box scans the viewport first and completes off
184    /// the render thread; see `typ-buffer/tests/perf.rs`.
185    pub fn buffer_find_all(&self, query: &SearchQuery) -> Vec<Selection> {
186        self.buffer.find_all(query)
187    }
188
189    /// Select a range and scroll it into view.
190    pub fn select_range(&mut self, selection: Selection) {
191        self.selections.set_single(selection);
192        self.goal_col = None;
193        self.scroll_to_cursor();
194    }
195
196    /// Replace every match, as one undo step. Returns how many.
197    pub fn replace_all(&mut self, query: &SearchQuery, replacement: &str) -> usize {
198        let hits = self.buffer.find_all(query);
199        if hits.is_empty() {
200            return 0;
201        }
202
203        // `Other`, so a replace-all is always its own undo step and never folds
204        // into a run of typing that happened either side of it.
205        self.buffer
206            .begin_edit_group(EditKind::Other, &self.selections);
207        // Backwards, so each replacement leaves the earlier hits' positions
208        // untouched — the same reason multi-caret edits run in reverse.
209        for hit in hits.iter().rev() {
210            let (start, end) = hit.range();
211            self.buffer.replace_range(start, end, replacement);
212        }
213        self.buffer.end_edit_group();
214
215        self.clamp_selections();
216        hits.len()
217    }
218
219    /// Pull every selection back inside the text.
220    ///
221    /// Only replace-all needs this. Undo and redo restore selections that were
222    /// recorded against the very rope being restored, so they are in range by
223    /// construction; a replace rewrites text underneath selections that were
224    /// never recorded anywhere.
225    fn clamp_selections(&mut self) {
226        let last_line = self.last_line();
227        let buffer = &self.buffer;
228        let clamp = |p: Position| {
229            let line = p.line.min(last_line);
230            Position {
231                line,
232                col: p.col.min(buffer.line_grapheme_count(line)),
233            }
234        };
235        let clamped: Vec<Selection> = self
236            .selections
237            .iter()
238            .map(|s| Selection {
239                anchor: clamp(s.anchor),
240                head: clamp(s.head),
241            })
242            .collect();
243        self.set_selections(clamped);
244        self.goal_col = None;
245    }
246}
247
248impl Panel for EditorPanel {
249    fn name(&self) -> &'static str {
250        "editor"
251    }
252
253    fn title(&self) -> String {
254        let name = self
255            .buffer
256            .path()
257            .and_then(|p| p.file_name())
258            .and_then(|n| n.to_str())
259            .unwrap_or("untitled")
260            .to_string();
261        if self.buffer.is_dirty() {
262            format!("{name} *")
263        } else {
264            name
265        }
266    }
267
268    fn render(&mut self, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
269        let border = if ctx.is_focused {
270            ctx.theme.border_focused
271        } else {
272            ctx.theme.border
273        };
274        let block = Block::bordered()
275            .border_style(Style::default().fg(border))
276            .title(self.title());
277        let inner = block.inner(area);
278        block.render(area, buf);
279
280        self.height = inner.height as usize;
281        self.width = inner.width as usize;
282        let end = (self.top_line + self.height).min(self.buffer.line_count());
283        let selections: Vec<Selection> = self.selections.iter().copied().collect();
284        let left_col = self.left_col;
285        let lines: Vec<Line> = (self.top_line..end)
286            .map(|i| {
287                self.buffer.with_line_str(i, |text| {
288                    crate::render::styled_line(text, i, left_col, TAB_WIDTH, &selections, ctx.theme)
289                })
290            })
291            .collect();
292        Paragraph::new(lines)
293            .style(Style::default().fg(ctx.theme.fg).bg(ctx.theme.bg))
294            .render(inner, buf);
295    }
296
297    fn apply_action(&mut self, action: typ_core::Action) -> Option<Vec<PanelEvent>> {
298        self.perform(action)
299    }
300
301    fn cursor_position(&self, panel_area: Rect) -> Option<(u16, u16)> {
302        let inner = Self::text_area(panel_area);
303        let cursor = self.cursor();
304        let row = cursor.line.checked_sub(self.top_line)?;
305        if row >= inner.height as usize {
306            return None;
307        }
308        // Scrolled off the left edge is as invisible as scrolled off the right,
309        // so both answer None rather than clamping to an edge the cursor is not
310        // actually at.
311        let col = self.cursor_display_col(cursor).checked_sub(self.left_col)?;
312        if col >= inner.width as usize {
313            return None;
314        }
315        Some((inner.x + col as u16, inner.y + row as u16))
316    }
317
318    /// The editor has no raw-key behavior left.
319    ///
320    /// Every key that does anything here is a keymap row resolving to an
321    /// `Action`, which is the invariant the whole milestone exists to establish:
322    /// a primitive reachable only from a key handler is invisible to the
323    /// command palette and to the vim layer. The M1-era arms that used to live
324    /// here were the last thing violating it.
325    fn handle_key(&mut self, _chord: KeyChord) -> Vec<PanelEvent> {
326        Vec::new()
327    }
328
329    fn handle_mouse(&mut self, event: MouseEvent, panel_area: Rect) -> Vec<PanelEvent> {
330        let at = |panel: &Self, event: &MouseEvent| {
331            let inner = Self::text_area(panel_area);
332            let row = event.row.saturating_sub(inner.y) as usize;
333            // Both offsets apply: a click is at a screen cell, and the text
334            // under it is `top_line` rows down and `left_col` columns across.
335            let col = event.column.saturating_sub(inner.x) as usize + panel.left_col;
336            let line = (panel.top_line + row).min(panel.last_line());
337            Position {
338                line,
339                col: panel
340                    .buffer
341                    .with_line_str(line, |text| display_to_grapheme_col(text, col, TAB_WIDTH)),
342            }
343        };
344
345        match event.kind {
346            MouseEventKind::Down(MouseButton::Left) => {
347                let position = at(self, &event);
348
349                if event.modifiers.contains(KeyModifiers::ALT) {
350                    // Alt+click stacks a cursor: the mouse half of
351                    // multi-cursor, with Action::AddCursor as the keyboard half.
352                    self.selections.push(Selection::caret(position));
353                    self.last_click = Some(position);
354                    self.drag_anchor = Some(position);
355                    return vec![PanelEvent::NeedsRedraw];
356                }
357
358                if self.last_click == Some(position) {
359                    // A second click in the same cell selects the word under
360                    // it. No timing check: clicking the same cell twice is
361                    // deliberate, and a double-click timer would put a clock on
362                    // the render path to distinguish two things a user does not
363                    // confuse.
364                    let text = self.buffer.line_text(position.line);
365                    if let Some((start, end)) = typ_buffer::word_at(&text, position.col) {
366                        self.selections.set_single(Selection {
367                            anchor: Position {
368                                line: position.line,
369                                col: start,
370                            },
371                            head: Position {
372                                line: position.line,
373                                col: end,
374                            },
375                        });
376                        self.drag_anchor = None;
377                        self.goal_col = None;
378                        return vec![PanelEvent::NeedsRedraw];
379                    }
380                }
381
382                self.set_caret(position);
383                self.drag_anchor = Some(position);
384                self.last_click = Some(position);
385                vec![PanelEvent::NeedsRedraw]
386            }
387
388            MouseEventKind::Drag(MouseButton::Left) => {
389                let Some(anchor) = self.drag_anchor else {
390                    // A drag with no press behind it is not ours: it belongs to
391                    // whatever panel the press landed in.
392                    return Vec::new();
393                };
394                let head = at(self, &event);
395                self.selections.set_single(Selection { anchor, head });
396                self.goal_col = None;
397                vec![PanelEvent::NeedsRedraw]
398            }
399
400            MouseEventKind::Up(MouseButton::Left) => {
401                self.drag_anchor = None;
402                Vec::new()
403            }
404
405            _ => Vec::new(),
406        }
407    }
408
409    fn handle_scroll(&mut self, delta: i32, _panel_area: Rect) -> Vec<PanelEvent> {
410        let max_top = self.buffer.line_count().saturating_sub(self.height.max(1));
411        self.top_line = (self.top_line as i64 + delta as i64).clamp(0, max_top as i64) as usize;
412        vec![PanelEvent::NeedsRedraw]
413    }
414
415    fn needs_close_confirmation(&self) -> Option<String> {
416        self.buffer
417            .is_dirty()
418            .then(|| "Unsaved changes. Close anyway?".to_string())
419    }
420
421    fn as_any(&self) -> &dyn Any {
422        self
423    }
424    fn as_any_mut(&mut self) -> &mut dyn Any {
425        self
426    }
427}