Skip to main content

basalt_tui/note_editor/
state.rs

1use std::{
2    fmt,
3    fs::File,
4    io::{self, Write},
5    path::{Path, PathBuf},
6};
7
8use ratatui::layout::Size;
9
10use crate::{
11    config::Symbols,
12    note_editor::{
13        ast::{self},
14        cursor::{self, Cursor},
15        parser,
16        rich_text::RichText,
17        text_buffer::TextBuffer,
18        viewport::Viewport,
19        virtual_document::VirtualDocument,
20    },
21};
22
23#[derive(Clone, Copy, Debug, Default, PartialEq)]
24pub enum EditMode {
25    #[default]
26    /// Shows the markdown exactly as written
27    Source,
28    // TODO:
29    // /// Hides most of the markdown syntax
30    // LivePreview
31}
32
33#[derive(Clone, Copy, Debug, Default, PartialEq)]
34pub enum View {
35    #[default]
36    Read,
37    Edit(EditMode),
38}
39
40impl fmt::Display for View {
41    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
42        match self {
43            View::Read => write!(f, "READ"),
44            View::Edit(..) => write!(f, "EDIT"),
45        }
46    }
47}
48
49#[derive(Clone, Debug, Default)]
50pub struct NoteEditorState<'a> {
51    // FIXME: Use Rope instead of String for O(log n) instead of O(n).
52    pub content: String,
53    pub view: View,
54    pub cursor: Cursor,
55    pub ast_nodes: Vec<ast::Node>,
56    pub virtual_document: VirtualDocument<'a>,
57    pub symbols: Symbols,
58    filepath: PathBuf,
59    filename: String,
60    active: bool,
61    insert_mode: bool,
62    vim_mode: bool,
63    editor_enabled: bool,
64    modified: bool,
65    viewport: Viewport,
66    text_buffer: Option<TextBuffer>,
67    /// Which block is currently in raw/edit mode. Stored explicitly so
68    /// the layout always matches the text_buffer, even when the cursor
69    /// position would temporarily resolve to a different block.
70    editing_block: Option<usize>,
71}
72
73impl<'a> NoteEditorState<'a> {
74    pub fn new(content: &str, filename: &str, filepath: &Path, symbols: &Symbols) -> Self {
75        let ast_nodes = parser::from_str(content);
76        let content = content.to_string();
77        Self {
78            text_buffer: None,
79            content: content.clone(),
80            view: View::Read,
81            cursor: Cursor::default(),
82            viewport: Viewport::default(),
83            symbols: symbols.clone(),
84            virtual_document: VirtualDocument::new(symbols),
85            filename: filename.to_string(),
86            filepath: filepath.to_path_buf(),
87            ast_nodes,
88            active: false,
89            insert_mode: false,
90            vim_mode: false,
91            editor_enabled: false,
92            modified: false,
93            editing_block: None,
94        }
95    }
96
97    pub fn viewport(&self) -> &Viewport {
98        &self.viewport
99    }
100
101    pub fn is_editing(&self) -> bool {
102        matches!(self.view, View::Edit(..))
103    }
104
105    pub fn insert_mode(&self) -> bool {
106        self.insert_mode
107    }
108
109    pub fn set_insert_mode(&mut self, mode: bool) {
110        self.insert_mode = mode;
111    }
112
113    pub fn vim_mode(&self) -> bool {
114        self.vim_mode
115    }
116
117    pub fn set_vim_mode(&mut self, mode: bool) {
118        self.vim_mode = mode;
119    }
120
121    pub fn editor_enabled(&self) -> bool {
122        self.editor_enabled
123    }
124
125    pub fn set_editor_enabled(&mut self, enabled: bool) {
126        self.editor_enabled = enabled;
127    }
128
129    pub fn text_buffer(&self) -> Option<&TextBuffer> {
130        self.text_buffer.as_ref()
131    }
132
133    pub fn enter_insert(&mut self, block_idx: usize) {
134        // Commit any pending edits from the previous block before switching.
135        self.commit_text_buffer();
136
137        self.editing_block = Some(block_idx);
138        if let Some(node) = self.ast_nodes.get(block_idx) {
139            let source_range = node.source_range();
140            if let Some(content) = self.content.get(source_range.clone()) {
141                self.text_buffer = Some(TextBuffer::new(content, source_range.clone()));
142            }
143        } else if self.content.is_empty() {
144            // Only create an empty node for genuinely empty files, not when
145            // blocks haven't been laid out yet.
146            let empty_node = ast::Node::Paragraph {
147                text: RichText::empty(),
148                source_range: 0..0,
149            };
150            self.text_buffer = Some(TextBuffer::new("", empty_node.source_range().clone()));
151            self.ast_nodes.push(empty_node);
152        }
153    }
154
155    /// Write the current text_buffer back to self.content if it was modified,
156    /// re-parse AST nodes. Returns true if content changed.
157    pub fn commit_text_buffer(&mut self) -> bool {
158        if let Some(buffer) = self.text_buffer() {
159            let new_content = buffer.write(&self.content);
160            if self.content != new_content {
161                self.content = new_content;
162                self.ast_nodes = parser::from_str(&self.content);
163                self.modified = true;
164                return true;
165            }
166        }
167        false
168    }
169
170    pub fn exit_insert(&mut self) {
171        if matches!(self.view, View::Read) {
172            return;
173        }
174
175        self.commit_text_buffer();
176        self.text_buffer = None;
177        self.editing_block = None;
178    }
179
180    pub fn set_filename(&mut self, name: &str) {
181        self.filename = name.to_string();
182    }
183
184    pub fn set_filepath(&mut self, path: &Path) {
185        self.filepath = path.to_path_buf();
186    }
187
188    pub fn insert_char(&mut self, c: char) {
189        if let Some(buffer) = &mut self.text_buffer {
190            let insertion_offset = self.cursor.source_offset();
191            buffer.insert_char(c, insertion_offset);
192
193            // Shift source ranges of all nodes after the insertion point by the character's byte length
194            let char_byte_len = c.len_utf8();
195            self.shift_source_ranges(insertion_offset, char_byte_len as isize);
196
197            self.update_layout();
198
199            // Jump cursor to position after the inserted character
200            self.cursor.update(
201                cursor::Message::Jump(insertion_offset + char_byte_len),
202                self.virtual_document.lines(),
203                &self.text_buffer,
204            );
205
206            self.ensure_cursor_visible();
207        }
208    }
209
210    pub fn delete_char(&mut self) {
211        if let Some(buffer) = &mut self.text_buffer {
212            if buffer.source_range.start == self.cursor.source_offset() {
213                // TODO: Get previous block source range start
214                // Get current block source range end
215                // Get content with source range
216                // Create new text buffer that has merged the previous and current blocks
217            } else {
218                let deletion_offset = self.cursor.source_offset();
219                if let Some(char_byte_len) = buffer.delete_char(deletion_offset) {
220                    // We shift by the negative character byte length to move ranges backwards
221                    self.shift_source_ranges(deletion_offset, -(char_byte_len as isize));
222
223                    self.update_layout();
224
225                    // Position cursor at where the deleted character was.
226                    let new_cursor_pos = deletion_offset.saturating_sub(char_byte_len);
227                    self.cursor.update(
228                        cursor::Message::Jump(new_cursor_pos),
229                        self.virtual_document.lines(),
230                        &self.text_buffer,
231                    );
232
233                    self.ensure_cursor_visible();
234                }
235            }
236        }
237    }
238
239    pub fn active(&self) -> bool {
240        self.active
241    }
242
243    pub fn current_block(&self) -> usize {
244        *self
245            .virtual_document
246            .line_to_block()
247            .get(self.cursor.virtual_row())
248            .unwrap_or(&0)
249    }
250
251    pub fn set_view(&mut self, view: View) {
252        let block_idx = self.current_block();
253
254        self.view = view;
255
256        use cursor::Message::*;
257
258        match self.view {
259            View::Read => {
260                self.exit_insert();
261                self.update_layout();
262                self.cursor.update(
263                    SwitchMode(cursor::CursorMode::Read),
264                    self.virtual_document.lines(),
265                    &None,
266                );
267            }
268            View::Edit(..) => {
269                self.enter_insert(block_idx);
270                self.update_layout();
271                self.cursor.update(
272                    SwitchMode(cursor::CursorMode::Edit),
273                    self.virtual_document.lines(),
274                    &self.text_buffer,
275                );
276            }
277        }
278    }
279
280    pub fn resize_viewport(&mut self, size: Size) {
281        if self.viewport.size_changed(size) {
282            use cursor::Message::*;
283
284            let current_block_idx = self.editing_block;
285
286            self.virtual_document.layout(
287                &self.filename,
288                &self.content,
289                &self.view,
290                current_block_idx,
291                &self.ast_nodes,
292                size.width.into(),
293                self.text_buffer.clone(),
294            );
295
296            self.viewport.resize(size);
297
298            self.cursor.update(
299                Jump(self.cursor.source_offset()),
300                self.virtual_document.lines(),
301                &self.text_buffer,
302            );
303
304            self.ensure_cursor_visible();
305        }
306    }
307
308    /// Ensures the cursor is visible within the viewport by scrolling if necessary.
309    /// This method should be called after any operation that might cause the cursor
310    /// to move outside the visible area (e.g., resize, cursor movement).
311    fn ensure_cursor_visible(&mut self) {
312        let cursor_row = self.cursor.virtual_row() as i32;
313        let viewport_top = self.viewport.top() as i32;
314        let viewport_bottom = self.viewport.bottom() as i32;
315        let meta_len = self.virtual_document.meta().len() as i32;
316
317        let effective_bottom = viewport_bottom.saturating_sub(meta_len);
318
319        if cursor_row < viewport_top {
320            let scroll_offset = cursor_row - viewport_top;
321            self.viewport.scroll_by((scroll_offset, 0));
322        } else if cursor_row >= effective_bottom {
323            let scroll_offset = cursor_row - effective_bottom + 1;
324            self.viewport.scroll_by((scroll_offset, 0));
325        }
326    }
327
328    pub fn set_active(&mut self, active: bool) {
329        self.active = active;
330    }
331
332    pub fn modified(&self) -> bool {
333        self.modified || self.text_buffer().is_some_and(|buffer| buffer.modified)
334    }
335
336    pub fn cursor_word_forward(&mut self) {
337        use cursor::Message::*;
338
339        self.cursor.update(
340            MoveWordForward,
341            self.virtual_document.lines(),
342            &self.text_buffer,
343        );
344
345        self.ensure_cursor_visible();
346    }
347
348    pub fn cursor_word_backward(&mut self) {
349        use cursor::Message::*;
350
351        self.cursor.update(
352            MoveWordBackward,
353            self.virtual_document.lines(),
354            &self.text_buffer,
355        );
356
357        self.ensure_cursor_visible();
358    }
359
360    pub fn cursor_left(&mut self, amount: usize) {
361        use cursor::Message::*;
362
363        self.cursor.update(
364            MoveLeft(amount),
365            self.virtual_document.lines(),
366            &self.text_buffer,
367        );
368
369        self.ensure_cursor_visible();
370    }
371
372    pub fn cursor_right(&mut self, amount: usize) {
373        use cursor::Message::*;
374
375        self.cursor.update(
376            MoveRight(amount),
377            self.virtual_document.lines(),
378            &self.text_buffer,
379        );
380
381        self.ensure_cursor_visible();
382    }
383
384    pub fn cursor_to_end(&mut self) {
385        let last_block = self.virtual_document.blocks().len().saturating_sub(1);
386        self.cursor_jump(last_block);
387        // After jumping to the last block (which lands on its first line),
388        // move down to reach the actual last line within that block.
389        self.cursor_down(usize::MAX);
390    }
391
392    pub fn cursor_jump(&mut self, idx: usize) {
393        let prev_block_idx = self.current_block();
394
395        if let Some(block) = self.virtual_document.blocks().get(idx) {
396            self.cursor.update(
397                cursor::Message::Jump(block.source_range.start),
398                self.virtual_document.lines(),
399                &self.text_buffer,
400            );
401        }
402
403        self.relayout_on_block_change(prev_block_idx);
404        self.ensure_cursor_visible();
405    }
406
407    pub fn update_layout(&mut self) {
408        use cursor::Message::*;
409
410        // Deferred initialization: if Edit mode was set before the viewport was
411        // sized (e.g. vim_mode at note open), initialize the text buffer now that
412        // the virtual document has been laid out.
413        if matches!(self.view, View::Edit(..)) && self.text_buffer.is_none() {
414            let block_idx = self.current_block();
415            self.enter_insert(block_idx);
416            self.cursor.update(
417                SwitchMode(cursor::CursorMode::Edit),
418                self.virtual_document.lines(),
419                &self.text_buffer,
420            );
421        }
422
423        let current_block_idx = self.editing_block;
424
425        self.virtual_document.layout(
426            &self.filename,
427            &self.content,
428            &self.view,
429            current_block_idx,
430            &self.ast_nodes,
431            self.viewport.area().width.into(),
432            self.text_buffer.clone(),
433        );
434
435        self.cursor.update(
436            Jump(self.cursor.source_offset()),
437            self.virtual_document.lines(),
438            &self.text_buffer,
439        );
440    }
441
442    pub fn cursor_up(&mut self, amount: usize) {
443        let prev_block_idx = self.current_block();
444
445        self.cursor.update(
446            cursor::Message::MoveUp(amount),
447            self.virtual_document.lines(),
448            &self.text_buffer,
449        );
450
451        self.relayout_on_block_change(prev_block_idx);
452        self.ensure_cursor_visible();
453    }
454
455    pub fn cursor_down(&mut self, amount: usize) {
456        let prev_block_idx = self.current_block();
457
458        self.cursor.update(
459            cursor::Message::MoveDown(amount),
460            self.virtual_document.lines(),
461            &self.text_buffer,
462        );
463
464        self.relayout_on_block_change(prev_block_idx);
465        self.ensure_cursor_visible();
466    }
467
468    /// When the cursor crosses a block boundary in Edit mode, switch the
469    /// text_buffer to the new block and re-layout so the new block is
470    /// rendered in raw mode.
471    ///
472    /// After re-layout the source offset from the old layout may not
473    /// correspond to the same logical position (e.g. code-block visual
474    /// vs raw source ranges differ).  We determine whether the cursor
475    /// entered the block from above or below by comparing block indices
476    /// and whether the jump crossed more than one block (multi-block
477    /// jumps like gg/G always go to the entry edge).
478    fn relayout_on_block_change(&mut self, prev_block_idx: usize) {
479        if !matches!(self.view, View::Edit(..)) {
480            return;
481        }
482
483        let target_block_idx = self.current_block();
484        if target_block_idx == prev_block_idx {
485            return;
486        }
487
488        let adjacent = prev_block_idx.abs_diff(target_block_idx) == 1;
489        let moved_up = target_block_idx < prev_block_idx;
490        let use_end = adjacent && moved_up;
491
492        let target_offset = self.ast_nodes.get(target_block_idx).map(|node| {
493            let range = node.source_range();
494            if use_end {
495                range.end.saturating_sub(1).max(range.start)
496            } else {
497                range.start
498            }
499        });
500
501        self.enter_insert(target_block_idx);
502
503        self.virtual_document.layout(
504            &self.filename,
505            &self.content,
506            &self.view,
507            self.editing_block,
508            &self.ast_nodes,
509            self.viewport.area().width.into(),
510            self.text_buffer.clone(),
511        );
512
513        if let Some(offset) = target_offset {
514            self.cursor.update(
515                cursor::Message::Jump(offset),
516                self.virtual_document.lines(),
517                &self.text_buffer,
518            );
519        }
520    }
521
522    pub fn save_to_file(&mut self) -> io::Result<()> {
523        if self.modified() {
524            let mut file = File::create(&self.filepath)?;
525            file.write_all(self.content.as_bytes())?;
526            self.modified = false;
527        }
528        Ok(())
529    }
530
531    /// The shift amount can be positive (insertion) or negative (deletion).
532    fn shift_source_ranges(&mut self, offset: usize, shift: isize) {
533        self.shift_nodes(offset, shift);
534    }
535
536    /// Shifts source ranges of top-level AST nodes.
537    ///
538    /// This function is a helper function intended to shift the source ranges when editing the
539    /// document. After exiting the edit mode, the source ranges are calculated by the parser, so
540    /// we don't have to be precise here.
541    fn shift_nodes(&mut self, offset: usize, shift: isize) {
542        let shift_value = |v: usize| v.checked_add_signed(shift).unwrap_or(0);
543
544        // We only take the current node and the rest after it
545        let nodes = self
546            .ast_nodes
547            .iter_mut()
548            .filter(|node| node.source_range().end > offset);
549
550        nodes.for_each(|node| {
551            let range = node.source_range();
552            let shifted_range = if range.start > offset {
553                shift_value(range.start)..shift_value(range.end)
554            } else {
555                range.start..shift_value(range.end)
556            };
557            node.set_source_range(shifted_range);
558        });
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565    use ratatui::layout::Size;
566    use std::path::Path;
567
568    fn assert_cursor_visible(state: &NoteEditorState, context: &str) {
569        let cursor_row = state.cursor.virtual_row() as i32;
570        let top = state.viewport().top() as i32;
571        let bottom = state.viewport().bottom() as i32;
572        assert!(
573            cursor_row >= top && cursor_row < bottom,
574            "{context}: cursor row {cursor_row} outside viewport [{top}, {bottom})",
575        );
576    }
577
578    #[test]
579    fn test_viewport_scrolls_with_cursor_in_edit_mode() {
580        let content = "# Title\n\nLine 1\n\nLine 2\n\nLine 3\n\nLine 4\n\nLine 5\n";
581
582        let mut state =
583            NoteEditorState::new(content, "test", Path::new("test.md"), &Symbols::unicode());
584        state.resize_viewport(Size::new(40, 4));
585
586        state.cursor_down(2);
587        state.set_view(View::Edit(EditMode::Source));
588
589        state.insert_char('\n');
590        state.insert_char('\n');
591        state.insert_char('\n');
592        state.insert_char('\n');
593        assert_cursor_visible(&state, "after insert_char");
594
595        state.cursor_right(20);
596        assert_cursor_visible(&state, "after cursor_right");
597
598        state.cursor_left(20);
599        assert_cursor_visible(&state, "after cursor_left");
600
601        state.cursor_down(5);
602        assert_cursor_visible(&state, "after cursor_down");
603
604        state.cursor_up(5);
605        assert_cursor_visible(&state, "after cursor_up");
606    }
607}