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: NvimMode,
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: NvimMode::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 == NvimMode::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 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 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 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 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}