kimun_notes/components/text_editor/
snapshot.rs1use std::borrow::Cow;
2use std::num::NonZeroU64;
3
4pub struct EditorSnapshot<'a> {
18 pub lines: Cow<'a, [String]>,
19 pub cursor: (usize, usize),
24 pub content_revision: NonZeroU64,
28}
29
30impl<'a> EditorSnapshot<'a> {
31 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 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 pub fn cursor_in_bounds(&self) -> bool {
63 self.cursor.0 < self.lines.len()
64 }
65
66 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 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 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; }
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#[derive(Debug, Clone)]
114pub struct NvimSnapshot {
115 pub lines: Vec<String>,
117 pub cursor: (usize, usize),
119 pub mode: EditorMode,
120 pub cmdline: Option<String>,
123 pub dirty: bool,
125 pub content_gen: u64,
129 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 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); }
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 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 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 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"); 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}