Skip to main content

kimun_notes/components/text_editor/
snapshot.rs

1use std::borrow::Cow;
2use std::num::NonZeroU64;
3
4/// Atomic view of the editor's `(lines, cursor, content_revision)`
5/// tuple at a single point in time. Producers (today
6/// `TextEditorComponent::view_snapshot`) own the construction-time
7/// invariant: the cursor's row is in-bounds for `lines`. Consumers
8/// (`view.rs`, `click_to_logical_u16`, the autocomplete host, etc.)
9/// take a `&EditorSnapshot<'_>` and skip the per-leaf `.get()`
10/// guards that previously defended against drift between cursor and
11/// lines.
12///
13/// The `Cow` lets the Textarea backend borrow its lines directly
14/// (zero clone) while the Nvim backend clones out from behind its
15/// `Mutex` (the lines must outlive the `MutexGuard`, which is
16/// dropped before the snapshot is returned).
17pub struct EditorSnapshot<'a> {
18    pub lines: Cow<'a, [String]>,
19    /// `(row, col)` with `row < lines.len()` (clamped at
20    /// construction when the producer's source was stale) UNLESS
21    /// `lines.is_empty()`, in which case the snapshot represents an
22    /// empty buffer and `cursor` is `(0, 0)`.
23    pub cursor: (usize, usize),
24    /// Content identity at construction. Stable across cursor moves;
25    /// bumps on real text changes only (see
26    /// [[decouple-text-revision]]).
27    pub content_revision: NonZeroU64,
28}
29
30impl<'a> EditorSnapshot<'a> {
31    /// Borrow-mode constructor for the Textarea backend and tests.
32    pub fn borrowed(
33        lines: &'a [String],
34        cursor: (usize, usize),
35        content_revision: NonZeroU64,
36    ) -> Self {
37        Self {
38            lines: Cow::Borrowed(lines),
39            cursor,
40            content_revision,
41        }
42    }
43
44    /// Owned-mode constructor for the Nvim backend (lines cloned out
45    /// from behind the `Mutex`) and for tests that don't have a
46    /// long-lived borrow.
47    pub fn owned(
48        lines: Vec<String>,
49        cursor: (usize, usize),
50        content_revision: NonZeroU64,
51    ) -> EditorSnapshot<'static> {
52        EditorSnapshot {
53            lines: Cow::Owned(lines),
54            cursor,
55            content_revision,
56        }
57    }
58
59    /// `true` when the cursor row is a valid index into `lines`.
60    /// `false` only when `lines` is empty (in which case both row 0
61    /// and any other row are out of bounds).
62    pub fn cursor_in_bounds(&self) -> bool {
63        self.cursor.0 < self.lines.len()
64    }
65
66    /// Cursor row guaranteed in-bounds for `lines`. Returns `0` on
67    /// an empty buffer (the only case where the producer cannot
68    /// clamp to a valid index).
69    pub fn cursor_row_clamped(&self) -> usize {
70        if self.lines.is_empty() {
71            0
72        } else {
73            self.cursor.0.min(self.lines.len() - 1)
74        }
75    }
76
77    /// Cursor row's logical line. Returns the empty slice when
78    /// `lines` is empty.
79    pub fn cursor_line(&self) -> &str {
80        self.lines
81            .get(self.cursor_row_clamped())
82            .map(String::as_str)
83            .unwrap_or("")
84    }
85
86    /// Global byte offset of the cursor across `lines.join("\n")`.
87    /// Mirrors `autocomplete_glue::row_char_col_to_byte` but consumes
88    /// the snapshot directly so callers (e.g. the autocomplete
89    /// controller) don't need to depend on the editor's glue
90    /// module. Clamps the char column to the row's char count, then
91    /// returns the byte position of the char-column within the
92    /// joined buffer.
93    pub fn cursor_byte_offset(&self) -> usize {
94        let row = self.cursor.0;
95        let mut byte = 0;
96        for line in self.lines.iter().take(row) {
97            byte += line.len() + 1; // +1 for the implicit `\n` separator
98        }
99        let Some(line) = self.lines.get(row) else {
100            return byte;
101        };
102        byte + line
103            .char_indices()
104            .nth(self.cursor.1)
105            .map(|(b, _)| b)
106            .unwrap_or(line.len())
107    }
108}
109
110/// Cached state from a running `nvim --embed` process.
111///
112/// Written by async refresh tasks; read synchronously by the render path.
113#[derive(Debug, Clone)]
114pub struct NvimSnapshot {
115    /// Buffer lines (0-indexed).
116    pub lines: Vec<String>,
117    /// Cursor position (row, col), 0-indexed.
118    pub cursor: (usize, usize),
119    pub mode: NvimMode,
120    /// Set when mode is `Command` — the full command line including the type prefix
121    /// (e.g., `":set nu"` or `"/pattern"`). `None` in all other modes.
122    pub cmdline: Option<String>,
123    /// `true` after every keystroke, cleared by `mark_saved()`.
124    pub dirty: bool,
125    /// Monotonically increasing; incremented every time `lines` actually changes.
126    /// Used by `view.update()` so the parse cache is rebuilt from fresh content,
127    /// not from whatever lines happened to be in the snapshot when the key was pressed.
128    pub content_gen: u64,
129    /// Active visual selection in logical (row, char-col) coordinates, 0-indexed.
130    /// `None` when not in a visual mode. For `VisualLine` the end col is `usize::MAX`.
131    pub visual_selection: Option<((usize, usize), (usize, usize))>,
132}
133
134impl Default for NvimSnapshot {
135    fn default() -> Self {
136        Self {
137            lines: vec![String::new()],
138            cursor: (0, 0),
139            mode: NvimMode::Normal,
140            cmdline: None,
141            dirty: false,
142            content_gen: 0,
143            visual_selection: None,
144        }
145    }
146}
147
148impl NvimSnapshot {
149    /// The string to display in the footer mode indicator.
150    ///
151    /// In command mode, shows the live command line with a block cursor appended.
152    /// In all other modes, shows the mode label (e.g., `"NORMAL"`).
153    pub fn footer_label(&self) -> String {
154        if self.mode == NvimMode::Command
155            && let Some(cmd) = &self.cmdline
156        {
157            return format!("{}\u{2590}", cmd); // ▐ block cursor
158        }
159        self.mode.label().to_string()
160    }
161}
162
163#[derive(Debug, Clone, PartialEq)]
164pub enum NvimMode {
165    Normal,
166    Insert,
167    Visual,
168    VisualLine,
169    Command,
170    Other(String),
171}
172
173impl NvimMode {
174    pub fn label(&self) -> &str {
175        match self {
176            NvimMode::Normal => "NORMAL",
177            NvimMode::Insert => "INSERT",
178            NvimMode::Visual => "VISUAL",
179            NvimMode::VisualLine => "V-LINE",
180            NvimMode::Command => "COMMAND",
181            NvimMode::Other(_) => "OTHER",
182        }
183    }
184
185    /// Parse the one- or two-character mode string returned by `nvim_get_mode`.
186    pub fn from_nvim_str(s: &str) -> Self {
187        match s {
188            "n" | "no" | "nov" | "noV" | "no\x16" => NvimMode::Normal,
189            "i" => NvimMode::Insert,
190            "v" => NvimMode::Visual,
191            "V" => NvimMode::VisualLine,
192            "c" => NvimMode::Command,
193            other => NvimMode::Other(other.to_string()),
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn rev(n: u64) -> NonZeroU64 {
203        NonZeroU64::new(n).unwrap()
204    }
205
206    #[test]
207    fn snapshot_borrowed_passes_cursor_through() {
208        let lines = vec!["a".to_string(), "b".to_string()];
209        let snap = EditorSnapshot::borrowed(&lines, (1, 0), rev(5));
210        assert_eq!(snap.cursor, (1, 0));
211        assert!(snap.cursor_in_bounds());
212        assert_eq!(snap.cursor_line(), "b");
213    }
214
215    #[test]
216    fn snapshot_helpers_on_empty_buffer() {
217        let snap: EditorSnapshot<'_> = EditorSnapshot::owned(Vec::new(), (0, 0), rev(1));
218        assert!(!snap.cursor_in_bounds());
219        assert_eq!(snap.cursor_row_clamped(), 0);
220        assert_eq!(snap.cursor_line(), "");
221    }
222
223    #[test]
224    fn snapshot_cursor_byte_offset_across_rows() {
225        let lines = vec!["hello".to_string(), "wørld".to_string()];
226        // Row 1, col 2 (after 'w', 'ø') — bytes: 'hello\n' = 6 + 'wø' = 3 = 9.
227        let snap = EditorSnapshot::borrowed(&lines, (1, 2), rev(1));
228        assert_eq!(snap.cursor_byte_offset(), 9);
229    }
230
231    #[test]
232    fn snapshot_clamps_stale_cursor_row() {
233        // Tests cursor_row_clamped behavior — the field itself is
234        // populated by the producer, not by these helpers.
235        let lines = vec!["only".to_string()];
236        let snap = EditorSnapshot::borrowed(&lines, (5, 2), rev(1));
237        assert_eq!(snap.cursor_row_clamped(), 0);
238        assert_eq!(snap.cursor_line(), "only");
239    }
240
241    #[test]
242    fn default_snapshot_is_not_dirty() {
243        let snap = NvimSnapshot::default();
244        assert!(!snap.dirty);
245    }
246
247    #[test]
248    fn mode_label_normal() {
249        assert_eq!(NvimMode::Normal.label(), "NORMAL");
250    }
251
252    #[test]
253    fn mode_label_insert() {
254        assert_eq!(NvimMode::Insert.label(), "INSERT");
255    }
256
257    #[test]
258    fn mode_label_visual() {
259        assert_eq!(NvimMode::Visual.label(), "VISUAL");
260    }
261
262    #[test]
263    fn mode_label_visual_line() {
264        assert_eq!(NvimMode::VisualLine.label(), "V-LINE");
265    }
266
267    #[test]
268    fn mode_label_command() {
269        assert_eq!(NvimMode::Command.label(), "COMMAND");
270    }
271
272    #[test]
273    fn mode_from_str_normal() {
274        assert!(matches!(NvimMode::from_nvim_str("n"), NvimMode::Normal));
275    }
276
277    #[test]
278    fn mode_from_str_insert() {
279        assert!(matches!(NvimMode::from_nvim_str("i"), NvimMode::Insert));
280    }
281
282    #[test]
283    fn mode_from_str_visual() {
284        assert!(matches!(NvimMode::from_nvim_str("v"), NvimMode::Visual));
285    }
286
287    #[test]
288    fn mode_from_str_visual_line() {
289        assert!(matches!(NvimMode::from_nvim_str("V"), NvimMode::VisualLine));
290    }
291
292    #[test]
293    fn mode_from_str_command() {
294        assert!(matches!(NvimMode::from_nvim_str("c"), NvimMode::Command));
295    }
296
297    #[test]
298    fn mode_from_str_unknown() {
299        let m = NvimMode::from_nvim_str("R");
300        assert!(matches!(m, NvimMode::Other(_)));
301        if let NvimMode::Other(s) = m {
302            assert_eq!(s, "R");
303        }
304    }
305
306    #[test]
307    fn footer_label_normal_mode() {
308        let snap = NvimSnapshot {
309            mode: NvimMode::Normal,
310            cmdline: None,
311            ..Default::default()
312        };
313        assert_eq!(snap.footer_label(), "NORMAL");
314    }
315
316    #[test]
317    fn footer_label_command_mode_with_cmdline() {
318        let snap = NvimSnapshot {
319            mode: NvimMode::Command,
320            cmdline: Some(":set nu".to_string()),
321            ..Default::default()
322        };
323        assert_eq!(snap.footer_label(), ":set nu\u{2590}");
324    }
325
326    #[test]
327    fn footer_label_command_mode_no_cmdline() {
328        let snap = NvimSnapshot {
329            mode: NvimMode::Command,
330            cmdline: None,
331            ..Default::default()
332        };
333        assert_eq!(snap.footer_label(), "COMMAND");
334    }
335}