kimun_notes/components/text_editor/
snapshot.rs1use std::num::NonZeroU64;
2
3pub struct EditorSnapshot {
17 pub text: crate::ropetext::Text,
21 pub cursor: (usize, usize),
25 pub content_revision: NonZeroU64,
29}
30
31impl EditorSnapshot {
32 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 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 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 pub fn cursor_in_bounds(&self) -> bool {
76 self.cursor.0 < self.text.line_count()
77 }
78
79 pub fn cursor_row_clamped(&self) -> usize {
81 self.cursor.0.min(self.text.line_count().saturating_sub(1))
82 }
83
84 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 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#[derive(Debug, Clone)]
113pub struct NvimSnapshot {
114 pub lines: Vec<String>,
116 pub cursor: (usize, usize),
118 pub mode: EditorMode,
119 pub cmdline: Option<String>,
122 pub dirty: bool,
124 pub content_gen: u64,
128 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 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); }
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 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 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}