use edtui::{EditorState, Lines};
use crate::markdown::DocBlock;
use crate::markdown::cursor_bridge::byte_offset_to_block;
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,
#[allow(dead_code)] pub baseline: String,
pub line_boundaries: Vec<usize>,
pub active_block: Option<BlockSourceRange>,
pub command_line: Option<String>,
pub status_message: Option<String>,
#[allow(dead_code)] 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]
#[allow(dead_code)] pub fn is_dirty(&self) -> bool {
self.source != self.baseline
}
#[allow(dead_code)] 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 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);
}
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
}
#[allow(dead_code)] 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),
}
}
#[allow(dead_code)] 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)"
);
}
}