use std::{
borrow::Cow,
fmt,
fs::File,
io::{self, Write},
ops::Range,
path::{Path, PathBuf},
time::{Duration, Instant},
};
use ratatui::{layout::Size, style::Color};
use crate::{
config::{Symbols, Theme},
note_editor::{
ast::{self},
cursor::{self, Cursor},
motion::{Direction, TextObjectKind},
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),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SelectionMode {
Char,
Line,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Selection {
pub anchor: usize,
pub mode: SelectionMode,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FindKind {
pub direction: Direction,
pub till: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct FindMotion {
target: char,
kind: FindKind,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Pending {
Find(FindKind),
TextObject(TextObjectKind),
Replace,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Operator {
Delete,
Change,
Yank,
}
#[derive(Clone, Debug, Default)]
pub struct Register {
pub text: String,
pub linewise: bool,
}
#[derive(Clone, Debug)]
struct Snapshot {
content: String,
offset: usize,
}
const YANK_FLASH_DURATION: Duration = Duration::from_millis(150);
#[derive(Clone, Debug)]
struct YankFlash {
range: Range<usize>,
started: Instant,
}
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, Copy, Debug, Default, PartialEq)]
pub enum Mode {
Insert,
Normal,
Visual,
VisualLine,
Edit,
#[default]
Read,
}
impl Mode {
pub fn label(self) -> &'static str {
match self {
Mode::Insert => "INSERT",
Mode::Normal => "NORMAL",
Mode::Visual => "VISUAL",
Mode::VisualLine => "V-LINE",
Mode::Edit => "EDIT",
Mode::Read => "READ",
}
}
pub fn color(self, theme: &Theme) -> Color {
match self {
Mode::Insert | Mode::Edit => theme.mode_insert,
Mode::Normal => theme.mode_normal,
Mode::Visual | Mode::VisualLine => theme.accent,
Mode::Read => theme.mode_read,
}
}
}
#[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,
theme: Theme,
filepath: PathBuf,
filename: String,
active: bool,
insert_mode: bool,
vim_mode: bool,
editor_enabled: bool,
modified: bool,
viewport: Viewport,
selection: Option<Selection>,
yank_flash: Option<YankFlash>,
pending_count: Option<usize>,
pending: Option<Pending>,
last_find: Option<FindMotion>,
pending_operator: Option<Operator>,
register: Register,
undo_stack: Vec<Snapshot>,
redo_stack: Vec<Snapshot>,
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,
view: View::Read,
cursor: Cursor::default(),
viewport: Viewport::default(),
symbols: symbols.clone(),
theme: Theme::default(),
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,
selection: None,
yank_flash: None,
pending_count: None,
pending: None,
last_find: None,
pending_operator: None,
register: Register::default(),
undo_stack: Vec::new(),
redo_stack: Vec::new(),
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 mode(&self) -> Mode {
match self.view {
View::Edit(..) if self.vim_mode && self.insert_mode => Mode::Insert,
View::Edit(..) if self.vim_mode && self.is_selecting() => {
match self.selection().map(|selection| selection.mode) {
Some(SelectionMode::Line) => Mode::VisualLine,
_ => Mode::Visual,
}
}
View::Edit(..) if self.vim_mode => Mode::Normal,
View::Edit(..) => Mode::Edit,
View::Read => Mode::Read,
}
}
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,
);
self.ensure_cursor_visible();
}
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.viewport.left().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 cursor_screen_row(&self) -> i32 {
(self.cursor.virtual_row() + self.virtual_document.meta().len()) as i32
}
fn ensure_cursor_visible(&mut self) {
let cursor_row = self.cursor_screen_row();
let cursor_column = self.cursor.virtual_column() as i32;
let vertical = if cursor_row < self.viewport.top() as i32 {
cursor_row - self.viewport.top() as i32
} else if cursor_row >= self.viewport.bottom() as i32 {
cursor_row - self.viewport.bottom() as i32 + 1
} else {
0
};
let line_width = self
.virtual_document
.lines()
.get(self.cursor.virtual_row())
.map(|line| {
line.virtual_spans()
.iter()
.map(|span| span.width())
.sum::<usize>()
})
.unwrap_or(0);
let horizontal = if line_width <= self.viewport.width as usize {
-(self.viewport.left() as i32)
} else if cursor_column < self.viewport.left() as i32 {
cursor_column - self.viewport.left() as i32
} else if cursor_column >= self.viewport.right() as i32 {
cursor_column - self.viewport.right() as i32 + 1
} else {
0
};
if (vertical, horizontal) != (0, 0) {
self.viewport.scroll_by((vertical, horizontal));
}
}
fn scroll_cursor_to_top(&mut self) {
let cursor_row = self.cursor_screen_row();
let total_rows =
(self.virtual_document.meta().len() + self.virtual_document.lines().len()) as i32;
let max_top = (total_rows - self.viewport.height as i32).max(0);
let vertical = cursor_row.min(max_top) - self.viewport.top() as i32;
let horizontal = -(self.viewport.left() as i32);
if (vertical, horizontal) != (0, 0) {
self.viewport.scroll_by((vertical, horizontal));
}
}
pub fn set_active(&mut self, active: bool) {
self.active = active;
}
pub fn theme(&self) -> Theme {
self.theme
}
pub fn set_theme(&mut self, theme: &Theme) {
self.theme = *theme;
self.virtual_document.set_theme(theme);
self.update_layout();
}
pub fn modified(&self) -> bool {
self.modified || self.text_buffer().is_some_and(|buffer| buffer.modified)
}
pub fn selection(&self) -> Option<Selection> {
self.selection
}
pub fn is_selecting(&self) -> bool {
self.selection.is_some()
}
pub fn toggle_selection(&mut self, mode: SelectionMode) {
self.selection = match self.selection {
Some(selection) if selection.mode == mode => None,
_ => Some(Selection {
anchor: self.cursor.source_offset(),
mode,
}),
};
}
pub fn clear_selection(&mut self) {
self.selection = None;
}
pub fn has_pending_count(&self) -> bool {
self.pending_count.is_some()
}
pub fn push_count_digit(&mut self, digit: u8) {
self.pending_count = Some(
self.pending_count
.unwrap_or(0)
.saturating_mul(10)
.saturating_add(digit as usize),
);
}
pub fn take_count(&mut self) -> Option<usize> {
self.pending_count.take()
}
pub fn reset_count(&mut self) {
self.pending_count = None;
}
pub fn awaiting_find_target(&self) -> bool {
matches!(self.pending, Some(Pending::Find(_)))
}
pub fn arm_find(&mut self, direction: Direction, till: bool) {
self.pending = Some(Pending::Find(FindKind { direction, till }));
}
pub fn clear_pending_find(&mut self) {
if self.awaiting_find_target() {
self.pending = None;
}
}
pub fn take_pending_find(&mut self) -> Option<FindKind> {
match self.pending {
Some(Pending::Find(kind)) => {
self.pending = None;
Some(kind)
}
_ => None,
}
}
pub fn remember_find(&mut self, target: char, kind: FindKind) {
self.last_find = Some(FindMotion { target, kind });
}
pub fn last_find(&self) -> Option<(char, FindKind)> {
self.last_find.map(|find| (find.target, find.kind))
}
pub fn awaiting_text_object(&self) -> bool {
matches!(self.pending, Some(Pending::TextObject(_)))
}
pub fn arm_text_object(&mut self, kind: TextObjectKind) {
self.pending = Some(Pending::TextObject(kind));
}
pub fn take_text_object(&mut self) -> Option<TextObjectKind> {
match self.pending {
Some(Pending::TextObject(kind)) => {
self.pending = None;
Some(kind)
}
_ => None,
}
}
pub fn clear_pending_text_object(&mut self) {
if self.awaiting_text_object() {
self.pending = None;
}
}
pub fn awaiting_replace(&self) -> bool {
matches!(self.pending, Some(Pending::Replace))
}
pub fn arm_replace(&mut self) {
self.pending = Some(Pending::Replace);
}
pub fn clear_pending_replace(&mut self) {
if self.awaiting_replace() {
self.pending = None;
}
}
pub fn pending_operator(&self) -> Option<Operator> {
self.pending_operator
}
pub fn pending_hint(&self) -> String {
let count = self
.pending_count
.map(|c| c.to_string())
.unwrap_or_default();
let operator = match self.pending_operator {
Some(Operator::Delete) => "d",
Some(Operator::Change) => "c",
Some(Operator::Yank) => "y",
None => "",
};
format!("{count}{operator}")
}
pub fn set_operator(&mut self, operator: Operator) {
self.pending_operator = Some(operator);
}
pub fn take_operator(&mut self) -> Option<Operator> {
self.pending_operator.take()
}
pub fn clear_operator(&mut self) {
self.pending_operator = None;
}
pub fn register(&self) -> &Register {
&self.register
}
pub fn set_register(&mut self, text: String, linewise: bool) {
self.register = Register { text, linewise };
}
fn snapshot(&self) -> Snapshot {
Snapshot {
content: self.content.clone(),
offset: self.cursor.source_offset(),
}
}
pub fn mark_undo_point(&mut self) {
self.undo_stack.push(self.snapshot());
self.redo_stack.clear();
}
pub fn undo(&mut self) -> bool {
match self.undo_stack.pop() {
Some(previous) => {
self.redo_stack.push(self.snapshot());
self.restore(previous);
true
}
None => false,
}
}
pub fn redo(&mut self) -> bool {
match self.redo_stack.pop() {
Some(next) => {
self.undo_stack.push(self.snapshot());
self.restore(next);
true
}
None => false,
}
}
fn restore(&mut self, snapshot: Snapshot) {
self.content = snapshot.content;
self.ast_nodes = parser::from_str(&self.content);
self.modified = true;
self.text_buffer = None;
self.editing_block = None;
self.jump_to_offset(snapshot.offset.min(self.content.len()));
}
pub fn splice(&mut self, range: Range<usize>, replacement: &str) {
self.commit_text_buffer();
self.mark_undo_point();
self.content.replace_range(range.clone(), replacement);
self.ast_nodes = parser::from_str(&self.content);
self.modified = true;
self.text_buffer = None;
self.editing_block = None;
let target = (range.start + replacement.len()).min(self.content.len());
self.jump_to_offset(target);
}
pub fn paste(&mut self, after: bool) {
if self.register.text.is_empty() {
return;
}
let cursor = self.cursor.source_offset();
if self.register.linewise {
let insert_at = if after {
self.content[cursor..]
.find('\n')
.map_or(self.content.len(), |i| cursor + i + 1)
} else {
self.content[..cursor].rfind('\n').map_or(0, |i| i + 1)
};
let mut text = self.register.text.clone();
if !text.ends_with('\n') {
text.push('\n');
}
self.splice(insert_at..insert_at, &text);
let landing = self.content[insert_at..]
.char_indices()
.find(|&(_, c)| !c.is_whitespace())
.map_or(insert_at, |(i, _)| insert_at + i);
self.jump_to_offset(landing);
} else {
let char_len = self.content[cursor..]
.chars()
.next()
.map_or(0, char::len_utf8);
let insert_at = if after { cursor + char_len } else { cursor };
let text = self.register.text.clone();
self.splice(insert_at..insert_at, &text);
}
}
fn live_content(&self) -> Cow<'_, str> {
self.text_buffer
.as_ref()
.filter(|buffer| buffer.modified)
.map(|buffer| Cow::Owned(buffer.write(&self.content)))
.unwrap_or(Cow::Borrowed(&self.content))
}
pub fn selection_range(&self) -> Option<Range<usize>> {
let selection = self.selection?;
let content = self.live_content();
let cursor = self.cursor.source_offset().min(content.len());
let anchor = selection.anchor.min(content.len());
let (lo, hi) = (anchor.min(cursor), anchor.max(cursor));
let range = match selection.mode {
SelectionMode::Char => {
let end = hi + content[hi..].chars().next().map_or(0, char::len_utf8);
lo..end
}
SelectionMode::Line => {
let start = content[..lo].rfind('\n').map_or(0, |i| i + 1);
let end = content[hi..]
.find('\n')
.map_or(content.len(), |i| hi + i + 1);
start..end
}
};
Some(range)
}
pub fn selected_text(&self) -> Option<String> {
let range = self.selection_range()?;
self.live_content().get(range).map(str::to_string)
}
pub fn flash_yank(&mut self, range: Range<usize>) {
self.yank_flash = Some(YankFlash {
range,
started: Instant::now(),
});
}
pub fn yank_flash_range(&self) -> Option<Range<usize>> {
self.yank_flash
.as_ref()
.filter(|flash| flash.started.elapsed() < YANK_FLASH_DURATION)
.map(|flash| flash.range.clone())
}
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.scroll_cursor_to_top();
}
pub fn jump_to_offset(&mut self, offset: usize) {
let offset = cursor::snap_to_char_boundary(&self.content, offset);
if matches!(self.view, View::Edit(..)) {
let target_block = self
.ast_nodes
.iter()
.position(|node| node.source_range().contains(&offset))
.or_else(|| {
self.ast_nodes
.iter()
.position(|node| node.source_range().start >= offset)
})
.or_else(|| {
self.ast_nodes
.iter()
.rposition(|node| node.source_range().end <= offset)
});
if let Some(block) = target_block {
if self.editing_block != Some(block) {
self.enter_insert(block);
}
if let Some(start) = self.text_buffer.as_ref().map(|b| b.source_range.start) {
let end = self
.text_buffer
.as_ref()
.map_or(start, |b| b.source_range.end);
let gap_is_blank = self
.content
.get(offset..start)
.is_some_and(|gap| gap.chars().all(|c| c == ' ' || c == '\t'));
if offset < start && gap_is_blank {
if let Some(text) = self.content.get(offset..end) {
self.text_buffer = Some(TextBuffer::new(text, offset..end));
}
}
}
}
}
self.cursor.update(
cursor::Message::Jump(offset),
self.virtual_document.lines(),
&self.text_buffer,
);
self.update_layout();
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.viewport.left().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.viewport.left().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 cursor_screen_row = state.cursor_screen_row();
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})",
);
let cursor_column = state.cursor.virtual_column() as i32;
let left = state.viewport().left() as i32;
let right = state.viewport().right() as i32;
assert!(
cursor_column >= left && cursor_column < right,
"{context}: cursor column {cursor_column} outside viewport [{left}, {right})",
);
}
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_callout_renders_icon_and_label() {
use ratatui::style::Color;
let mut state = NoteEditorState::new(
"> [!warning]\n> Mind the gap.\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("⚠ Warning")),
"callout header shows the icon and label, got {read:?}",
);
assert!(
read.iter().any(|line| line.contains("Mind the gap.")),
"callout body follows the header, got {read:?}",
);
let header_colored = state
.virtual_document
.lines()
.iter()
.flat_map(|line| line.clone().spans())
.any(|span| span.content.contains("Warning") && span.style.fg == Some(Color::Yellow));
assert!(header_colored, "warning callout header is yellow");
}
#[test]
fn test_obsidian_callout_with_title() {
let mut state = NoteEditorState::new(
"> [!note]- A word on moving your vault folder\n> Body text.\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.resize_viewport(Size::new(60, 14));
let read = line_texts(&state);
assert!(
read.iter()
.any(|line| line.contains("✎ A word on moving your vault folder")),
"callout header shows the custom title, got {read:?}",
);
assert!(
read.iter().any(|line| line.contains("Body text.")),
"callout body follows the header, got {read:?}",
);
assert!(
!read.iter().any(|line| line.contains("[!note]")),
"raw marker line must not leak into the body, got {read:?}",
);
}
#[test]
fn test_obsidian_callout_aliases_and_types() {
let mut state = NoteEditorState::new(
"> [!summary] Overview\n> Body.\n\n> [!bug]\n> Squashed.\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.resize_viewport(Size::new(60, 20));
let read = line_texts(&state);
assert!(
read.iter().any(|line| line.contains("▤ Overview")),
"summary alias renders as an abstract callout, got {read:?}",
);
assert!(
read.iter().any(|line| line.contains("⊙ Bug")),
"bug is a first-class Obsidian callout type, got {read:?}",
);
}
#[test]
fn test_callout_keeps_color_when_editing() {
use ratatui::style::Color;
let mut state = NoteEditorState::new(
"> [!warning]\n> Mind the gap.\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.resize_viewport(Size::new(50, 14));
state.set_view(View::Edit(EditMode::Source));
let bars: Vec<_> = state
.virtual_document
.lines()
.iter()
.flat_map(|line| line.clone().spans())
.filter(|span| span.content.contains('▌') || span.content.contains('>'))
.collect();
assert!(!bars.is_empty(), "callout bars/markers should be present");
assert!(
bars.iter().all(|span| span.style.fg == Some(Color::Yellow)),
"callout keeps its yellow accent while editing, got {bars:?}",
);
}
#[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_jump_to_offset_to_list_keeps_markers() {
let mut state = NoteEditorState::new(
"# Title\n\n- alpha bravo\n- charlie delta\n",
"test",
Path::new("test.md"),
&Symbols::unicode(),
);
state.set_vim_mode(true);
state.resize_viewport(Size::new(40, 12));
state.set_view(View::Edit(EditMode::Source));
let charlie = state.content.find("charlie").unwrap();
state.jump_to_offset(charlie);
let lines = line_texts(&state);
assert!(
lines.iter().any(|line| line == "- charlie delta"),
"cursor line is raw: {lines:?}",
);
assert!(
lines.iter().any(|line| line == "● alpha bravo"),
"other item keeps its rendered bullet: {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");
}
#[test]
fn test_jump_to_heading_scrolls_it_to_top() {
let filler = "\nparagraph\n".repeat(10);
let content = format!("# Intro\n{filler}# Target\n{filler}");
let mut state =
NoteEditorState::new(&content, "test", Path::new("test.md"), &Symbols::unicode());
state.resize_viewport(Size::new(40, 6));
let target_offset = content.find("# Target").unwrap();
let target_block = state
.virtual_document
.blocks()
.iter()
.position(|block| block.source_range().contains(&target_offset))
.unwrap();
state.cursor_jump(target_block);
assert_eq!(
state.cursor_screen_row(),
state.viewport().top() as i32,
"heading should sit at the top of the viewport",
);
}
#[test]
fn test_viewport_scrolls_horizontally_on_long_code_line() {
let long = "x".repeat(100);
let content = format!("```\n{long}\n```\n");
let mut state =
NoteEditorState::new(&content, "test", Path::new("test.md"), &Symbols::unicode());
state.resize_viewport(Size::new(20, 10));
state.set_view(View::Edit(EditMode::Source));
state.cursor_down(1);
assert_eq!(state.viewport().left(), 0, "no scroll at line start");
state.cursor_right(80);
let panned = state.viewport().left();
assert!(panned > 0, "viewport should pan right to follow the cursor");
assert_cursor_visible(&state, "after cursor_right on long line");
state.cursor_left(80);
assert!(
state.viewport().left() < panned,
"viewport should pan back toward the start",
);
assert_cursor_visible(&state, "after cursor_left on long line");
}
fn widest_line_in_range(state: &NoteEditorState, range: std::ops::Range<usize>) -> usize {
state
.virtual_document
.lines()
.iter()
.filter(|line| {
line.source_range()
.is_some_and(|r| range.contains(&r.start))
})
.map(|line| line.virtual_spans().iter().map(|span| span.width()).sum())
.max()
.expect("a line in the given source range")
}
#[test]
fn test_code_background_extends_past_horizontal_scroll() {
let content = format!("```\nshort\n{}\n```\n", "x".repeat(100));
let mut state =
NoteEditorState::new(&content, "", Path::new("test.md"), &Symbols::unicode());
let width = 20;
state.resize_viewport(Size::new(width, 10));
state.set_view(View::Edit(EditMode::Source));
state.cursor_down(2);
state.cursor_right(80);
state.update_layout();
let left = state.viewport().left() as usize;
assert!(left > 0, "expected a horizontal scroll");
let short_line_width = widest_line_in_range(&state, 4..10);
assert!(
short_line_width >= left + width as usize,
"code background ({short_line_width}) must cover the viewport \
({left} + {width})",
);
}
#[test]
fn test_non_active_block_fills_extend_past_horizontal_scroll() {
let long = "x".repeat(100);
let content = format!("```\n{long}\n```\n\n```\nbbb\n```\n");
let second_block_start = content.find("\n\n").unwrap() + 2;
let mut state =
NoteEditorState::new(&content, "", Path::new("test.md"), &Symbols::unicode());
let width = 20;
state.resize_viewport(Size::new(width, 10));
state.set_view(View::Edit(EditMode::Source));
state.cursor_down(1);
state.cursor_right(80);
state.update_layout();
let left = state.viewport().left() as usize;
assert!(left > 0, "expected a horizontal scroll");
let non_active_width = widest_line_in_range(&state, second_block_start..content.len());
assert!(
non_active_width >= left + width as usize,
"non-active code background ({non_active_width}) must cover the \
viewport ({left} + {width})",
);
}
fn edit_state(content: &str) -> NoteEditorState<'static> {
let mut state =
NoteEditorState::new(content, "test", Path::new("test.md"), &Symbols::unicode());
state.resize_viewport(Size::new(40, 10));
state.set_view(View::Edit(EditMode::Source));
state
}
#[test]
fn test_typing_wrapping_paragraph_never_pans_horizontally() {
let mut state =
NoteEditorState::new("hello\n", "test", Path::new("test.md"), &Symbols::unicode());
state.resize_viewport(Size::new(12, 20));
state.set_view(View::Edit(EditMode::Source));
state.cursor_right(100);
for c in " world foobar".chars() {
state.insert_char(c);
assert_eq!(
state.viewport().left(),
0,
"wrapped text must not pan; left stayed non-zero after typing {c:?}",
);
}
assert!(
state.cursor.virtual_row() > 0,
"the word should have wrapped"
);
assert_cursor_visible(&state, "after the word wrapped");
}
#[test]
fn test_charwise_selection_is_inclusive() {
let mut state = edit_state("hello world\n");
state.toggle_selection(SelectionMode::Char);
state.cursor_right(4);
assert_eq!(state.selected_text().as_deref(), Some("hello"));
}
#[test]
fn test_charwise_selection_extends_backwards() {
let mut state = edit_state("hello world\n");
state.cursor_right(4);
state.toggle_selection(SelectionMode::Char);
state.cursor_left(4);
assert_eq!(state.selected_text().as_deref(), Some("hello"));
}
#[test]
fn test_linewise_selection_covers_whole_line() {
let mut state = edit_state("line one\nline two\n");
state.cursor_right(3);
state.toggle_selection(SelectionMode::Line);
assert_eq!(state.selected_text().as_deref(), Some("line one\n"));
}
#[test]
fn test_toggle_same_mode_clears_selection() {
let mut state = edit_state("hello\n");
state.toggle_selection(SelectionMode::Char);
assert!(state.is_selecting());
state.toggle_selection(SelectionMode::Char);
assert!(!state.is_selecting());
assert_eq!(state.selection_range(), None);
}
}