use edtui::{EditorState, Lines};
use crate::markdown::DocBlock;
use crate::markdown::cursor_bridge::byte_offset_to_block;
use crate::markdown::renderer::{render_block_from_slice, render_markdown};
use crate::theme::{Palette, Theme};
use crate::ui::markdown_view::MarkdownViewState;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BlockSourceRange {
pub index: usize,
pub start_byte: usize,
pub end_byte: usize,
}
#[derive(Debug, Clone, Copy)]
#[allow(dead_code)] pub struct EditEffect {
pub byte_delta: isize,
pub line_delta: isize,
}
pub struct HybridState {
pub editor_state: EditorState,
pub source: String,
pub baseline: String,
pub line_boundaries: Vec<usize>,
pub active_block: Option<BlockSourceRange>,
pub command_line: Option<String>,
pub status_message: Option<String>,
pub close_after_save: bool,
}
impl HybridState {
pub fn from_source(source: &str) -> Self {
let editor_state = EditorState::new(Lines::from(source));
let source_owned = source.to_string();
let line_boundaries = compute_line_boundaries(&source_owned);
Self {
editor_state,
source: source_owned.clone(),
baseline: source_owned,
line_boundaries,
active_block: None,
command_line: None,
status_message: None,
close_after_save: false,
}
}
#[must_use]
pub fn is_dirty(&self) -> bool {
self.source != self.baseline
}
pub fn apply_edit(
&mut self,
blocks: &mut [DocBlock],
byte_offset: usize,
deleted: usize,
inserted: &str,
) -> EditEffect {
let delete_end = byte_offset + deleted;
assert!(
delete_end <= self.source.len(),
"apply_edit: byte_offset({byte_offset}) + deleted({deleted}) = {delete_end} \
exceeds source length ({})",
self.source.len()
);
assert!(
self.source.is_char_boundary(byte_offset),
"apply_edit: byte_offset {byte_offset} is not on a UTF-8 char boundary"
);
assert!(
self.source.is_char_boundary(delete_end),
"apply_edit: byte_offset + deleted = {delete_end} is not on a UTF-8 char boundary"
);
let deleted_newlines: isize = self.source[byte_offset..delete_end]
.bytes()
.filter(|&b| b == b'\n')
.count() as isize;
let inserted_newlines: isize = inserted.bytes().filter(|&b| b == b'\n').count() as isize;
self.source.replace_range(byte_offset..delete_end, inserted);
self.line_boundaries = compute_line_boundaries(&self.source);
let byte_delta: isize = inserted.len() as isize - deleted as isize;
let line_delta: isize = inserted_newlines - deleted_newlines;
for block in blocks.iter_mut() {
let (start, end) = block_byte_range(block);
if end < byte_offset {
continue;
}
if start <= byte_offset && delete_end <= end {
let defer_to_after = start == byte_offset && deleted == 0 && start > 0;
if !defer_to_after {
set_block_byte_range(block, start, apply_delta(end, byte_delta));
continue;
}
}
assert!(
byte_offset <= start || delete_end <= start,
"apply_edit: deleted range [{byte_offset}, {delete_end}) crosses block boundary \
at byte {start}; cross-block deletes are not supported (sub-phase 6 invariant)"
);
set_block_byte_range(
block,
apply_delta(start, byte_delta),
apply_delta(end, byte_delta),
);
}
EditEffect {
byte_delta,
line_delta,
}
}
}
pub fn byte_offset_from_editor_state(
editor_state: &EditorState,
line_boundaries: &[usize],
) -> usize {
let row = editor_state.cursor.row;
let col = editor_state.cursor.col;
let line_start = line_boundaries
.get(row)
.copied()
.unwrap_or_else(|| line_boundaries.last().copied().unwrap_or(0));
line_start + col
}
pub fn recompute_active_block(hybrid: &mut HybridState, view: &MarkdownViewState) {
if view.rendered.is_empty() {
hybrid.active_block = None;
return;
}
let cursor_byte = byte_offset_from_editor_state(&hybrid.editor_state, &hybrid.line_boundaries);
let new_index = byte_offset_to_block(&view.rendered, cursor_byte);
let (start_byte, end_byte) = block_byte_range(&view.rendered[new_index]);
hybrid.active_block = Some(BlockSourceRange {
index: new_index,
start_byte,
end_byte,
});
}
pub fn insert_char(hybrid: &mut HybridState, view: &mut MarkdownViewState, ch: char) {
let byte = byte_offset_from_editor_state(&hybrid.editor_state, &hybrid.line_boundaries);
let mut buf = [0u8; 4];
let inserted = ch.encode_utf8(&mut buf);
let char_len = inserted.len();
hybrid.apply_edit(&mut view.rendered, byte, 0, inserted);
sync_editor_lines_from_source(hybrid);
set_cursor_to_byte(hybrid, byte + char_len);
recompute_active_block(hybrid, view);
}
pub fn delete_char_before(hybrid: &mut HybridState, view: &mut MarkdownViewState) {
let byte = byte_offset_from_editor_state(&hybrid.editor_state, &hybrid.line_boundaries);
if byte == 0 {
return;
}
let char_start = prev_char_boundary(&hybrid.source, byte - 1);
let char_len = byte - char_start;
hybrid.apply_edit(&mut view.rendered, char_start, char_len, "");
sync_editor_lines_from_source(hybrid);
set_cursor_to_byte(hybrid, char_start);
recompute_active_block(hybrid, view);
}
pub fn delete_char_after(hybrid: &mut HybridState, view: &mut MarkdownViewState) {
let byte = byte_offset_from_editor_state(&hybrid.editor_state, &hybrid.line_boundaries);
if byte >= hybrid.source.len() {
return;
}
let char_end = next_char_boundary(&hybrid.source, byte + 1);
let char_len = char_end - byte;
hybrid.apply_edit(&mut view.rendered, byte, char_len, "");
sync_editor_lines_from_source(hybrid);
set_cursor_to_byte(hybrid, byte.min(hybrid.source.len()));
recompute_active_block(hybrid, view);
}
pub fn reparse_and_splice_block(
hybrid: &HybridState,
view: &mut MarkdownViewState,
block_index: usize,
palette: &Palette,
theme: Theme,
) {
let Some(block) = view.rendered.get(block_index) else {
return;
};
let (start, end) = block_byte_range(block);
let end = end.min(hybrid.source.len());
let start = start.min(end);
let slice = &hybrid.source[start..end];
let replacement = render_block_from_slice(slice, start, palette, theme);
view.splice_blocks(block_index..block_index + 1, replacement);
}
pub fn full_reparse(
hybrid: &HybridState,
view: &mut MarkdownViewState,
palette: &Palette,
theme: Theme,
) {
let blocks = render_markdown(&hybrid.source, palette, theme);
view.total_lines = blocks.iter().map(DocBlock::height).sum();
view.rendered = blocks;
view.text_layouts.clear();
view.table_layouts.clear();
view.recompute_positions();
}
fn sync_editor_lines_from_source(hybrid: &mut HybridState) {
hybrid.editor_state.lines = Lines::from(hybrid.source.as_str());
}
pub fn move_cursor_left(hybrid: &mut HybridState, view: &MarkdownViewState) {
let byte = byte_offset_from_editor_state(&hybrid.editor_state, &hybrid.line_boundaries);
if byte == 0 {
return;
}
let new_byte = prev_char_boundary(&hybrid.source, byte - 1);
set_cursor_to_byte(hybrid, new_byte);
recompute_active_block(hybrid, view);
}
pub fn move_cursor_right(hybrid: &mut HybridState, view: &MarkdownViewState) {
let byte = byte_offset_from_editor_state(&hybrid.editor_state, &hybrid.line_boundaries);
if byte >= hybrid.source.len() {
return;
}
let new_byte = next_char_boundary(&hybrid.source, byte + 1);
set_cursor_to_byte(hybrid, new_byte);
recompute_active_block(hybrid, view);
}
pub fn move_cursor_down(hybrid: &mut HybridState, view: &MarkdownViewState) {
let row = hybrid.editor_state.cursor.row;
let col = hybrid.editor_state.cursor.col;
let next_row = row + 1;
if next_row >= hybrid.line_boundaries.len() {
return; }
let new_byte = clamped_byte_on_line(&hybrid.source, &hybrid.line_boundaries, next_row, col);
set_cursor_to_byte(hybrid, new_byte);
recompute_active_block(hybrid, view);
}
pub fn move_cursor_up(hybrid: &mut HybridState, view: &MarkdownViewState) {
let row = hybrid.editor_state.cursor.row;
if row == 0 {
return;
}
let col = hybrid.editor_state.cursor.col;
let new_byte = clamped_byte_on_line(&hybrid.source, &hybrid.line_boundaries, row - 1, col);
set_cursor_to_byte(hybrid, new_byte);
recompute_active_block(hybrid, view);
}
pub fn move_cursor_page_down(hybrid: &mut HybridState, view: &MarkdownViewState, count: usize) {
let row = hybrid.editor_state.cursor.row;
let col = hybrid.editor_state.cursor.col;
let last_row = hybrid.line_boundaries.len().saturating_sub(1);
let new_row = (row + count).min(last_row);
if new_row == row {
return;
}
let new_byte = clamped_byte_on_line(&hybrid.source, &hybrid.line_boundaries, new_row, col);
set_cursor_to_byte(hybrid, new_byte);
recompute_active_block(hybrid, view);
}
pub fn move_cursor_page_up(hybrid: &mut HybridState, view: &MarkdownViewState, count: usize) {
let row = hybrid.editor_state.cursor.row;
let col = hybrid.editor_state.cursor.col;
let new_row = row.saturating_sub(count);
if new_row == row {
return;
}
let new_byte = clamped_byte_on_line(&hybrid.source, &hybrid.line_boundaries, new_row, col);
set_cursor_to_byte(hybrid, new_byte);
recompute_active_block(hybrid, view);
}
pub fn move_cursor_line_start(hybrid: &mut HybridState, view: &MarkdownViewState) {
let row = hybrid.editor_state.cursor.row;
let new_byte = hybrid
.line_boundaries
.get(row)
.copied()
.unwrap_or_else(|| hybrid.line_boundaries.last().copied().unwrap_or(0));
set_cursor_to_byte(hybrid, new_byte);
recompute_active_block(hybrid, view);
}
pub fn move_cursor_line_end(hybrid: &mut HybridState, view: &MarkdownViewState) {
let row = hybrid.editor_state.cursor.row;
let line_start = hybrid
.line_boundaries
.get(row)
.copied()
.unwrap_or_else(|| hybrid.line_boundaries.last().copied().unwrap_or(0));
let line_end_content = hybrid
.line_boundaries
.get(row + 1)
.map(|&next| next.saturating_sub(2))
.unwrap_or(hybrid.source.len().saturating_sub(1));
let line_end = line_end_content.max(line_start);
let new_byte = if line_end > line_start {
prev_char_boundary(&hybrid.source, line_end)
} else {
line_start
};
set_cursor_to_byte(hybrid, new_byte);
recompute_active_block(hybrid, view);
}
pub fn move_cursor_word_right(hybrid: &mut HybridState, view: &MarkdownViewState) {
let byte = byte_offset_from_editor_state(&hybrid.editor_state, &hybrid.line_boundaries);
let new_byte = next_word_boundary(&hybrid.source, byte);
if new_byte == byte {
return;
}
set_cursor_to_byte(hybrid, new_byte);
recompute_active_block(hybrid, view);
}
pub fn move_cursor_word_left(hybrid: &mut HybridState, view: &MarkdownViewState) {
let byte = byte_offset_from_editor_state(&hybrid.editor_state, &hybrid.line_boundaries);
let new_byte = prev_word_boundary(&hybrid.source, byte);
if new_byte == byte {
return;
}
set_cursor_to_byte(hybrid, new_byte);
recompute_active_block(hybrid, view);
}
pub fn move_cursor_doc_start(hybrid: &mut HybridState, view: &MarkdownViewState) {
set_cursor_to_byte(hybrid, 0);
recompute_active_block(hybrid, view);
}
pub fn move_cursor_doc_end(hybrid: &mut HybridState, view: &MarkdownViewState) {
let end = hybrid.source.len();
set_cursor_to_byte(hybrid, end);
recompute_active_block(hybrid, view);
}
pub fn delete_word_before(hybrid: &mut HybridState, view: &mut MarkdownViewState) {
let byte = byte_offset_from_editor_state(&hybrid.editor_state, &hybrid.line_boundaries);
let target = prev_word_boundary(&hybrid.source, byte);
if target == byte {
return;
}
let len = byte - target;
hybrid.apply_edit(&mut view.rendered, target, len, "");
sync_editor_lines_from_source(hybrid);
set_cursor_to_byte(hybrid, target);
recompute_active_block(hybrid, view);
}
pub fn delete_word_after(hybrid: &mut HybridState, view: &mut MarkdownViewState) {
let byte = byte_offset_from_editor_state(&hybrid.editor_state, &hybrid.line_boundaries);
let target = next_word_boundary(&hybrid.source, byte);
if target == byte {
return;
}
let len = target - byte;
hybrid.apply_edit(&mut view.rendered, byte, len, "");
sync_editor_lines_from_source(hybrid);
set_cursor_to_byte(hybrid, byte);
recompute_active_block(hybrid, view);
}
pub fn delete_to_line_start(hybrid: &mut HybridState, view: &mut MarkdownViewState) {
let byte = byte_offset_from_editor_state(&hybrid.editor_state, &hybrid.line_boundaries);
let row = hybrid.editor_state.cursor.row;
let line_start = hybrid.line_boundaries.get(row).copied().unwrap_or(0);
if line_start == byte {
return;
}
let len = byte - line_start;
hybrid.apply_edit(&mut view.rendered, line_start, len, "");
sync_editor_lines_from_source(hybrid);
set_cursor_to_byte(hybrid, line_start);
recompute_active_block(hybrid, view);
}
pub fn delete_to_line_end(hybrid: &mut HybridState, view: &mut MarkdownViewState) {
let byte = byte_offset_from_editor_state(&hybrid.editor_state, &hybrid.line_boundaries);
let row = hybrid.editor_state.cursor.row;
let line_end = hybrid
.line_boundaries
.get(row + 1)
.map(|&next| next.saturating_sub(1))
.unwrap_or(hybrid.source.len());
if line_end == byte {
return;
}
let len = line_end - byte;
hybrid.apply_edit(&mut view.rendered, byte, len, "");
sync_editor_lines_from_source(hybrid);
set_cursor_to_byte(hybrid, byte);
recompute_active_block(hybrid, view);
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn next_word_boundary(source: &str, byte: usize) -> usize {
let mut i = next_char_boundary(source, byte);
while i < source.len() {
let c = source[i..].chars().next().unwrap_or('\0');
if is_word_char(c) {
break;
}
i += c.len_utf8();
}
while i < source.len() {
let c = source[i..].chars().next().unwrap_or('\0');
if !is_word_char(c) {
break;
}
i += c.len_utf8();
}
i
}
fn prev_word_boundary(source: &str, byte: usize) -> usize {
let mut i = byte.min(source.len());
let step_back = |source: &str, mut pos: usize| -> Option<(usize, char)> {
if pos == 0 {
return None;
}
pos -= 1;
while pos > 0 && !source.is_char_boundary(pos) {
pos -= 1;
}
let c = source[pos..].chars().next()?;
Some((pos, c))
};
loop {
match step_back(source, i) {
Some((new_pos, c)) if !is_word_char(c) => i = new_pos,
_ => break,
}
}
loop {
match step_back(source, i) {
Some((new_pos, c)) if is_word_char(c) => i = new_pos,
_ => break,
}
}
i
}
fn prev_char_boundary(s: &str, byte: usize) -> usize {
let mut b = byte.min(s.len());
while b > 0 && !s.is_char_boundary(b) {
b -= 1;
}
b
}
fn next_char_boundary(s: &str, byte: usize) -> usize {
let mut b = byte.min(s.len());
while b < s.len() && !s.is_char_boundary(b) {
b += 1;
}
b
}
fn clamped_byte_on_line(source: &str, line_boundaries: &[usize], row: usize, col: usize) -> usize {
let line_start = line_boundaries
.get(row)
.copied()
.unwrap_or_else(|| line_boundaries.last().copied().unwrap_or(0));
let line_end = line_boundaries
.get(row + 1)
.map(|&next| next.saturating_sub(1))
.unwrap_or(source.len());
let desired = (line_start + col).min(line_end);
next_char_boundary(source, desired)
}
fn set_cursor_to_byte(hybrid: &mut HybridState, byte: usize) {
let lb = &hybrid.line_boundaries;
let row = match lb.binary_search(&byte) {
Ok(i) => i,
Err(i) => i.saturating_sub(1),
};
let col = byte.saturating_sub(lb.get(row).copied().unwrap_or(0));
hybrid.editor_state.cursor = edtui::Index2::new(row, col);
}
fn compute_line_boundaries(source: &str) -> Vec<usize> {
let mut boundaries = vec![0usize];
for (i, b) in source.bytes().enumerate() {
if b == b'\n' {
boundaries.push(i + 1);
}
}
boundaries
}
fn apply_delta(value: usize, delta: isize) -> usize {
if delta >= 0 {
value + delta as usize
} else {
value.saturating_sub((-delta) as usize)
}
}
fn block_byte_range(block: &DocBlock) -> (usize, usize) {
match block {
DocBlock::Text {
source_byte_start,
source_byte_end,
..
} => (*source_byte_start as usize, *source_byte_end as usize),
DocBlock::Mermaid {
source_byte_start,
source_byte_end,
..
} => (*source_byte_start as usize, *source_byte_end as usize),
DocBlock::Table(t) => (t.source_byte_start as usize, t.source_byte_end as usize),
}
}
fn set_block_byte_range(block: &mut DocBlock, start: usize, end: usize) {
let start32 = start as u32;
let end32 = end as u32;
match block {
DocBlock::Text {
source_byte_start,
source_byte_end,
..
} => {
*source_byte_start = start32;
*source_byte_end = end32;
}
DocBlock::Mermaid {
source_byte_start,
source_byte_end,
..
} => {
*source_byte_start = start32;
*source_byte_end = end32;
}
DocBlock::Table(t) => {
t.source_byte_start = start32;
t.source_byte_end = end32;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown::renderer::render_markdown;
use crate::theme::{Palette, Theme};
fn palette() -> Palette {
Palette::from_theme(Theme::Default)
}
fn theme() -> Theme {
Theme::Default
}
fn setup(source: &str) -> (HybridState, Vec<DocBlock>) {
let state = HybridState::from_source(source);
let blocks = render_markdown(source, &palette(), theme());
(state, blocks)
}
fn assert_contiguous(blocks: &[DocBlock], source_len: usize) {
for i in 0..blocks.len().saturating_sub(1) {
let (_, end_i) = block_byte_range(&blocks[i]);
let (start_next, _) = block_byte_range(&blocks[i + 1]);
assert_eq!(
end_i,
start_next,
"contiguity broken between block[{i}] (end={end_i}) and block[{}] (start={start_next})",
i + 1
);
}
if let Some(last) = blocks.last() {
let (_, last_end) = block_byte_range(last);
assert_eq!(
last_end, source_len,
"last block end ({last_end}) != source_len ({source_len})"
);
}
}
const DOC_3: &str = "Para one.\n\n```mermaid\ngraph LR\nA-->B\n```\n\nPara two.\n";
fn nth(blocks: &[DocBlock], i: usize) -> &DocBlock {
blocks.get(i).unwrap_or_else(|| {
panic!(
"expected block[{i}] but doc only rendered {} block(s); \
check DOC_3 produces the expected structure",
blocks.len()
)
})
}
#[test]
fn apply_edit_insert_in_middle_of_block_shifts_only_end_byte() {
let (mut state, mut blocks) = setup(DOC_3);
assert!(blocks.len() >= 3, "DOC_3 must render to at least 3 blocks");
let (b0_start, b0_end) = block_byte_range(nth(&blocks, 0));
let (b1_start_before, b1_end_before) = block_byte_range(nth(&blocks, 1));
let (b2_start_before, b2_end_before) = block_byte_range(nth(&blocks, 2));
let mid = (b1_start_before + b1_end_before) / 2;
let effect = state.apply_edit(&mut blocks, mid, 0, "X");
assert_eq!(effect.byte_delta, 1);
assert_eq!(effect.line_delta, 0);
let (b0s, b0e) = block_byte_range(&blocks[0]);
assert_eq!((b0s, b0e), (b0_start, b0_end), "block 0 must be unchanged");
let (b1s, b1e) = block_byte_range(&blocks[1]);
assert_eq!(b1s, b1_start_before, "block 1 start must not change");
assert_eq!(b1e, b1_end_before + 1, "block 1 end must grow by 1");
let (b2s, b2e) = block_byte_range(&blocks[2]);
assert_eq!(b2s, b2_start_before + 1, "block 2 start must shift +1");
assert_eq!(b2e, b2_end_before + 1, "block 2 end must shift +1");
}
#[test]
fn apply_edit_insert_at_doc_start_shifts_all_blocks() {
let (mut state, mut blocks) = setup(DOC_3);
assert!(blocks.len() >= 3, "DOC_3 must render to at least 3 blocks");
let (_, b0_end_before) = block_byte_range(nth(&blocks, 0));
let (b1_start_before, b1_end_before) = block_byte_range(nth(&blocks, 1));
let (b2_start_before, b2_end_before) = block_byte_range(nth(&blocks, 2));
let effect = state.apply_edit(&mut blocks, 0, 0, "AB");
assert_eq!(effect.byte_delta, 2);
let (b0s, b0e) = block_byte_range(&blocks[0]);
assert_eq!(b0s, 0, "block 0 start stays at 0");
assert_eq!(b0e, b0_end_before + 2, "block 0 end must grow by 2");
let (b1s, b1e) = block_byte_range(&blocks[1]);
assert_eq!(b1s, b1_start_before + 2);
assert_eq!(b1e, b1_end_before + 2);
let (b2s, b2e) = block_byte_range(&blocks[2]);
assert_eq!(b2s, b2_start_before + 2);
assert_eq!(b2e, b2_end_before + 2);
}
#[test]
fn apply_edit_delete_range_in_block() {
let (mut state, mut blocks) = setup(DOC_3);
assert!(blocks.len() >= 3, "DOC_3 must render to at least 3 blocks");
let (b1_start_before, b1_end_before) = block_byte_range(nth(&blocks, 1));
let (b2_start_before, b2_end_before) = block_byte_range(nth(&blocks, 2));
let del_offset = b1_start_before + 2;
let effect = state.apply_edit(&mut blocks, del_offset, 5, "");
assert_eq!(effect.byte_delta, -5);
let (b1s, b1e) = block_byte_range(&blocks[1]);
assert_eq!(b1s, b1_start_before);
assert_eq!(b1e, b1_end_before - 5);
let (b2s, b2e) = block_byte_range(&blocks[2]);
assert_eq!(b2s, b2_start_before - 5);
assert_eq!(b2e, b2_end_before - 5);
}
#[test]
fn apply_edit_at_block_end_stays_in_block() {
let (mut state, mut blocks) = setup(DOC_3);
assert!(blocks.len() >= 2, "DOC_3 must render to at least 2 blocks");
let (_, b0_end_before) = block_byte_range(nth(&blocks, 0));
let (b1_start_before, b1_end_before) = block_byte_range(nth(&blocks, 1));
let effect = state.apply_edit(&mut blocks, b0_end_before, 0, "ZZ");
assert_eq!(effect.byte_delta, 2);
let (b0s, b0e) = block_byte_range(&blocks[0]);
assert_eq!(b0s, 0);
assert_eq!(b0e, b0_end_before + 2);
let (b1s, b1e) = block_byte_range(&blocks[1]);
assert_eq!(b1s, b1_start_before + 2, "block 1 start must shift +2");
assert_eq!(b1e, b1_end_before + 2, "block 1 end must shift +2");
assert_eq!(
b0_end_before + 2,
b1_start_before + 2,
"contiguity must be preserved after insert-at-block-end"
);
}
#[test]
fn apply_edit_preserves_contiguity_invariant() {
let (mut state, mut blocks) = setup("Hello world\n");
let edits: &[(usize, usize, &str)] = &[
(5, 0, "inserted"), (2, 3, "xy"), (0, 0, "prefix"), ];
for &(offset, del, ins) in edits {
state.apply_edit(&mut blocks, offset, del, ins);
assert_contiguous(&blocks, state.source.len());
}
}
#[test]
fn apply_edit_line_boundaries_rebuilt() {
let (mut state, mut blocks) = setup("line one\nline two\n");
assert_eq!(state.line_boundaries.len(), 3);
state.apply_edit(&mut blocks, 4, 0, "\n");
assert_eq!(
state.line_boundaries.len(),
4,
"inserting one newline must add one line boundary"
);
}
#[test]
fn apply_edit_line_delta_correct() {
let (mut state, mut blocks) = setup("paragraph\n");
let effect = state.apply_edit(&mut blocks, 9, 0, "\n\n\n");
assert_eq!(
effect.line_delta, 3,
"inserting 3 newlines must yield line_delta = 3"
);
}
#[test]
fn apply_edit_utf8_boundary_validation_panics_on_mid_char() {
let (mut state, mut blocks) = setup("caf\u{00e9}\n"); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
state.apply_edit(&mut blocks, 4, 0, "X");
}));
assert!(
result.is_err(),
"apply_edit at a mid-char byte offset must panic"
);
}
use crate::ui::markdown_view::MarkdownViewState;
fn view_with_blocks(blocks: Vec<DocBlock>) -> MarkdownViewState {
let total_lines = blocks.iter().map(DocBlock::height).sum();
MarkdownViewState {
rendered: blocks,
total_lines,
..Default::default()
}
}
#[test]
fn recompute_active_block_updates_on_cursor_move() {
let (mut state, blocks) = setup(DOC_3);
assert!(blocks.len() >= 3, "DOC_3 must render to at least 3 blocks");
let view = view_with_blocks(blocks);
recompute_active_block(&mut state, &view);
let ab = state.active_block.expect("active_block must be Some");
assert_eq!(ab.index, 0, "byte 0 must be in block 0");
let (_, _) = block_byte_range(&view.rendered[0]);
let (b2_start, _) = block_byte_range(&view.rendered[2]);
set_cursor_to_byte(&mut state, b2_start);
recompute_active_block(&mut state, &view);
let ab2 = state
.active_block
.expect("active_block must be Some after move");
assert_eq!(
ab2.index, 2,
"cursor at block 2 start must identify block 2"
);
}
#[test]
fn cursor_movement_left_decrements_byte_unless_at_zero() {
let source = "Hello world\n";
let (mut state, blocks) = setup(source);
let view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, 5);
move_cursor_left(&mut state, &view);
let byte_after = byte_offset_from_editor_state(&state.editor_state, &state.line_boundaries);
assert_eq!(byte_after, 4, "left from byte 5 must land at byte 4");
set_cursor_to_byte(&mut state, 0);
move_cursor_left(&mut state, &view);
let byte_at_0 = byte_offset_from_editor_state(&state.editor_state, &state.line_boundaries);
assert_eq!(byte_at_0, 0, "left at byte 0 must stay at 0");
}
#[test]
fn cursor_movement_respects_utf8_boundaries() {
let source = "caf\u{00e9}\n"; let (mut state, blocks) = setup(source);
let view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, 3);
move_cursor_right(&mut state, &view);
let byte_after = byte_offset_from_editor_state(&state.editor_state, &state.line_boundaries);
assert_eq!(
byte_after, 5,
"right from byte 3 must skip 2-byte char 'é' and land at byte 5"
);
}
#[test]
fn cursor_movement_down_crosses_block_boundary() {
let (mut state, blocks) = setup(DOC_3);
assert!(blocks.len() >= 2, "DOC_3 must render at least 2 blocks");
let view = view_with_blocks(blocks);
let (_, b0_end) = block_byte_range(&view.rendered[0]);
set_cursor_to_byte(&mut state, b0_end.saturating_sub(1));
recompute_active_block(&mut state, &view);
let before_idx = state.active_block.map(|ab| ab.index).unwrap_or(99);
move_cursor_down(&mut state, &view);
recompute_active_block(&mut state, &view);
let after_idx = state.active_block.map(|ab| ab.index).unwrap_or(99);
assert!(
after_idx >= before_idx,
"moving down must not move backward in block index"
);
}
#[test]
fn cursor_movement_line_start_and_end() {
let source = "first line\nsecond line\n";
let (mut state, blocks) = setup(source);
let view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, 15); move_cursor_line_start(&mut state, &view);
let start_byte = byte_offset_from_editor_state(&state.editor_state, &state.line_boundaries);
assert_eq!(
start_byte, 11,
"line_start must land at the beginning of line 1"
);
move_cursor_line_end(&mut state, &view);
let end_byte = byte_offset_from_editor_state(&state.editor_state, &state.line_boundaries);
assert_eq!(
end_byte, 21,
"line_end must land at last char before the newline"
);
}
#[test]
fn active_block_raw_height_matches_wrapped_slice() {
let source = "Short paragraph.\n";
let (_, blocks) = setup(source);
let (b_start, b_end) = block_byte_range(&blocks[0]);
let slice = &source[b_start..b_end];
let raw_span = ratatui::text::Span::raw(slice);
let wrapped = crate::text_layout::wrap_spans(&[raw_span], 80);
assert_eq!(
wrapped.len(),
2,
"paragraph ending in '\\n' wraps to 2 rows (content + empty)"
);
}
#[test]
fn insert_char_extends_active_block_byte_range() {
let source = "Hello\n";
let (mut state, blocks) = setup(source);
let mut view = view_with_blocks(blocks);
recompute_active_block(&mut state, &view);
let before = state.active_block.expect("active_block must be Some");
let before_len = before.end_byte - before.start_byte;
for ch in ['A', 'B', 'C', 'D', 'E'] {
insert_char(&mut state, &mut view, ch);
}
recompute_active_block(&mut state, &view);
let after = state
.active_block
.expect("active_block must be Some after inserts");
let after_len = after.end_byte - after.start_byte;
assert_eq!(
after_len,
before_len + 5,
"5 inserted chars must extend the active block byte range by 5"
);
}
#[test]
fn insert_char_active_block_slice_includes_new_char() {
let source = "Hello\n";
let (mut state, blocks) = setup(source);
let mut view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, 5);
recompute_active_block(&mut state, &view);
insert_char(&mut state, &mut view, 'X');
let ab = state
.active_block
.expect("active_block must be Some after insert");
let slice = &state.source[ab.start_byte..ab.end_byte];
assert_eq!(
slice, "HelloX\n",
"active block slice must reflect the inserted X — got {slice:?}"
);
}
#[test]
fn insert_char_in_middle_of_active_block_appears_in_slice() {
let source = "Hello\n";
let (mut state, blocks) = setup(source);
let mut view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, 3);
recompute_active_block(&mut state, &view);
insert_char(&mut state, &mut view, 'X');
let ab = state.active_block.expect("active_block must be Some");
let slice = &state.source[ab.start_byte..ab.end_byte];
assert_eq!(
slice, "HelXlo\n",
"mid-block insert must show X between the ls — got {slice:?}"
);
}
#[test]
fn insert_at_active_block_start_lands_in_previous_block() {
let (mut state, blocks) = setup(DOC_3);
assert!(blocks.len() >= 3, "DOC_3 must render to at least 3 blocks");
let mut view = view_with_blocks(blocks);
let (b2_start, b2_end) = block_byte_range(&view.rendered[2]);
set_cursor_to_byte(&mut state, b2_start);
recompute_active_block(&mut state, &view);
assert_eq!(
state.active_block.unwrap().index,
2,
"cursor must be in block 2"
);
let len_before = b2_end - b2_start;
insert_char(&mut state, &mut view, 'X');
let ab = state.active_block.expect("active_block must be Some");
let slice = &state.source[ab.start_byte..ab.end_byte];
let len_after = ab.end_byte - ab.start_byte;
assert_eq!(
len_after, len_before,
"insert at block 2 start must NOT extend block 2 (the X goes to block 1) — \
this is the apply_edit insert-at-end convention; got slice {slice:?}"
);
}
#[test]
fn backspace_at_byte_zero_does_nothing() {
let source = "Hello\n";
let (mut state, blocks) = setup(source);
let mut view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, 0);
delete_char_before(&mut state, &mut view);
assert_eq!(
state.source, source,
"backspace at byte 0 must not mutate source"
);
}
#[test]
fn backspace_in_middle_of_block_decrements_byte_range() {
let (mut state, blocks) = setup(DOC_3);
assert!(blocks.len() >= 3, "DOC_3 must render to at least 3 blocks");
let mut view = view_with_blocks(blocks);
let (b1_start_before, b1_end_before) = block_byte_range(&view.rendered[1]);
let (b2_start_before, b2_end_before) = block_byte_range(&view.rendered[2]);
let cursor_byte = b1_start_before + 10;
set_cursor_to_byte(&mut state, cursor_byte);
delete_char_before(&mut state, &mut view);
let (b1s, b1e) = block_byte_range(&view.rendered[1]);
let (b2s, b2e) = block_byte_range(&view.rendered[2]);
assert_eq!(b1s, b1_start_before, "block 1 start must not change");
assert_eq!(b1e, b1_end_before - 1, "block 1 end must shrink by 1");
assert_eq!(b2s, b2_start_before - 1, "block 2 start must shift -1");
assert_eq!(b2e, b2_end_before - 1, "block 2 end must shift -1");
}
#[test]
fn enter_inserts_newline_extends_block() {
let source = "Hello world\n";
let (mut state, blocks) = setup(source);
let mut view = view_with_blocks(blocks);
let (_, b0_end_before) = block_byte_range(&view.rendered[0]);
set_cursor_to_byte(&mut state, 5); insert_char(&mut state, &mut view, '\n');
let (_, b0_end_after) = block_byte_range(&view.rendered[0]);
assert_eq!(
b0_end_after,
b0_end_before + 1,
"inserting one newline must extend the block's end byte by 1"
);
assert_eq!(
state.source, "Hello\n world\n",
"source must reflect the inserted newline"
);
}
#[test]
fn cursor_leave_after_edits_reparses_block() {
let source = "Hello\n\n```mermaid\ngraph LR\nA-->B\n```\n";
let (mut state, blocks) = setup(source);
assert!(blocks.len() >= 2, "doc must have text + mermaid");
let mut view = view_with_blocks(blocks);
let original_id = match &view.rendered[0] {
DocBlock::Text { id, .. } => *id,
_ => panic!("block 0 must be a text block"),
};
set_cursor_to_byte(&mut state, 5);
for ch in " world".chars() {
insert_char(&mut state, &mut view, ch);
}
reparse_and_splice_block(&state, &mut view, 0, &palette(), theme());
let new_id = match &view.rendered[0] {
DocBlock::Text { id, .. } => *id,
_ => panic!("block 0 must still be a text block after re-parse"),
};
assert_ne!(
new_id, original_id,
"re-parsed block must have a different TextBlockId (content changed)"
);
}
#[test]
fn type_mermaid_fence_splits_text_block() {
let source = "Intro.\n\nOutro.\n";
let (mut state, blocks) = setup(source);
let mut view = view_with_blocks(blocks);
let block_count_before = view.rendered.len();
assert!(
block_count_before >= 2,
"expected at least 2 text blocks for 2 paragraphs"
);
let fence = "```mermaid\ngraph LR\nA-->B\n```\n\n";
set_cursor_to_byte(&mut state, 8);
for ch in fence.chars() {
insert_char(&mut state, &mut view, ch);
}
reparse_and_splice_block(&state, &mut view, 0, &palette(), theme());
assert!(
view.rendered.len() >= 3,
"inserting a mermaid fence must produce at least 3 blocks (text+mermaid+text); \
source = {:?}, block count = {}",
state.source,
view.rendered.len(),
);
assert!(
view.rendered
.iter()
.any(|b| matches!(b, DocBlock::Mermaid { .. })),
"re-parsed document must contain a Mermaid block",
);
}
#[test]
fn delete_char_after_removes_correct_char() {
let source = "Hello\n";
let (mut state, blocks) = setup(source);
let mut view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, 0); delete_char_after(&mut state, &mut view);
assert_eq!(state.source, "ello\n", "Delete at byte 0 must remove 'H'");
}
#[test]
fn delete_char_after_at_end_is_noop() {
let source = "Hi\n";
let (mut state, blocks) = setup(source);
let mut view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, source.len()); delete_char_after(&mut state, &mut view);
assert_eq!(
state.source, "Hi\n",
"Delete past end must not change source"
);
}
#[test]
fn utf8_safe_editing_insert_and_backspace() {
let source = "Hello\n";
let (mut state, blocks) = setup(source);
let mut view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, 5); insert_char(&mut state, &mut view, '\u{00e9}');
assert_eq!(state.source, "Hello\u{00e9}\n");
delete_char_before(&mut state, &mut view);
assert_eq!(
state.source, "Hello\n",
"backspace after multi-byte insert must restore original source"
);
}
#[test]
fn full_reparse_rebuilds_block_list() {
let source = "Para one.\n\n```mermaid\ngraph LR\nA-->B\n```\n\nPara two.\n";
let (mut state, blocks) = setup(source);
let mut view = view_with_blocks(blocks);
let original_len = view.rendered.len();
state.apply_edit(&mut view.rendered, 0, 0, "New intro.\n\n");
full_reparse(&state, &mut view, &palette(), theme());
assert!(
view.rendered.len() >= original_len,
"full_reparse must produce at least as many blocks after inserting a new paragraph"
);
if let Some(last) = view.rendered.last() {
let (_, end) = block_byte_range(last);
assert_eq!(
end,
state.source.len(),
"full_reparse: last block end must equal source length"
);
}
}
#[test]
fn is_dirty_tracks_edits_and_clears_on_baseline_update() {
let source = "Hello\n";
let (mut state, mut blocks) = setup(source);
assert!(!state.is_dirty(), "fresh state must not be dirty");
state.apply_edit(&mut blocks, 0, 0, "X");
assert!(state.is_dirty(), "state must be dirty after edit");
state.baseline.clone_from(&state.source);
assert!(
!state.is_dirty(),
"state must not be dirty after baseline sync"
);
}
fn find_mermaid_block(blocks: &[DocBlock]) -> usize {
blocks
.iter()
.position(|b| matches!(b, DocBlock::Mermaid { .. }))
.expect("test document must contain at least one DocBlock::Mermaid")
}
#[test]
fn active_mermaid_renders_raw_when_cursor_inside() {
let (mut state, blocks) = setup(DOC_3);
let mermaid_idx = find_mermaid_block(&blocks);
let view = view_with_blocks(blocks);
let (mermaid_start, mermaid_end) = block_byte_range(&view.rendered[mermaid_idx]);
set_cursor_to_byte(&mut state, mermaid_start);
recompute_active_block(&mut state, &view);
let ab = state
.active_block
.expect("active_block must be Some when cursor is inside mermaid");
assert_eq!(
ab.index, mermaid_idx,
"cursor at mermaid source_byte_start must activate the mermaid block"
);
assert_eq!(
ab.start_byte, mermaid_start,
"active_block.start_byte must equal the mermaid block's source_byte_start"
);
assert_eq!(
ab.end_byte, mermaid_end,
"active_block.end_byte must equal the mermaid block's source_byte_end"
);
let raw_slice = &state.source[mermaid_start..mermaid_end];
assert!(
raw_slice.starts_with("```mermaid"),
"raw source slice for a mermaid block must start with the opening fence, got: {raw_slice:?}"
);
assert!(
raw_slice.trim_end().ends_with("```"),
"raw source slice must include the closing fence, got: {raw_slice:?}"
);
assert!(
!raw_slice.contains('\u{2502}') && !raw_slice.contains('\u{250C}'),
"raw source slice must not contain box-drawing chars (those appear only in formatted render)"
);
}
#[test]
fn mermaid_cursor_leave_with_unchanged_source_keeps_id_and_cache() {
let (_, blocks) = setup(DOC_3);
let mermaid_idx = find_mermaid_block(&blocks);
let id_before = match &blocks[mermaid_idx] {
DocBlock::Mermaid { id, .. } => *id,
_ => panic!("expected DocBlock::Mermaid at mermaid_idx"),
};
let blocks2 = crate::markdown::renderer::render_markdown(DOC_3, &palette(), theme());
let mermaid_idx2 = find_mermaid_block(&blocks2);
let id_after = match &blocks2[mermaid_idx2] {
DocBlock::Mermaid { id, .. } => *id,
_ => panic!("expected DocBlock::Mermaid after re-parse"),
};
assert_eq!(
id_before.0, id_after.0,
"unchanged mermaid source must yield the same MermaidBlockId \
so the cache entry survives and no spurious re-render is triggered"
);
}
#[test]
fn mermaid_cursor_leave_triggers_reparse_with_new_id() {
let (_, blocks) = setup(DOC_3);
let mermaid_idx = find_mermaid_block(&blocks);
let original_id = match &blocks[mermaid_idx] {
DocBlock::Mermaid { id, .. } => *id,
_ => panic!("expected DocBlock::Mermaid at mermaid_idx"),
};
let modified = DOC_3.replace("A-->B", "A-->C");
let blocks2 = crate::markdown::renderer::render_markdown(&modified, &palette(), theme());
let mermaid_idx2 = find_mermaid_block(&blocks2);
let new_id = match &blocks2[mermaid_idx2] {
DocBlock::Mermaid { id, .. } => *id,
_ => panic!("expected DocBlock::Mermaid after re-parse of modified source"),
};
assert_ne!(
new_id.0, original_id.0,
"changed mermaid content must produce a new MermaidBlockId \
(different hash → cache miss → async re-render queued)"
);
}
#[test]
fn cursor_inside_mermaid_block_byte_to_visual_works() {
use crate::markdown::cursor_bridge::byte_to_visual_raw;
let (state, blocks) = setup(DOC_3);
let mermaid_idx = find_mermaid_block(&blocks);
let view = view_with_blocks(blocks);
let (mermaid_start, _) = block_byte_range(&view.rendered[mermaid_idx]);
let mermaid_block = &view.rendered[mermaid_idx];
let (row, col) = byte_to_visual_raw(mermaid_block, &state.source, 0, 80, mermaid_start);
assert_eq!(
row, 0,
"cursor at start of mermaid block must be on visual row 0"
);
assert_eq!(col, 0, "cursor at start of mermaid block must be at col 0");
let mid_first_row = mermaid_start + 3;
let (row2, col2) = byte_to_visual_raw(mermaid_block, &state.source, 0, 80, mid_first_row);
assert_eq!(
row2, 0,
"cursor 3 bytes into the opening fence must remain on row 0"
);
assert_eq!(
col2, 3,
"cursor 3 bytes into the opening fence must be at col 3"
);
}
#[test]
fn editing_inside_active_mermaid_extends_byte_range() {
let (mut state, blocks) = setup(DOC_3);
let mermaid_idx = find_mermaid_block(&blocks);
let mut view = view_with_blocks(blocks);
let (mermaid_start, mermaid_end_before) = block_byte_range(&view.rendered[mermaid_idx]);
set_cursor_to_byte(&mut state, mermaid_start);
recompute_active_block(&mut state, &view);
insert_char(&mut state, &mut view, 'Z');
let (_, mermaid_end_after) = block_byte_range(&view.rendered[mermaid_idx]);
assert_eq!(
mermaid_end_after,
mermaid_end_before + 1,
"inserting one ASCII char inside the mermaid block must extend source_byte_end by 1"
);
if let Some(next_block) = view.rendered.get(mermaid_idx + 1) {
let (next_start, _) = block_byte_range(next_block);
assert_eq!(
next_start,
mermaid_end_before + 1,
"block after the mermaid must shift right by 1 after insert"
);
}
assert_contiguous(&view.rendered, state.source.len());
}
const DOC_WITH_TABLE: &str = "Intro.\n\n| Col1 | Col2 |\n|---|---|\n| a | b |\n\nOutro.\n";
fn find_table_block(blocks: &[DocBlock]) -> usize {
blocks
.iter()
.position(|b| matches!(b, DocBlock::Table(_)))
.expect("DOC_WITH_TABLE must render to at least one DocBlock::Table")
}
#[test]
fn active_table_renders_raw_when_cursor_inside() {
let (mut state, blocks) = setup(DOC_WITH_TABLE);
let table_idx = find_table_block(&blocks);
let view = view_with_blocks(blocks);
let (table_start, _) = block_byte_range(&view.rendered[table_idx]);
set_cursor_to_byte(&mut state, table_start);
recompute_active_block(&mut state, &view);
let ab = state.active_block.expect("active_block must be Some");
assert_eq!(
ab.index, table_idx,
"cursor at the table's source_byte_start must identify the table block as active"
);
let (expected_start, expected_end) = block_byte_range(&view.rendered[table_idx]);
assert_eq!(
ab.start_byte, expected_start,
"active_block.start_byte must equal the table's source_byte_start"
);
assert_eq!(
ab.end_byte, expected_end,
"active_block.end_byte must equal the table's source_byte_end"
);
let raw_slice = &state.source[expected_start..expected_end];
assert!(
raw_slice.contains('|'),
"raw source slice for a table block must contain '|' characters"
);
assert!(
!raw_slice.contains('\u{2502}') && !raw_slice.contains('\u{250C}'),
"raw source slice must not contain box-drawing chars (those appear only in formatted render)"
);
}
#[test]
fn active_table_re_renders_box_on_cursor_leave() {
let (mut state, blocks) = setup(DOC_WITH_TABLE);
let table_idx = find_table_block(&blocks);
let (table_start, table_end) = block_byte_range(&blocks[table_idx]);
let view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, table_start);
recompute_active_block(&mut state, &view);
let inside = state
.active_block
.expect("active_block must be Some inside table");
assert_eq!(
inside.index, table_idx,
"cursor inside table must activate table block"
);
set_cursor_to_byte(&mut state, table_end);
recompute_active_block(&mut state, &view);
let outside = state
.active_block
.expect("active_block must be Some after table");
assert_ne!(
outside.index, table_idx,
"cursor past the table's source_byte_end must no longer activate the table block"
);
assert!(
outside.index > table_idx,
"block after the table must have a higher index than the table"
);
let (state2, blocks2) = setup(DOC_WITH_TABLE);
let mut view2 = view_with_blocks(blocks2);
reparse_and_splice_block(&state2, &mut view2, table_idx, &palette(), theme());
}
#[test]
fn cursor_inside_table_byte_to_visual_works() {
use crate::markdown::cursor_bridge::byte_to_visual_raw;
let (state, blocks) = setup(DOC_WITH_TABLE);
let table_idx = find_table_block(&blocks);
let view = view_with_blocks(blocks);
let (table_start, _) = block_byte_range(&view.rendered[table_idx]);
let table_block = &view.rendered[table_idx];
let (row, col) = byte_to_visual_raw(
table_block,
&state.source,
0, 80,
table_start,
);
assert_eq!(row, 0, "cursor at start of table must be on visual row 0");
assert_eq!(col, 0, "cursor at start of table must be at col 0");
let first_row = "| Col1 | Col2 |\n";
let second_row_byte = table_start + first_row.len();
let (row2, col2) = byte_to_visual_raw(table_block, &state.source, 0, 80, second_row_byte);
assert_eq!(
row2, 1,
"cursor at start of second table row must be on visual row 1"
);
assert_eq!(
col2, 0,
"cursor at start of second table row must be at col 0"
);
}
#[test]
fn editing_inside_active_table_extends_byte_range() {
let (mut state, blocks) = setup(DOC_WITH_TABLE);
let table_idx = find_table_block(&blocks);
let mut view = view_with_blocks(blocks);
let (table_start, table_end_before) = block_byte_range(&view.rendered[table_idx]);
set_cursor_to_byte(&mut state, table_start);
recompute_active_block(&mut state, &view);
insert_char(&mut state, &mut view, 'X');
let (_, table_end_after) = block_byte_range(&view.rendered[table_idx]);
assert_eq!(
table_end_after,
table_end_before + 1,
"inserting one ASCII char inside the table must extend source_byte_end by 1"
);
if let Some(next_block) = view.rendered.get(table_idx + 1) {
let (next_start, _) = block_byte_range(next_block);
assert_eq!(
next_start,
table_end_before + 1,
"block after the table must shift right by 1 after insert"
);
}
assert_contiguous(&view.rendered, state.source.len());
}
#[test]
fn word_right_lands_after_current_word() {
let source = "alpha beta gamma\n";
let (mut state, blocks) = setup(source);
let view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, 0);
super::move_cursor_word_right(&mut state, &view);
let byte = byte_offset_from_editor_state(&state.editor_state, &state.line_boundaries);
assert_eq!(
byte, 5,
"should land at the space after 'alpha', got {byte}"
);
}
#[test]
fn word_right_from_middle_of_word_finishes_it() {
let source = "alpha beta gamma\n";
let (mut state, blocks) = setup(source);
let view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, 2); super::move_cursor_word_right(&mut state, &view);
let byte = byte_offset_from_editor_state(&state.editor_state, &state.line_boundaries);
assert_eq!(byte, 5, "should finish 'alpha', got {byte}");
}
#[test]
fn word_left_lands_at_start_of_previous_word() {
let source = "alpha beta\n";
let (mut state, blocks) = setup(source);
let view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, 10);
super::move_cursor_word_left(&mut state, &view);
let byte = byte_offset_from_editor_state(&state.editor_state, &state.line_boundaries);
assert_eq!(byte, 6, "should land at start of 'beta', got {byte}");
}
#[test]
fn delete_word_before_removes_one_word() {
let source = "alpha beta gamma\n";
let (mut state, blocks) = setup(source);
let mut view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, 10); super::delete_word_before(&mut state, &mut view);
assert_eq!(state.source, "alpha gamma\n", "got {:?}", state.source);
let byte = byte_offset_from_editor_state(&state.editor_state, &state.line_boundaries);
assert_eq!(byte, 6, "cursor should sit where 'beta' started");
}
#[test]
fn delete_to_line_start_clears_to_column_zero() {
let source = "first line\nsecond line\n";
let (mut state, blocks) = setup(source);
let mut view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, 13);
super::delete_to_line_start(&mut state, &mut view);
assert_eq!(
state.source, "first line\ncond line\n",
"got {:?}",
state.source
);
}
#[test]
fn delete_to_line_end_keeps_trailing_newline() {
let source = "first line\nsecond\n";
let (mut state, blocks) = setup(source);
let mut view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, 6); super::delete_to_line_end(&mut state, &mut view);
assert_eq!(state.source, "first \nsecond\n", "got {:?}", state.source);
}
#[test]
fn doc_start_and_doc_end_jump_to_boundaries() {
let source = "abc\ndef\nghi\n";
let (mut state, blocks) = setup(source);
let view = view_with_blocks(blocks);
set_cursor_to_byte(&mut state, 5);
super::move_cursor_doc_start(&mut state, &view);
assert_eq!(
byte_offset_from_editor_state(&state.editor_state, &state.line_boundaries),
0
);
super::move_cursor_doc_end(&mut state, &view);
assert_eq!(
byte_offset_from_editor_state(&state.editor_state, &state.line_boundaries),
source.len()
);
}
}