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.cursor.update(
cursor::Message::Jump(source_pos + char_byte_len),
self.virtual_document.lines(),
&self.text_buffer,
);
self.update_layout();
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)?;
let deleted_at = source_pos.saturating_sub(deleted_char_byte_len);
self.shift_source_ranges(deleted_at, -(deleted_char_byte_len as isize));
self.cursor.update(
cursor::Message::Jump(deleted_at),
self.virtual_document.lines(),
&self.text_buffer,
);
self.update_layout();
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.cursor.source_offset(),
&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::*;
let prev_block_idx = self.current_block_idx();
self.cursor.update(
MoveWordForward,
self.virtual_document.lines(),
&self.text_buffer,
);
self.relayout_on_block_change(prev_block_idx);
self.ensure_cursor_visible();
}
pub fn cursor_word_backward(&mut self) {
use cursor::Message::*;
let prev_block_idx = self.current_block_idx();
self.cursor.update(
MoveWordBackward,
self.virtual_document.lines(),
&self.text_buffer,
);
self.relayout_on_block_change(prev_block_idx);
self.ensure_cursor_visible();
}
pub fn cursor_left(&mut self, amount: usize) {
use cursor::Message::*;
let prev_block_idx = self.current_block_idx();
self.cursor.update(
MoveLeft(amount),
self.virtual_document.lines(),
&self.text_buffer,
);
self.relayout_on_block_change(prev_block_idx);
self.ensure_cursor_visible();
}
pub fn cursor_right(&mut self, amount: usize) {
use cursor::Message::*;
let prev_block_idx = self.current_block_idx();
self.cursor.update(
MoveRight(amount),
self.virtual_document.lines(),
&self.text_buffer,
);
self.relayout_on_block_change(prev_block_idx);
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.cursor.source_offset(),
&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 {
self.update_layout();
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,
target_offset.unwrap_or_else(|| self.cursor.source_offset()),
&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})",
);
}
fn line_texts(state: &NoteEditorState) -> Vec<String> {
state
.virtual_document
.lines()
.iter()
.map(|line| {
line.clone()
.spans()
.iter()
.map(|span| span.content.to_string())
.collect()
})
.collect()
}
#[test]
fn test_block_quote_raw_marker_line_by_line() {
let mut state = NoteEditorState::new(
"> quote line one\n> quote line two\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.resize_viewport(Size::new(50, 14));
let read = line_texts(&state);
assert!(
read.iter().any(|line| line.contains("┃ quote line one")),
"read mode renders the quote marker, got {read:?}",
);
state.set_view(View::Edit(EditMode::Source));
let edit = line_texts(&state);
assert!(
edit.iter().any(|line| line.contains("> quote line one")),
"cursor line shows the raw marker, got {edit:?}",
);
assert!(
edit.iter().any(|line| line.contains("┃ quote line two")),
"non-cursor line keeps the rendered marker, got {edit:?}",
);
state.cursor_down(1);
let edit = line_texts(&state);
assert!(
edit.iter().any(|line| line.contains("┃ quote line one")),
"first line now rendered, got {edit:?}",
);
assert!(
edit.iter().any(|line| line.contains("> quote line two")),
"second line now raw, got {edit:?}",
);
}
#[test]
fn test_block_quote_cursor_marker_is_colored() {
use ratatui::style::Color;
let mut state = NoteEditorState::new(
"> > deep\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.resize_viewport(Size::new(40, 8));
state.set_view(View::Edit(EditMode::Source));
let markers: Vec<_> = state
.virtual_document
.lines()
.iter()
.flat_map(|line| line.clone().spans())
.filter(|span| span.content.contains('>'))
.collect();
assert!(!markers.is_empty(), "quote markers should be present");
assert!(
markers
.iter()
.all(|span| span.style.fg == Some(Color::Magenta)),
"every `>` marker (all nesting levels) should be coloured",
);
}
#[test]
fn test_code_block_content_not_decorated_when_editing() {
let mut state = NoteEditorState::new(
"```\n- not a list\n# not a heading\n```\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.resize_viewport(Size::new(40, 10));
state.set_view(View::Edit(EditMode::Source));
let lines = line_texts(&state);
assert!(
lines.iter().any(|line| line.contains("- not a list")),
"code content stays literal, got {lines:?}",
);
assert!(
!lines.iter().any(|line| line.contains('●')),
"code content must not get a list bullet, got {lines:?}",
);
assert!(
lines.iter().any(|line| line.contains("# not a heading")),
"code content keeps its `#`, got {lines:?}",
);
}
#[test]
fn test_heading_keeps_underline_when_editing() {
let mut state = NoteEditorState::new(
"## Title\n\npara\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.resize_viewport(Size::new(40, 10));
state.set_view(View::Edit(EditMode::Source));
let lines = line_texts(&state);
assert!(
lines.iter().any(|line| line.contains("## Title")),
"heading markers stay visible, got {lines:?}",
);
assert!(
lines
.iter()
.any(|line| !line.is_empty() && line.chars().all(|c| c == '─')),
"heading keeps its underline, got {lines:?}",
);
}
#[test]
fn test_multiple_blank_lines_after_active_block_render() {
let mut state = NoteEditorState::new(
"- item one\n\n\n\npara2\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.resize_viewport(Size::new(50, 14));
state.set_view(View::Edit(EditMode::Source));
let lines = line_texts(&state);
let item = lines
.iter()
.position(|line| line.contains("item one"))
.unwrap();
let para = lines
.iter()
.position(|line| line.contains("para2"))
.unwrap();
assert_eq!(
para - item,
4,
"expected three blank lines between the list and the paragraph, got {lines:?}",
);
}
#[test]
fn test_merge_into_previous_block_keeps_text() {
let mut state = NoteEditorState::new(
"- item one\n\nsecond paragraph\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.resize_viewport(Size::new(50, 12));
state.set_view(View::Edit(EditMode::Source));
state.cursor_down(1);
state.cursor_down(1);
assert_eq!(state.cursor.source_offset(), 12);
state.delete_char();
let lines = line_texts(&state);
assert!(
lines.iter().any(|line| line.contains("second paragraph")),
"merged text must stay visible, got {lines:?}",
);
state.commit_text_buffer();
assert_eq!(state.content, "- item one\nsecond paragraph\n");
}
#[test]
fn test_empty_list_item_renders_marker() {
let mut state = NoteEditorState::new(
"- one\n- \n- three\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.resize_viewport(Size::new(40, 12));
state.set_view(View::Edit(EditMode::Source));
let lines = line_texts(&state);
assert!(
lines.iter().any(|line| line == "● "),
"empty item must render its marker, got {lines:?}",
);
assert!(lines.iter().any(|line| line.contains("three")), "{lines:?}");
}
#[test]
fn test_cursor_column_aligns_on_tab_indented_line() {
let mut state = NoteEditorState::new(
"- a\n\t- b\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.resize_viewport(Size::new(40, 12));
state.set_view(View::Edit(EditMode::Source));
state.cursor_down(1);
assert_eq!(state.cursor.source_offset(), 7);
assert_eq!(state.cursor.virtual_column(), 4);
state.cursor_left(2);
assert_eq!(state.cursor.source_offset(), 5); assert_eq!(state.cursor.virtual_column(), 2); }
#[test]
fn test_tab_indented_nested_list_keeps_indentation_when_editing() {
let mut state = NoteEditorState::new(
"1. one\n2. two\n\t- nested\n\t\t- deep\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.resize_viewport(Size::new(50, 12));
state.set_view(View::Edit(EditMode::Source));
let lines = line_texts(&state);
assert!(lines.iter().any(|line| line == " ○ nested"), "{lines:?}");
assert!(lines.iter().any(|line| line == " ◆ deep"), "{lines:?}");
}
#[test]
fn test_nested_list_renders_cleanly_when_editing() {
let mut state = NoteEditorState::new(
"- item one\n - nested one\n - nested two\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.resize_viewport(Size::new(50, 12));
state.set_view(View::Edit(EditMode::Source));
let lines = line_texts(&state);
assert_eq!(lines.first().map(String::as_str), Some("- item one"));
assert!(
lines.iter().any(|line| line == " ○ nested one"),
"{lines:?}"
);
assert!(
lines.iter().any(|line| line == " ○ nested two"),
"{lines:?}"
);
assert!(
!lines
.iter()
.any(|line| !line.is_empty() && line.trim().is_empty()),
"spurious whitespace line: {lines:?}",
);
}
#[test]
fn test_delete_blank_before_item_keeps_item_intact() {
let mut state = NoteEditorState::new(
"- first\n\n- second item text\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.resize_viewport(Size::new(50, 12));
state.set_view(View::Edit(EditMode::Source));
state.cursor_down(1);
state.delete_char();
let lines = line_texts(&state);
assert!(
lines.iter().any(|line| line.contains("second item text")),
"item must stay intact on one row, got {lines:?}",
);
state.commit_text_buffer();
assert_eq!(state.content, "- first\n- second item text\n");
}
#[test]
fn test_loose_list_blank_not_doubled_when_item_raw() {
let mut state = NoteEditorState::new(
"- a\n\n- b\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.resize_viewport(Size::new(40, 12));
state.set_view(View::Edit(EditMode::Source));
let lines = line_texts(&state);
let a = lines.iter().position(|line| line.contains("- a")).unwrap();
let b = lines.iter().position(|line| line.contains("b")).unwrap();
assert_eq!(
b - a,
2,
"expected exactly one blank line between items, got {lines:?}",
);
state.cursor_down(1);
assert_eq!(
state.cursor.source_offset(),
4,
"cursor should land on the blank line between the items",
);
state.delete_char();
state.commit_text_buffer();
assert_eq!(state.content, "- a\n- b\n");
}
#[test]
fn test_newline_at_start_of_list_item_preserves_text() {
let mut state = NoteEditorState::new(
"- hello world\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.resize_viewport(Size::new(40, 10));
state.set_view(View::Edit(EditMode::Source));
state.insert_char('\n');
let lines = line_texts(&state);
assert!(
lines.iter().any(|line| line.contains("- hello world")),
"item text must survive the newline, got {lines:?}",
);
assert_eq!(state.cursor.source_offset(), 1);
assert_eq!(state.cursor.virtual_row(), 1);
assert_eq!(state.cursor.virtual_column(), 0);
}
#[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");
}
}