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: EditorMode,
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: EditorMode::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 == EditorMode::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 EditorMode {
165    Normal,
166    Insert,
167    Replace,
168    Visual,
169    VisualLine,
170    Command,
171    Other(String),
172}
173
174impl EditorMode {
175    pub fn label(&self) -> &str {
176        match self {
177            EditorMode::Normal => "NORMAL",
178            EditorMode::Insert => "INSERT",
179            EditorMode::Replace => "REPLACE",
180            EditorMode::Visual => "VISUAL",
181            EditorMode::VisualLine => "V-LINE",
182            EditorMode::Command => "COMMAND",
183            EditorMode::Other(_) => "OTHER",
184        }
185    }
186
187    /// Parse the one- or two-character mode string returned by `nvim_get_mode`.
188    /// Nvim-only: the vim engine sets its mode directly, never through this.
189    pub fn from_nvim_str(s: &str) -> Self {
190        match s {
191            "n" | "no" | "nov" | "noV" | "no\x16" => EditorMode::Normal,
192            "i" => EditorMode::Insert,
193            "R" => EditorMode::Replace,
194            "v" => EditorMode::Visual,
195            "V" => EditorMode::VisualLine,
196            "c" => EditorMode::Command,
197            other => EditorMode::Other(other.to_string()),
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    fn rev(n: u64) -> NonZeroU64 {
207        NonZeroU64::new(n).unwrap()
208    }
209
210    #[test]
211    fn snapshot_borrowed_passes_cursor_through() {
212        let lines = vec!["a".to_string(), "b".to_string()];
213        let snap = EditorSnapshot::borrowed(&lines, (1, 0), rev(5));
214        assert_eq!(snap.cursor, (1, 0));
215        assert!(snap.cursor_in_bounds());
216        assert_eq!(snap.cursor_line(), "b");
217    }
218
219    #[test]
220    fn snapshot_helpers_on_empty_buffer() {
221        let snap: EditorSnapshot<'_> = EditorSnapshot::owned(Vec::new(), (0, 0), rev(1));
222        assert!(!snap.cursor_in_bounds());
223        assert_eq!(snap.cursor_row_clamped(), 0);
224        assert_eq!(snap.cursor_line(), "");
225    }
226
227    #[test]
228    fn snapshot_cursor_byte_offset_across_rows() {
229        let lines = vec!["hello".to_string(), "wørld".to_string()];
230        // Row 1, col 2 (after 'w', 'ø') — bytes: 'hello\n' = 6 + 'wø' = 3 = 9.
231        let snap = EditorSnapshot::borrowed(&lines, (1, 2), rev(1));
232        assert_eq!(snap.cursor_byte_offset(), 9);
233    }
234
235    #[test]
236    fn snapshot_clamps_stale_cursor_row() {
237        // Tests cursor_row_clamped behavior — the field itself is
238        // populated by the producer, not by these helpers.
239        let lines = vec!["only".to_string()];
240        let snap = EditorSnapshot::borrowed(&lines, (5, 2), rev(1));
241        assert_eq!(snap.cursor_row_clamped(), 0);
242        assert_eq!(snap.cursor_line(), "only");
243    }
244
245    #[test]
246    fn default_snapshot_is_not_dirty() {
247        let snap = NvimSnapshot::default();
248        assert!(!snap.dirty);
249    }
250
251    #[test]
252    fn mode_label_normal() {
253        assert_eq!(EditorMode::Normal.label(), "NORMAL");
254    }
255
256    #[test]
257    fn mode_label_insert() {
258        assert_eq!(EditorMode::Insert.label(), "INSERT");
259    }
260
261    #[test]
262    fn mode_label_visual() {
263        assert_eq!(EditorMode::Visual.label(), "VISUAL");
264    }
265
266    #[test]
267    fn mode_label_visual_line() {
268        assert_eq!(EditorMode::VisualLine.label(), "V-LINE");
269    }
270
271    #[test]
272    fn mode_label_command() {
273        assert_eq!(EditorMode::Command.label(), "COMMAND");
274    }
275
276    #[test]
277    fn mode_from_str_normal() {
278        assert!(matches!(EditorMode::from_nvim_str("n"), EditorMode::Normal));
279    }
280
281    #[test]
282    fn mode_from_str_insert() {
283        assert!(matches!(EditorMode::from_nvim_str("i"), EditorMode::Insert));
284    }
285
286    #[test]
287    fn mode_from_str_visual() {
288        assert!(matches!(EditorMode::from_nvim_str("v"), EditorMode::Visual));
289    }
290
291    #[test]
292    fn mode_from_str_visual_line() {
293        assert!(matches!(
294            EditorMode::from_nvim_str("V"),
295            EditorMode::VisualLine
296        ));
297    }
298
299    #[test]
300    fn mode_from_str_command() {
301        assert!(matches!(
302            EditorMode::from_nvim_str("c"),
303            EditorMode::Command
304        ));
305    }
306
307    #[test]
308    fn mode_from_str_replace() {
309        assert!(matches!(
310            EditorMode::from_nvim_str("R"),
311            EditorMode::Replace
312        ));
313    }
314
315    #[test]
316    fn mode_from_str_unknown() {
317        let m = EditorMode::from_nvim_str("t"); // terminal mode — unmapped
318        assert!(matches!(m, EditorMode::Other(_)));
319        if let EditorMode::Other(s) = m {
320            assert_eq!(s, "t");
321        }
322    }
323
324    #[test]
325    fn footer_label_normal_mode() {
326        let snap = NvimSnapshot {
327            mode: EditorMode::Normal,
328            cmdline: None,
329            ..Default::default()
330        };
331        assert_eq!(snap.footer_label(), "NORMAL");
332    }
333
334    #[test]
335    fn footer_label_command_mode_with_cmdline() {
336        let snap = NvimSnapshot {
337            mode: EditorMode::Command,
338            cmdline: Some(":set nu".to_string()),
339            ..Default::default()
340        };
341        assert_eq!(snap.footer_label(), ":set nu\u{2590}");
342    }
343
344    #[test]
345    fn footer_label_command_mode_no_cmdline() {
346        let snap = NvimSnapshot {
347            mode: EditorMode::Command,
348            cmdline: None,
349            ..Default::default()
350        };
351        assert_eq!(snap.footer_label(), "COMMAND");
352    }
353}