use std::{
fmt,
fs::File,
io::{self, Write},
path::{Path, PathBuf},
};
use ratatui::layout::Size;
use crate::{
config::Symbols,
note_editor::{
ast::{self},
cursor::{self, Cursor},
parser,
rich_text::RichText,
text_buffer::TextBuffer,
viewport::Viewport,
virtual_document::VirtualDocument,
},
};
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum EditMode {
#[default]
Source,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum View {
#[default]
Read,
Edit(EditMode),
}
impl fmt::Display for View {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
View::Read => write!(f, "READ"),
View::Edit(..) => write!(f, "EDIT"),
}
}
}
#[derive(Clone, Debug, Default)]
pub struct NoteEditorState<'a> {
pub content: String,
pub view: View,
pub cursor: Cursor,
pub ast_nodes: Vec<ast::Node>,
pub virtual_document: VirtualDocument<'a>,
pub symbols: Symbols,
filepath: PathBuf,
filename: String,
active: bool,
insert_mode: bool,
vim_mode: bool,
editor_enabled: bool,
modified: bool,
viewport: Viewport,
text_buffer: Option<TextBuffer>,
editing_block: Option<usize>,
}
impl<'a> NoteEditorState<'a> {
pub fn new(content: &str, filename: &str, filepath: &Path, symbols: &Symbols) -> Self {
let ast_nodes = parser::from_str(content);
let content = content.to_string();
Self {
text_buffer: None,
content: content.clone(),
view: View::Read,
cursor: Cursor::default(),
viewport: Viewport::default(),
symbols: symbols.clone(),
virtual_document: VirtualDocument::new(symbols),
filename: filename.to_string(),
filepath: filepath.to_path_buf(),
ast_nodes,
active: false,
insert_mode: false,
vim_mode: false,
editor_enabled: false,
modified: false,
editing_block: None,
}
}
pub fn viewport(&self) -> &Viewport {
&self.viewport
}
pub fn is_editing(&self) -> bool {
matches!(self.view, View::Edit(..))
}
pub fn insert_mode(&self) -> bool {
self.insert_mode
}
pub fn set_insert_mode(&mut self, mode: bool) {
self.insert_mode = mode;
}
pub fn vim_mode(&self) -> bool {
self.vim_mode
}
pub fn set_vim_mode(&mut self, mode: bool) {
self.vim_mode = mode;
}
pub fn editor_enabled(&self) -> bool {
self.editor_enabled
}
pub fn set_editor_enabled(&mut self, enabled: bool) {
self.editor_enabled = enabled;
}
pub fn text_buffer(&self) -> Option<&TextBuffer> {
self.text_buffer.as_ref()
}
pub fn enter_insert(&mut self, block_idx: usize) {
self.commit_text_buffer();
self.editing_block = Some(block_idx);
if let Some(node) = self.ast_nodes.get(block_idx) {
let source_range = node.source_range();
if let Some(content) = self.content.get(source_range.clone()) {
self.text_buffer = Some(TextBuffer::new(content, source_range.clone()));
}
} else if self.content.is_empty() {
let empty_node = ast::Node::Paragraph {
text: RichText::empty(),
source_range: 0..0,
};
self.text_buffer = Some(TextBuffer::new("", empty_node.source_range().clone()));
self.ast_nodes.push(empty_node);
}
}
pub fn exit_insert(&mut self) {
if matches!(self.view, View::Read) {
return;
}
self.commit_text_buffer();
self.text_buffer = None;
self.editing_block = None;
}
pub fn commit_text_buffer(&mut self) -> Option<()> {
let buffer = self.text_buffer()?;
if buffer.modified {
let new_content = buffer.write(&self.content);
let changed = self.content != new_content;
self.content = new_content;
self.ast_nodes = parser::from_str(&self.content);
self.modified = self.modified || changed;
Some(())
} else {
None
}
}
pub fn set_filename(&mut self, name: &str) {
self.filename = name.to_string();
}
pub fn set_filepath(&mut self, path: &Path) {
self.filepath = path.to_path_buf();
}
pub fn insert_char(&mut self, c: char) {
if let Some(buffer) = &mut self.text_buffer {
let source_pos = self.cursor.source_offset();
buffer.insert_char(c, source_pos);
let char_byte_len = c.len_utf8();
self.shift_source_ranges(source_pos, char_byte_len as isize);
self.update_layout();
self.cursor.update(
cursor::Message::Jump(source_pos + char_byte_len),
self.virtual_document.lines(),
&self.text_buffer,
);
self.ensure_cursor_visible();
}
}
pub fn delete_char(&mut self) -> Option<()> {
let buffer = self.text_buffer()?;
let at_buffer_start = buffer.source_range.start == self.cursor.source_offset();
let previous_block_idx = self.previous_block_idx();
let current_block_idx = self.current_block_idx();
let should_merge = at_buffer_start && previous_block_idx < current_block_idx;
if should_merge {
let prev_start = self.ast_nodes.get(previous_block_idx)?.source_range().start;
let buffer_start = self.text_buffer.as_ref()?.source_range.start;
let prefix = self.content.get(prev_start..buffer_start)?;
self.text_buffer
.as_mut()?
.insert_at_start(prev_start, prefix);
self.editing_block = Some(previous_block_idx);
self.ast_nodes.remove(current_block_idx);
}
let buffer = self.text_buffer.as_mut()?;
let source_pos = self.cursor.source_offset();
let deleted_char_byte_len = buffer.delete_char(source_pos)?;
self.shift_source_ranges(source_pos, -(deleted_char_byte_len as isize));
self.update_layout();
let new_cursor_pos = source_pos.saturating_sub(deleted_char_byte_len);
self.cursor.update(
cursor::Message::Jump(new_cursor_pos),
self.virtual_document.lines(),
&self.text_buffer,
);
self.ensure_cursor_visible();
Some(())
}
pub fn active(&self) -> bool {
self.active
}
pub fn previous_block_idx(&self) -> usize {
let prev_line = self.cursor.virtual_row().saturating_sub(1);
self.virtual_document.line_to_block_idx(prev_line)
}
pub fn current_block_idx(&self) -> usize {
let current_line = self.cursor.virtual_row();
self.virtual_document.line_to_block_idx(current_line)
}
pub fn set_view(&mut self, view: View) {
let block_idx = self.current_block_idx();
self.view = view;
use cursor::Message::*;
match self.view {
View::Read => {
self.exit_insert();
self.update_layout();
self.cursor.update(
SwitchMode(cursor::CursorMode::Read),
self.virtual_document.lines(),
&None,
);
}
View::Edit(..) => {
self.enter_insert(block_idx);
self.update_layout();
self.cursor.update(
SwitchMode(cursor::CursorMode::Edit),
self.virtual_document.lines(),
&self.text_buffer,
);
}
}
}
pub fn resize_viewport(&mut self, size: Size) {
if self.viewport.size_changed(size) {
use cursor::Message::*;
let current_block_idx = self.editing_block;
self.virtual_document.layout(
&self.filename,
&self.content,
&self.view,
current_block_idx,
&self.ast_nodes,
size.width.into(),
self.text_buffer.clone(),
);
self.viewport.resize(size);
self.cursor.update(
Jump(self.cursor.source_offset()),
self.virtual_document.lines(),
&self.text_buffer,
);
self.ensure_cursor_visible();
}
}
fn ensure_cursor_visible(&mut self) {
let meta_len = self.virtual_document.meta().len() as i32;
let cursor_screen_row = self.cursor.virtual_row() as i32 + meta_len;
let viewport_top = self.viewport.top() as i32;
let viewport_bottom = self.viewport.bottom() as i32;
if cursor_screen_row < viewport_top {
let scroll_offset = cursor_screen_row - viewport_top;
self.viewport.scroll_by((scroll_offset, 0));
} else if cursor_screen_row >= viewport_bottom {
let scroll_offset = cursor_screen_row - viewport_bottom + 1;
self.viewport.scroll_by((scroll_offset, 0));
}
}
pub fn set_active(&mut self, active: bool) {
self.active = active;
}
pub fn modified(&self) -> bool {
self.modified || self.text_buffer().is_some_and(|buffer| buffer.modified)
}
pub fn cursor_word_forward(&mut self) {
use cursor::Message::*;
self.cursor.update(
MoveWordForward,
self.virtual_document.lines(),
&self.text_buffer,
);
self.ensure_cursor_visible();
}
pub fn cursor_word_backward(&mut self) {
use cursor::Message::*;
self.cursor.update(
MoveWordBackward,
self.virtual_document.lines(),
&self.text_buffer,
);
self.ensure_cursor_visible();
}
pub fn cursor_left(&mut self, amount: usize) {
use cursor::Message::*;
self.cursor.update(
MoveLeft(amount),
self.virtual_document.lines(),
&self.text_buffer,
);
self.ensure_cursor_visible();
}
pub fn cursor_right(&mut self, amount: usize) {
use cursor::Message::*;
self.cursor.update(
MoveRight(amount),
self.virtual_document.lines(),
&self.text_buffer,
);
self.ensure_cursor_visible();
}
pub fn cursor_to_end(&mut self) {
let last_block = self.virtual_document.blocks().len().saturating_sub(1);
self.cursor_jump(last_block);
self.cursor_down(usize::MAX);
}
pub fn cursor_jump(&mut self, idx: usize) {
let prev_block_idx = self.current_block_idx();
if let Some(block) = self.virtual_document.blocks().get(idx) {
self.cursor.update(
cursor::Message::Jump(block.source_range.start),
self.virtual_document.lines(),
&self.text_buffer,
);
}
self.relayout_on_block_change(prev_block_idx);
self.ensure_cursor_visible();
}
pub fn update_layout(&mut self) {
use cursor::Message::*;
if matches!(self.view, View::Edit(..)) && self.text_buffer.is_none() {
let offset = self.cursor.source_offset();
let block_idx = self
.ast_nodes
.iter()
.position(|node| node.source_range().contains(&offset))
.or_else(|| {
self.ast_nodes
.iter()
.rposition(|node| node.source_range().end <= offset)
})
.unwrap_or_else(|| self.current_block_idx());
self.enter_insert(block_idx);
self.cursor.update(
SwitchMode(cursor::CursorMode::Edit),
self.virtual_document.lines(),
&self.text_buffer,
);
}
let current_block_idx = self.editing_block;
self.virtual_document.layout(
&self.filename,
&self.content,
&self.view,
current_block_idx,
&self.ast_nodes,
self.viewport.area().width.into(),
self.text_buffer.clone(),
);
self.cursor.update(
Jump(self.cursor.source_offset()),
self.virtual_document.lines(),
&self.text_buffer,
);
}
pub fn cursor_up(&mut self, amount: usize) {
let prev_block_idx = self.current_block_idx();
let prev_row = self.cursor.virtual_row();
self.cursor.update(
cursor::Message::MoveUp(amount),
self.virtual_document.lines(),
&self.text_buffer,
);
let consumed = prev_row.saturating_sub(self.cursor.virtual_row());
self.viewport.scroll_up(amount.saturating_sub(consumed));
self.relayout_on_block_change(prev_block_idx);
self.ensure_cursor_visible();
}
pub fn cursor_down(&mut self, amount: usize) {
let prev_block_idx = self.current_block_idx();
self.cursor.update(
cursor::Message::MoveDown(amount),
self.virtual_document.lines(),
&self.text_buffer,
);
self.relayout_on_block_change(prev_block_idx);
self.ensure_cursor_visible();
}
fn relayout_on_block_change(&mut self, prev_block_idx: usize) {
if !matches!(self.view, View::Edit(..)) {
return;
}
let target_block_idx = self.current_block_idx();
if target_block_idx == prev_block_idx {
return;
}
let adjacent = prev_block_idx.abs_diff(target_block_idx) == 1;
let moved_up = target_block_idx < prev_block_idx;
let use_end = adjacent && moved_up;
let target_offset = self.ast_nodes.get(target_block_idx).map(|node| {
let range = node.source_range();
if use_end {
range.end.saturating_sub(1).max(range.start)
} else {
range.start
}
});
self.enter_insert(target_block_idx);
self.virtual_document.layout(
&self.filename,
&self.content,
&self.view,
self.editing_block,
&self.ast_nodes,
self.viewport.area().width.into(),
self.text_buffer.clone(),
);
if let Some(offset) = target_offset {
self.cursor.update(
cursor::Message::Jump(offset),
self.virtual_document.lines(),
&self.text_buffer,
);
}
}
pub fn save_to_file(&mut self) -> io::Result<()> {
if self.modified() {
let mut file = File::create(&self.filepath)?;
file.write_all(self.content.as_bytes())?;
self.modified = false;
}
Ok(())
}
fn shift_source_ranges(&mut self, offset: usize, shift: isize) {
self.ast_nodes
.iter_mut()
.for_each(|node| shift_node(node, offset, shift));
}
}
fn shift_node(node: &mut ast::Node, offset: usize, shift: isize) {
let shift_value = |v: usize| v.checked_add_signed(shift).unwrap_or(0);
let range = node.source_range();
if range.end <= offset {
return;
}
let shifted_range = if range.start > offset {
shift_value(range.start)..shift_value(range.end)
} else {
range.start..shift_value(range.end)
};
node.set_source_range(shifted_range);
if let Some(children) = node.children_as_mut() {
children
.iter_mut()
.for_each(|child| shift_node(child, offset, shift));
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::layout::Size;
use std::path::Path;
fn assert_cursor_visible(state: &NoteEditorState, context: &str) {
let meta_len = state.virtual_document.meta().len() as i32;
let cursor_screen_row = state.cursor.virtual_row() as i32 + meta_len;
let top = state.viewport().top() as i32;
let bottom = state.viewport().bottom() as i32;
assert!(
cursor_screen_row >= top && cursor_screen_row < bottom,
"{context}: cursor screen row {cursor_screen_row} outside viewport [{top}, {bottom})",
);
}
#[test]
fn test_viewport_scrolls_with_cursor_in_edit_mode() {
let content = "# Title\n\nLine 1\n\nLine 2\n\nLine 3\n\nLine 4\n\nLine 5\n";
let mut state =
NoteEditorState::new(content, "test", Path::new("test.md"), &Symbols::unicode());
state.resize_viewport(Size::new(40, 4));
state.cursor_down(2);
state.set_view(View::Edit(EditMode::Source));
state.insert_char('\n');
state.insert_char('\n');
state.insert_char('\n');
state.insert_char('\n');
assert_cursor_visible(&state, "after insert_char");
state.cursor_right(20);
assert_cursor_visible(&state, "after cursor_right");
state.cursor_left(20);
assert_cursor_visible(&state, "after cursor_left");
state.cursor_down(5);
assert_cursor_visible(&state, "after cursor_down");
state.cursor_up(5);
assert_cursor_visible(&state, "after cursor_up");
}
}