Skip to main content

kimun_notes/components/text_editor/
snapshot.rs

1use std::num::NonZeroU64;
2
3/// Atomic view of the editor's `(lines, cursor, content_revision)`
4/// tuple at a single point in time. Producers (today
5/// `TextEditorComponent::view_snapshot`) own the construction-time
6/// invariant: the cursor's row is in-bounds for `lines`. Consumers
7/// (`view.rs`, `click_to_logical_u16`, the autocomplete host, etc.)
8/// take a `&EditorSnapshot` and skip the per-leaf `.get()`
9/// guards that previously defended against drift between cursor and
10/// lines.
11///
12/// The `Cow` lets the Textarea backend borrow its lines directly
13/// (zero clone) while the Nvim backend clones out from behind its
14/// `Mutex` (the lines must outlive the `MutexGuard`, which is
15/// dropped before the snapshot is returned).
16pub struct EditorSnapshot {
17    /// The text itself. Cloning one is O(1) — the rope shares its structure — so
18    /// a snapshot *is* the buffer's text rather than a copy of it, and the cursor
19    /// below cannot drift away from what it was read with.
20    pub text: crate::ropetext::Text,
21    /// `(row, col)`, clamped at construction when the producer's source was
22    /// stale. A text always has at least one row, so there is no empty-buffer
23    /// case to special-case.
24    pub cursor: (usize, usize),
25    /// Content identity at construction. Stable across cursor moves;
26    /// bumps on real text changes only (see
27    /// [[decouple-text-revision]]).
28    pub content_revision: NonZeroU64,
29}
30
31impl EditorSnapshot {
32    /// Build one from rows, for the nvim backend and for tests.
33    pub fn borrowed(
34        lines: &[String],
35        cursor: (usize, usize),
36        content_revision: NonZeroU64,
37    ) -> Self {
38        Self {
39            text: crate::ropetext::Text::from(lines.join("\n").as_str()),
40            cursor,
41            content_revision,
42        }
43    }
44
45    /// The hot path: the buffer already holds the text, so nothing is rebuilt.
46    pub fn of_buffer(
47        text: crate::ropetext::Text,
48        cursor: (usize, usize),
49        content_revision: NonZeroU64,
50    ) -> Self {
51        Self {
52            text,
53            cursor,
54            content_revision,
55        }
56    }
57
58    /// Owned-mode constructor for the Nvim backend (lines cloned out
59    /// from behind the `Mutex`) and for tests that don't have a
60    /// long-lived borrow.
61    pub fn owned(
62        lines: Vec<String>,
63        cursor: (usize, usize),
64        content_revision: NonZeroU64,
65    ) -> EditorSnapshot {
66        EditorSnapshot {
67            text: crate::ropetext::Text::from(lines.join("\n").as_str()),
68            cursor,
69            content_revision,
70        }
71    }
72
73    /// `true` when the cursor's row exists. A text always has at least one row,
74    /// so this is false only for a cursor past the end.
75    pub fn cursor_in_bounds(&self) -> bool {
76        self.cursor.0 < self.text.line_count()
77    }
78
79    /// Cursor row, guaranteed to exist.
80    pub fn cursor_row_clamped(&self) -> usize {
81        self.cursor.0.min(self.text.line_count().saturating_sub(1))
82    }
83
84    /// The cursor row's text.
85    pub fn cursor_line(&self) -> std::borrow::Cow<'_, str> {
86        self.text
87            .line(self.cursor_row_clamped())
88            .unwrap_or_default()
89    }
90
91    /// The cursor's byte offset into the whole buffer.
92    ///
93    /// Was a row walk summing lengths; the text addresses it directly. The row
94    /// is clamped and an unrepresentable column falls back to the end of the
95    /// buffer — the return type leaves no way to refuse, and the callers (the
96    /// autocomplete controller) treat the offset as a trigger point rather than
97    /// an edit site.
98    pub fn cursor_byte_offset(&self) -> usize {
99        self.text
100            .position(
101                self.cursor_row_clamped(),
102                crate::ropetext::Column::new(self.cursor.1),
103            )
104            .map(|at| at.byte())
105            .unwrap_or_else(|| self.text.len_bytes())
106    }
107}
108
109/// Cached state from a running `nvim --embed` process.
110///
111/// Written by async refresh tasks; read synchronously by the render path.
112#[derive(Debug, Clone)]
113pub struct NvimSnapshot {
114    /// Buffer lines (0-indexed).
115    pub lines: Vec<String>,
116    /// Cursor position (row, col), 0-indexed.
117    pub cursor: (usize, usize),
118    pub mode: EditorMode,
119    /// Set when mode is `Command` — the full command line including the type prefix
120    /// (e.g., `":set nu"` or `"/pattern"`). `None` in all other modes.
121    pub cmdline: Option<String>,
122    /// `true` after every keystroke, cleared by `mark_saved()`.
123    pub dirty: bool,
124    /// Monotonically increasing; incremented every time `lines` actually changes.
125    /// Used by `view.update()` so the parse cache is rebuilt from fresh content,
126    /// not from whatever lines happened to be in the snapshot when the key was pressed.
127    pub content_gen: u64,
128    /// Active visual selection in logical (row, char-col) coordinates, 0-indexed.
129    /// `None` when not in a visual mode. For `VisualLine` the end col is `usize::MAX`.
130    pub visual_selection: Option<((usize, usize), (usize, usize))>,
131}
132
133impl Default for NvimSnapshot {
134    fn default() -> Self {
135        Self {
136            lines: vec![String::new()],
137            cursor: (0, 0),
138            mode: EditorMode::Normal,
139            cmdline: None,
140            dirty: false,
141            content_gen: 0,
142            visual_selection: None,
143        }
144    }
145}
146
147impl NvimSnapshot {
148    /// The string to display in the footer mode indicator.
149    ///
150    /// In command mode, shows the live command line with a block cursor appended.
151    /// In all other modes, shows the mode label (e.g., `"NORMAL"`).
152    pub fn footer_label(&self) -> String {
153        if self.mode == EditorMode::Command
154            && let Some(cmd) = &self.cmdline
155        {
156            return format!("{}\u{2590}", cmd); // ▐ block cursor
157        }
158        self.mode.label().to_string()
159    }
160}
161
162#[derive(Debug, Clone, PartialEq)]
163pub enum EditorMode {
164    Normal,
165    Insert,
166    Replace,
167    Visual,
168    VisualLine,
169    Command,
170    Other(String),
171}
172
173impl EditorMode {
174    pub fn label(&self) -> &str {
175        match self {
176            EditorMode::Normal => "NORMAL",
177            EditorMode::Insert => "INSERT",
178            EditorMode::Replace => "REPLACE",
179            EditorMode::Visual => "VISUAL",
180            EditorMode::VisualLine => "V-LINE",
181            EditorMode::Command => "COMMAND",
182            EditorMode::Other(_) => "OTHER",
183        }
184    }
185
186    /// Parse the one- or two-character mode string returned by `nvim_get_mode`.
187    /// Nvim-only: the vim engine sets its mode directly, never through this.
188    pub fn from_nvim_str(s: &str) -> Self {
189        match s {
190            "n" | "no" | "nov" | "noV" | "no\x16" => EditorMode::Normal,
191            "i" => EditorMode::Insert,
192            "R" => EditorMode::Replace,
193            "v" => EditorMode::Visual,
194            "V" => EditorMode::VisualLine,
195            "c" => EditorMode::Command,
196            other => EditorMode::Other(other.to_string()),
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    fn rev(n: u64) -> NonZeroU64 {
206        NonZeroU64::new(n).unwrap()
207    }
208
209    #[test]
210    fn snapshot_borrowed_passes_cursor_through() {
211        let lines = vec!["a".to_string(), "b".to_string()];
212        let snap = EditorSnapshot::borrowed(&lines, (1, 0), rev(5));
213        assert_eq!(snap.cursor, (1, 0));
214        assert!(snap.cursor_in_bounds());
215        assert_eq!(snap.cursor_line(), "b");
216    }
217
218    #[test]
219    fn snapshot_helpers_on_empty_buffer() {
220        let snap: EditorSnapshot = EditorSnapshot::owned(Vec::new(), (0, 0), rev(1));
221        // An empty buffer still has one empty row, so (0, 0) is a real place.
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}