use super::markdown_converter::markdown_to_document;
use super::structured_document::*;
use std::cmp::min;
use std::time::{Duration, Instant};
use tdoc::{Document, markdown};
pub type EditResult = Result<(), EditError>;
#[derive(Debug, Clone, PartialEq)]
pub enum EditError {
InvalidPosition,
InvalidBlockIndex,
EmptyDocument,
ConversionFailed(String),
}
const MAX_UNDO_STEPS: usize = 200;
const UNDO_COALESCE_IDLE: Duration = Duration::from_secs(2);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UndoKind {
Typing,
Deleting,
Other,
}
#[derive(Debug, Clone)]
struct EditorSnapshot {
document: StructuredDocument,
cursor: DocumentPosition,
selection: Option<(DocumentPosition, DocumentPosition)>,
}
pub struct StructuredEditor {
document: StructuredDocument,
cursor: DocumentPosition,
selection: Option<(DocumentPosition, DocumentPosition)>, paragraph_cb: Option<Box<dyn FnMut(BlockType) + 'static>>,
undo_stack: Vec<EditorSnapshot>,
redo_stack: Vec<EditorSnapshot>,
undo_baseline: EditorSnapshot,
last_edit_kind: Option<UndoKind>,
last_edit_time: Option<Instant>,
}
impl StructuredEditor {
fn renumber_ordered_from(&mut self, start_index: usize, start_number: u64) {
let mut n = start_number;
let blocks_len = self.document.block_count();
if start_index >= blocks_len {
return;
}
let blocks = self.document.blocks_mut();
let mut i = start_index;
while i < blocks.len() {
match blocks[i].block_type {
BlockType::ListItem { ordered: true, .. } => {
blocks[i].block_type = BlockType::ListItem {
ordered: true,
number: Some(n),
checkbox: None,
};
n += 1;
i += 1;
}
_ => break,
}
}
}
pub fn new() -> Self {
Self::with_document(StructuredDocument::new())
}
pub fn with_document(document: StructuredDocument) -> Self {
let baseline = EditorSnapshot {
document: document.clone(),
cursor: DocumentPosition::start(),
selection: None,
};
StructuredEditor {
document,
cursor: DocumentPosition::start(),
selection: None,
paragraph_cb: None,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
undo_baseline: baseline,
last_edit_kind: None,
last_edit_time: None,
}
}
pub fn document(&self) -> &StructuredDocument {
&self.document
}
pub fn document_mut(&mut self) -> &mut StructuredDocument {
&mut self.document
}
pub fn set_paragraph_change_callback(
&mut self,
cb: Option<Box<dyn FnMut(BlockType) + 'static>>,
) {
self.paragraph_cb = cb;
self.trigger_paragraph_change();
}
fn trigger_paragraph_change(&mut self) {
println!("Triggering paragraph change");
if let Some(cb) = self.paragraph_cb.as_mut() {
println!("Calling paragraph change callback");
let block_type = self
.document
.blocks()
.get(self.cursor.block_index)
.map(|b| b.block_type.clone())
.unwrap_or(BlockType::Paragraph);
cb(block_type);
}
}
fn current_snapshot(&self) -> EditorSnapshot {
EditorSnapshot {
document: self.document.clone(),
cursor: self.cursor,
selection: self.selection,
}
}
pub fn commit_undo_step(&mut self, kind: UndoKind, now: Instant) {
let within_idle_window = self
.last_edit_time
.is_some_and(|t| now.saturating_duration_since(t) < UNDO_COALESCE_IDLE);
self.last_edit_time = Some(now);
self.record_step(kind, within_idle_window);
}
fn record_step(&mut self, kind: UndoKind, within_idle_window: bool) {
if self.undo_baseline.document == self.document {
self.undo_baseline.cursor = self.cursor;
self.undo_baseline.selection = self.selection;
return;
}
let coalesce = kind != UndoKind::Other
&& self.last_edit_kind == Some(kind)
&& within_idle_window
&& !self.undo_stack.is_empty();
if !coalesce {
self.undo_stack.push(self.undo_baseline.clone());
if self.undo_stack.len() > MAX_UNDO_STEPS {
self.undo_stack.remove(0);
}
}
self.redo_stack.clear();
self.undo_baseline = self.current_snapshot();
self.last_edit_kind = Some(kind);
}
pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty() || self.undo_baseline.document != self.document
}
pub fn can_redo(&self) -> bool {
!self.redo_stack.is_empty()
}
pub fn undo(&mut self) -> bool {
self.flush_pending_edit();
let Some(previous) = self.undo_stack.pop() else {
return false;
};
self.redo_stack.push(self.undo_baseline.clone());
self.undo_baseline = previous.clone();
self.last_edit_kind = None;
self.restore_snapshot(previous);
true
}
pub fn redo(&mut self) -> bool {
self.flush_pending_edit();
let Some(next) = self.redo_stack.pop() else {
return false;
};
self.undo_stack.push(self.undo_baseline.clone());
self.undo_baseline = next.clone();
self.last_edit_kind = None;
self.restore_snapshot(next);
true
}
pub fn reset_undo_history(&mut self) {
self.undo_stack.clear();
self.redo_stack.clear();
self.last_edit_kind = None;
self.last_edit_time = None;
self.undo_baseline = self.current_snapshot();
}
fn break_undo_coalescing(&mut self) {
self.last_edit_kind = None;
}
fn flush_pending_edit(&mut self) {
self.record_step(UndoKind::Other, false);
}
fn restore_snapshot(&mut self, snapshot: EditorSnapshot) {
self.document = snapshot.document;
self.cursor = snapshot.cursor;
self.selection = snapshot.selection;
self.normalize_cursor();
self.trigger_paragraph_change();
}
fn normalize_cursor(&mut self) {
self.cursor = self.document.clamp_position(self.cursor);
}
pub fn cursor(&self) -> DocumentPosition {
self.cursor
}
pub fn set_cursor(&mut self, pos: DocumentPosition) {
self.break_undo_coalescing();
self.cursor = self.document.clamp_position(pos);
self.selection = None; self.trigger_paragraph_change();
}
pub fn selection(&self) -> Option<(DocumentPosition, DocumentPosition)> {
self.selection
}
pub fn set_selection(&mut self, start: DocumentPosition, end: DocumentPosition) {
self.break_undo_coalescing();
let start = self.document.clamp_position(start);
let end = self.document.clamp_position_forward(end);
self.selection = Some((start, end));
}
pub fn clear_selection(&mut self) {
self.break_undo_coalescing();
self.selection = None;
}
pub fn select_all(&mut self) {
self.break_undo_coalescing();
if self.document.block_count() == 0 {
self.selection = None;
return;
}
let start = DocumentPosition::new(0, 0);
let last_idx = self.document.block_count() - 1;
let end = {
let blocks = self.document.blocks();
let last_len = blocks[last_idx].text_len();
DocumentPosition::new(last_idx, last_len)
};
self.selection = Some((start, end));
self.cursor = end;
self.normalize_cursor();
}
pub fn extend_selection_to(&mut self, end: DocumentPosition) {
self.break_undo_coalescing();
let end = self.document.clamp_position(end);
if let Some((start, _)) = self.selection {
self.selection = Some((start, end));
} else {
self.selection = Some((self.cursor, end));
}
self.cursor = end;
self.normalize_cursor();
}
fn collect_selection_block_indices(&self) -> Vec<usize> {
let block_count = self.document.block_count();
if block_count == 0 {
return Vec::new();
}
let Some((mut start, mut end)) = self.selection else {
return Vec::new();
};
if start.block_index > end.block_index
|| (start.block_index == end.block_index && start.offset > end.offset)
{
std::mem::swap(&mut start, &mut end);
}
if start.block_index == end.block_index && start.offset == end.offset {
return Vec::new();
}
let start = self.document.clamp_position(start);
let end = self.document.clamp_position_forward(end);
let start_idx = start.block_index.min(block_count - 1);
let end_idx = end.block_index.min(block_count - 1);
let mut indices: Vec<usize> = (start_idx..=end_idx).collect();
if let Some(&last) = indices.last()
&& start_idx != end_idx
&& last == end_idx
{
let include_end = if end.offset == 0 {
self.document
.blocks()
.get(end_idx)
.map(|block| block.is_empty())
.unwrap_or(false)
} else {
true
};
if !include_end {
indices.pop();
}
}
indices.sort_unstable();
indices.dedup();
indices
}
pub fn select_word_at(&mut self, pos: DocumentPosition) {
let pos = self.document.clamp_position(pos);
let blocks = self.document.blocks();
if pos.block_index >= blocks.len() {
return;
}
let block = &blocks[pos.block_index];
let text = block.to_plain_text();
if text.is_empty() || pos.offset >= text.len() {
return;
}
let mut start = pos.offset;
let mut end = pos.offset;
while start > 0 {
let ch = text[..start].chars().next_back().unwrap();
if ch.is_whitespace() || ch.is_ascii_punctuation() {
break;
}
start = text[..start]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
}
let chars = text[end..].char_indices();
for (_, ch) in chars {
if ch.is_whitespace() || ch.is_ascii_punctuation() {
break;
}
end = text[..end]
.chars()
.next()
.map(|c| end + c.len_utf8())
.unwrap_or(end);
}
if start == end {
end = text[end..]
.chars()
.next()
.map(|c| end + c.len_utf8())
.unwrap_or(end);
}
let start_pos = DocumentPosition::new(pos.block_index, start);
let end_pos = DocumentPosition::new(pos.block_index, end);
self.set_selection(start_pos, end_pos);
self.cursor = self.document.clamp_position_forward(end_pos);
}
pub fn select_line_at(&mut self, pos: DocumentPosition) {
let pos = self.document.clamp_position(pos);
let blocks = self.document.blocks();
if pos.block_index >= blocks.len() {
return;
}
let block = &blocks[pos.block_index];
let start_pos = DocumentPosition::new(pos.block_index, 0);
let end_pos = DocumentPosition::new(pos.block_index, block.text_len());
self.set_selection(start_pos, end_pos);
self.cursor = self.document.clamp_position_forward(end_pos);
}
pub fn insert_text(&mut self, text: &str) -> EditResult {
if self.document.is_empty() {
let block = Block::paragraph().with_plain_text(text);
self.document.add_block(block);
self.cursor = DocumentPosition::new(0, text.len());
return Ok(());
}
let block_index = self.cursor.block_index;
if block_index >= self.document.block_count() {
return Err(EditError::InvalidBlockIndex);
}
if self.selection.is_none() && self.is_table_block(block_index) {
return Ok(());
}
if self.selection.is_some() {
self.delete_selection()?;
}
let offset = self.cursor.offset;
let (content_idx, content_offset) = {
let blocks = self.document.blocks();
let block = &blocks[block_index];
Self::find_content_at_offset_static(&block.content, offset)
};
let inner_within_link: Option<(usize, usize)> = {
let blocks = self.document.blocks();
let block = &blocks[block_index];
if content_idx < block.content.len() {
if let InlineContent::Link { content, .. } = &block.content[content_idx] {
let (inner_idx, inner_off) =
Self::find_content_at_offset_static(content, content_offset);
Some((inner_idx, inner_off))
} else {
None
}
} else {
None
}
};
let blocks = self.document.blocks_mut();
let block = &mut blocks[block_index];
if content_idx >= block.content.len() {
block
.content
.push(InlineContent::Text(TextRun::plain(text)));
} else {
match &mut block.content[content_idx] {
InlineContent::Text(run) => {
run.insert_text(content_offset, text);
}
InlineContent::Link { content, .. } => {
let link_len: usize = content.iter().map(|c| c.text_len()).sum();
if content_offset == 0 {
if content_idx > 0 {
if let InlineContent::Text(prev_run) =
&mut block.content[content_idx - 1]
{
let prev_len = prev_run.len();
prev_run.insert_text(prev_len, text);
} else {
block
.content
.insert(content_idx, InlineContent::Text(TextRun::plain(text)));
}
} else {
block
.content
.insert(content_idx, InlineContent::Text(TextRun::plain(text)));
}
} else if content_offset >= link_len {
if content_idx + 1 < block.content.len() {
if let InlineContent::Text(next_run) =
&mut block.content[content_idx + 1]
{
next_run.insert_text(0, text);
} else {
block.content.insert(
content_idx + 1,
InlineContent::Text(TextRun::plain(text)),
);
}
} else {
block
.content
.push(InlineContent::Text(TextRun::plain(text)));
}
} else {
let (inner_idx, inner_off) =
inner_within_link.unwrap_or((content.len(), 0));
if inner_idx >= content.len() {
content.push(InlineContent::Text(TextRun::plain(text)));
} else {
match &mut content[inner_idx] {
InlineContent::Text(run) => run.insert_text(inner_off, text),
_ => content
.insert(inner_idx, InlineContent::Text(TextRun::plain(text))),
}
}
}
}
InlineContent::HardBreak => {
if content_offset == 0 {
block
.content
.insert(content_idx, InlineContent::Text(TextRun::plain(text)));
} else {
if content_idx + 1 < block.content.len() {
match &mut block.content[content_idx + 1] {
InlineContent::Text(run) => run.insert_text(0, text),
_ => block.content.insert(
content_idx + 1,
InlineContent::Text(TextRun::plain(text)),
),
}
} else {
block
.content
.push(InlineContent::Text(TextRun::plain(text)));
}
}
}
}
}
self.cursor.offset += text.len();
Ok(())
}
fn cursor_after_plain_insert(
&self,
start: DocumentPosition,
paragraphs: &[&str],
) -> DocumentPosition {
let insert_block = start
.block_index
.min(self.document.block_count().saturating_sub(1));
let (block_index, offset) = if paragraphs.len() <= 1 {
let inserted_len = paragraphs.first().map(|s| s.len()).unwrap_or(0);
let left_len = start
.offset
.min(self.document.blocks()[insert_block].text_len());
(insert_block, left_len + inserted_len)
} else {
let last_block = (insert_block + paragraphs.len() - 1)
.min(self.document.block_count().saturating_sub(1));
let last_len = paragraphs.last().map(|s| s.len()).unwrap_or(0);
(last_block, last_len)
};
let block_len = self.document.blocks()[block_index].text_len();
DocumentPosition::new(block_index, min(offset, block_len))
}
fn structured_from_tdoc(document: &Document) -> Result<StructuredDocument, EditError> {
let mut buffer = Vec::new();
markdown::write(&mut buffer, document)
.map_err(|err| EditError::ConversionFailed(err.to_string()))?;
let markdown = String::from_utf8(buffer)
.map_err(|err| EditError::ConversionFailed(err.to_string()))?;
Ok(markdown_to_document(&markdown))
}
fn insert_structured_fragment(&mut self, fragment: &StructuredDocument) -> EditResult {
if fragment.block_count() == 0 {
return Ok(());
}
if self.document.is_empty() {
for block in fragment.blocks() {
self.document.add_block(block.clone());
}
let last_idx = self.document.block_count() - 1;
let last_len = self.document.blocks()[last_idx].text_len();
self.cursor = DocumentPosition::new(last_idx, last_len);
self.selection = None;
self.trigger_paragraph_change();
return Ok(());
}
if self.selection.is_some() {
self.delete_selection()?;
}
let block_index = self
.cursor
.block_index
.min(self.document.block_count().saturating_sub(1));
let offset = self
.cursor
.offset
.min(self.document.blocks()[block_index].text_len());
let (right_content, left_empty, original_block_type) = {
let blocks = self.document.blocks_mut();
let block = &mut blocks[block_index];
let right = block.split_content_at(offset);
let empty = block.content.is_empty();
let block_type = block.block_type.clone();
(right, empty, block_type)
};
let mut fragments: Vec<Block> = fragment.blocks().to_vec();
if fragments.is_empty() {
self.cursor = DocumentPosition::new(block_index, offset);
self.selection = None;
return Ok(());
}
let mut last_insert_block_index: Option<usize> = None;
let mut merged_len = 0usize;
{
let blocks = self.document.blocks_mut();
let current_block = &mut blocks[block_index];
if left_empty {
let mut replacement = fragments.remove(0);
replacement.normalize_content();
*current_block = replacement;
last_insert_block_index = Some(block_index);
} else if fragments
.first()
.map(|candidate| Self::can_merge_paragraphs(current_block, candidate))
.unwrap_or(false)
{
let mut first = fragments.remove(0);
merged_len = first.text_len();
current_block.content.append(&mut first.content);
current_block.normalize_content();
}
}
let mut insert_pos = block_index + 1;
for mut block in fragments.into_iter() {
block.normalize_content();
self.document.insert_block(insert_pos, block);
last_insert_block_index = Some(insert_pos);
insert_pos += 1;
}
if !right_content.is_empty() {
let mut tail_block = Block::new(original_block_type);
tail_block.content = right_content;
tail_block.normalize_content();
self.document.insert_block(insert_pos, tail_block);
}
if let Some(idx) = last_insert_block_index {
let len = self.document.blocks()[idx].text_len();
self.cursor = DocumentPosition::new(idx, len);
} else {
self.cursor = DocumentPosition::new(block_index, offset + merged_len);
}
self.selection = None;
self.trigger_paragraph_change();
Ok(())
}
fn can_merge_paragraphs(left: &Block, right: &Block) -> bool {
matches!(left.block_type, BlockType::Paragraph)
&& matches!(right.block_type, BlockType::Paragraph)
}
pub fn insert_newline(&mut self) -> EditResult {
if self.document.is_empty() {
self.document.add_block(Block::paragraph());
self.document.add_block(Block::paragraph());
self.cursor = DocumentPosition::new(1, 0);
return Ok(());
}
let block_index = self.cursor.block_index;
if block_index >= self.document.block_count() {
return Err(EditError::InvalidBlockIndex);
}
if self.selection.is_some() {
self.delete_selection()?;
}
let offset = self.cursor.offset;
let (block_type, is_empty) = {
let blocks = self.document.blocks();
let current_block = &blocks[block_index];
(current_block.block_type.clone(), current_block.is_empty())
};
if let BlockType::ListItem {
ordered,
number,
checkbox,
} = &block_type
{
if is_empty || offset == 0 {
let blocks = self.document.blocks_mut();
blocks[block_index].block_type = BlockType::Paragraph;
self.cursor.offset = 0;
return Ok(());
}
let right_content = {
let blocks = self.document.blocks_mut();
let block = &mut blocks[block_index];
block.split_content_at(offset)
};
let new_number = if *ordered { number.unwrap_or(1) + 1 } else { 0 };
let new_checkbox = if checkbox.is_some() {
Some(false)
} else {
None
};
let mut new_item = Block::new(BlockType::ListItem {
ordered: *ordered,
number: if *ordered { Some(new_number) } else { None },
checkbox: new_checkbox,
});
new_item.content = right_content;
self.document.insert_block(block_index + 1, new_item);
self.cursor = DocumentPosition::new(block_index + 1, 0);
if *ordered {
let cur_left_num = number.unwrap_or_else(|| {
let blocks = self.document.blocks();
let mut start = block_index;
while start > 0 {
if let BlockType::ListItem { ordered: true, .. } =
blocks[start - 1].block_type
{
start -= 1;
} else {
break;
}
}
let first = match blocks[start].block_type {
BlockType::ListItem {
ordered: true,
number,
..
} => number.unwrap_or(1),
_ => 1,
};
let idx_in_run = block_index - start;
first + idx_in_run as u64
});
let start_n = cur_left_num + 1;
self.renumber_ordered_from(block_index + 1, start_n);
}
} else {
let right_content = {
let blocks = self.document.blocks_mut();
let block = &mut blocks[block_index];
block.split_content_at(offset)
};
let mut new_para = Block::paragraph();
new_para.content = right_content;
self.document.insert_block(block_index + 1, new_para);
self.cursor = DocumentPosition::new(block_index + 1, 0);
}
Ok(())
}
pub fn insert_hard_break(&mut self) -> EditResult {
if self.document.is_empty() {
let mut block = Block::paragraph();
block.content.push(InlineContent::HardBreak);
self.document.add_block(block);
self.cursor = DocumentPosition::new(0, 1);
return Ok(());
}
let block_index = self.cursor.block_index;
if block_index >= self.document.block_count() {
return Err(EditError::InvalidBlockIndex);
}
if self.selection.is_some() {
self.delete_selection()?;
}
let offset = self.cursor.offset;
let blocks = self.document.blocks_mut();
let block = &mut blocks[block_index];
let right_content = block.split_content_at(offset);
block.content.push(InlineContent::HardBreak);
block.content.extend(right_content);
self.cursor.offset += 1;
Ok(())
}
fn is_table_block(&self, index: usize) -> bool {
matches!(
self.document.blocks().get(index).map(|b| &b.block_type),
Some(BlockType::Table { .. })
)
}
fn remove_atomic_block(&mut self, index: usize, cursor: DocumentPosition) -> EditResult {
if index >= self.document.block_count() {
return Err(EditError::InvalidBlockIndex);
}
self.document.remove_block(index);
if self.document.block_count() == 0 {
self.document.add_block(Block::paragraph());
self.cursor = DocumentPosition::start();
} else {
self.cursor = cursor;
}
self.selection = None;
self.normalize_cursor();
self.trigger_paragraph_change();
Ok(())
}
pub fn delete_backward(&mut self) -> EditResult {
if self.document.is_empty() {
return Err(EditError::EmptyDocument);
}
if self.selection.is_some() {
return self.delete_selection();
}
let block_index = self.cursor.block_index;
let offset = self.cursor.offset;
if offset == 0 {
if self.is_table_block(block_index) {
let target = if block_index > 0 {
DocumentPosition::new(
block_index - 1,
self.document.blocks()[block_index - 1].text_len(),
)
} else {
DocumentPosition::start()
};
return self.remove_atomic_block(block_index, target);
}
if block_index == 0 {
return Ok(()); }
if self.is_table_block(block_index - 1) {
return self.remove_atomic_block(
block_index - 1,
DocumentPosition::new(block_index - 1, 0),
);
}
let (prev_type, cur_type) = {
let blocks = self.document.blocks();
(
blocks[block_index - 1].block_type.clone(),
blocks[block_index].block_type.clone(),
)
};
let mut renumber_after: Option<(usize, u64)> = None;
{
let blocks = self.document.blocks_mut();
let current_block = blocks.remove(block_index);
let prev_block = &mut blocks[block_index - 1];
let prev_len = prev_block.text_len();
prev_block.content.extend(current_block.content);
prev_block.normalize_content();
self.cursor = DocumentPosition::new(block_index - 1, prev_len);
}
match (prev_type, cur_type) {
(
BlockType::ListItem {
ordered: true,
number: prev_num,
..
},
BlockType::ListItem { ordered: true, .. },
) => {
let start_index = block_index; let start_n = prev_num.unwrap_or(1) + 1;
renumber_after = Some((start_index, start_n));
}
(
BlockType::ListItem {
ordered: true,
number: prev_num,
..
},
BlockType::Paragraph,
) => {
let start_index = block_index; let start_n = prev_num.unwrap_or(1) + 1;
renumber_after = Some((start_index, start_n));
}
(BlockType::Paragraph, BlockType::ListItem { ordered: true, .. }) => {
let start_index = block_index; renumber_after = Some((start_index, 1));
}
_ => {}
}
if let Some((start_idx, start_n)) = renumber_after {
self.renumber_ordered_from(start_idx, start_n);
}
} else {
if let Some(prev_grapheme_start) =
self.document.previous_grapheme_offset(block_index, offset)
&& prev_grapheme_start < offset
{
let blocks = self.document.blocks_mut();
let block = &mut blocks[block_index];
block.delete_text_range(prev_grapheme_start, offset);
self.cursor.offset = prev_grapheme_start;
}
}
self.normalize_cursor();
Ok(())
}
pub fn delete_backward_bytes(&mut self, mut byte_count: usize) -> Result<bool, EditError> {
if byte_count == 0 {
return Ok(false);
}
if self.document.is_empty() {
return Err(EditError::EmptyDocument);
}
self.normalize_cursor();
let original_cursor = self.cursor;
let mut start = original_cursor;
{
let doc = self.document();
if doc.block_count() == 0 {
return Ok(false);
}
if start.block_index >= doc.block_count() {
start.block_index = doc.block_count() - 1;
start.offset = doc.blocks()[start.block_index].text_len();
}
while byte_count > 0 {
if start.offset == 0 {
if start.block_index == 0 {
break;
}
start.block_index -= 1;
start.offset = doc.blocks()[start.block_index].text_len();
continue;
}
if let Some(prev_offset) =
doc.previous_grapheme_offset(start.block_index, start.offset)
{
let removed = start.offset - prev_offset;
start.offset = prev_offset;
if removed == 0 {
break;
}
if removed > byte_count {
byte_count = 0;
} else {
byte_count -= removed;
}
} else {
break;
}
}
}
if start == original_cursor {
return Ok(false);
}
self.set_selection(start, original_cursor);
self.delete_selection()?;
Ok(true)
}
pub fn delete_forward(&mut self) -> EditResult {
if self.document.is_empty() {
return Err(EditError::EmptyDocument);
}
if self.selection.is_some() {
return self.delete_selection();
}
let block_index = self.cursor.block_index;
let offset = self.cursor.offset;
let block_count = self.document.block_count();
if block_index >= block_count {
return Err(EditError::InvalidBlockIndex);
}
let block_len = {
let blocks = self.document.blocks();
blocks[block_index].text_len()
};
if offset >= block_len {
if self.is_table_block(block_index) {
return self
.remove_atomic_block(block_index, DocumentPosition::new(block_index, 0));
}
if block_index >= block_count - 1 {
return Ok(()); }
if self.is_table_block(block_index + 1) {
return self.remove_atomic_block(
block_index + 1,
DocumentPosition::new(block_index, offset),
);
}
let (cur_type, next_type) = {
let blocks = self.document.blocks();
let cur = blocks[block_index].block_type.clone();
let nxt = blocks[block_index + 1].block_type.clone();
(cur, nxt)
};
{
let blocks = self.document.blocks_mut();
let next_block = blocks.remove(block_index + 1);
let block = &mut blocks[block_index];
block.content.extend(next_block.content);
block.normalize_content();
}
match (cur_type, next_type) {
(
BlockType::ListItem {
ordered: true,
number: cur_num,
..
},
BlockType::ListItem { ordered: true, .. },
) => {
let start_index = block_index + 1;
let start_n = cur_num.unwrap_or(1) + 1;
self.renumber_ordered_from(start_index, start_n);
}
(BlockType::Paragraph, BlockType::ListItem { ordered: true, .. }) => {
let start_index = block_index + 1;
self.renumber_ordered_from(start_index, 1);
}
(
BlockType::ListItem {
ordered: true,
number: cur_num,
..
},
BlockType::Paragraph,
) => {
let start_index = block_index + 1;
self.renumber_ordered_from(start_index, cur_num.unwrap_or(1) + 1);
}
_ => {}
}
self.normalize_cursor();
return Ok(());
}
let next_grapheme_end = self
.document
.next_grapheme_offset(block_index, offset)
.unwrap_or(offset);
if next_grapheme_end > offset {
let blocks = self.document.blocks_mut();
let block = &mut blocks[block_index];
block.delete_text_range(offset, next_grapheme_end);
}
self.normalize_cursor();
Ok(())
}
pub fn delete_word_backward(&mut self) -> EditResult {
if self.document.is_empty() {
return Err(EditError::EmptyDocument);
}
if self.selection.is_some() {
return self.delete_selection();
}
if self.is_table_block(self.cursor.block_index) {
return self.delete_backward();
}
let from = self.cursor;
let to = self.word_left_position(from);
if to == from {
return Ok(());
}
self.document.delete_range(to, from);
self.cursor = to;
self.normalize_cursor();
self.selection = None;
Ok(())
}
pub fn delete_word_forward(&mut self) -> EditResult {
if self.document.is_empty() {
return Err(EditError::EmptyDocument);
}
if self.selection.is_some() {
return self.delete_selection();
}
if self.is_table_block(self.cursor.block_index) {
return self.delete_forward();
}
let from = self.cursor;
let to = self.word_right_position(from);
if to == from {
return Ok(());
}
self.document.delete_range(from, to);
self.normalize_cursor();
self.selection = None;
Ok(())
}
pub fn delete_selection(&mut self) -> EditResult {
let Some((start, end)) = self.selection else {
return Ok(());
};
let (start, end) = if start.block_index < end.block_index
|| (start.block_index == end.block_index && start.offset <= end.offset)
{
(start, end)
} else {
(end, start)
};
self.document.delete_range(start, end);
self.cursor = start;
self.selection = None;
self.normalize_cursor();
Ok(())
}
pub fn move_cursor_left(&mut self) {
self.break_undo_coalescing();
if self.document.block_count() == 0 {
self.cursor = DocumentPosition::start();
self.selection = None;
return;
}
if self.cursor.block_index >= self.document.block_count() {
self.cursor.block_index = self.document.block_count() - 1;
let blocks = self.document.blocks();
self.cursor.offset = blocks[self.cursor.block_index].text_len();
}
if self.cursor.offset > 0 {
self.cursor = self.document.previous_grapheme_position(self.cursor);
} else if self.cursor.block_index > 0 {
self.cursor.block_index -= 1;
let blocks = self.document.blocks();
self.cursor.offset = blocks[self.cursor.block_index].text_len();
}
self.normalize_cursor();
self.selection = None;
}
pub fn move_cursor_right(&mut self) {
self.break_undo_coalescing();
let block_count = self.document.block_count();
if block_count == 0 {
self.cursor = DocumentPosition::start();
self.selection = None;
return;
}
if self.cursor.block_index >= block_count {
self.cursor.block_index = block_count - 1;
let blocks = self.document.blocks();
self.cursor.offset = blocks[self.cursor.block_index].text_len();
}
let blocks = self.document.blocks();
let block_len = blocks[self.cursor.block_index].text_len();
if self.cursor.offset < block_len {
self.cursor = self.document.next_grapheme_position(self.cursor);
} else if self.cursor.block_index < block_count - 1 {
self.cursor.block_index += 1;
self.cursor.offset = 0;
}
self.normalize_cursor();
self.selection = None;
}
pub fn move_cursor_up(&mut self) {
self.break_undo_coalescing();
if self.cursor.block_index > 0 {
self.cursor.block_index -= 1;
let blocks = self.document.blocks();
let new_block_len = blocks[self.cursor.block_index].text_len();
self.cursor.offset = self.cursor.offset.min(new_block_len);
self.normalize_cursor();
}
self.selection = None;
}
pub fn move_cursor_down(&mut self) {
self.break_undo_coalescing();
let block_count = self.document.block_count();
if block_count == 0 {
self.cursor = DocumentPosition::start();
self.selection = None;
return;
}
if self.cursor.block_index + 1 < block_count {
self.cursor.block_index += 1;
let blocks = self.document.blocks();
let new_block_len = blocks[self.cursor.block_index].text_len();
self.cursor.offset = self.cursor.offset.min(new_block_len);
self.normalize_cursor();
}
self.selection = None;
}
pub fn move_cursor_to_line_start(&mut self) {
self.break_undo_coalescing();
self.cursor.offset = 0;
self.normalize_cursor();
self.selection = None;
}
pub fn move_cursor_to_line_end(&mut self) {
self.break_undo_coalescing();
let blocks = self.document.blocks();
if self.cursor.block_index < blocks.len() {
self.cursor.offset = blocks[self.cursor.block_index].text_len();
self.normalize_cursor();
}
self.selection = None;
}
pub fn move_word_right(&mut self) {
self.break_undo_coalescing();
let new_pos = self.word_right_position(self.cursor);
self.cursor = new_pos;
self.selection = None;
}
pub fn move_word_left(&mut self) {
self.break_undo_coalescing();
let new_pos = self.word_left_position(self.cursor);
self.cursor = new_pos;
self.selection = None;
}
pub fn move_word_right_extend(&mut self) {
let new_pos = self.word_right_position(self.cursor);
if new_pos != self.cursor {
self.extend_selection_to(new_pos);
}
}
pub fn move_word_left_extend(&mut self) {
let new_pos = self.word_left_position(self.cursor);
if new_pos != self.cursor {
self.extend_selection_to(new_pos);
}
}
fn word_right_position(&self, pos: DocumentPosition) -> DocumentPosition {
let blocks = self.document.blocks();
if pos.block_index >= blocks.len() {
return self.document.clamp_position(pos);
}
let text = blocks[pos.block_index].to_plain_text();
let mut i = pos.offset.min(text.len());
if i >= text.len() {
if pos.block_index + 1 < blocks.len() {
return DocumentPosition::new(pos.block_index + 1, 0);
}
return self.document.clamp_position(pos);
}
while i < text.len() {
let ch = text[i..].chars().next().unwrap();
if ch.is_whitespace() || ch.is_ascii_punctuation() {
i += ch.len_utf8();
} else {
break;
}
}
while i < text.len() {
let ch = text[i..].chars().next().unwrap();
if !(ch.is_whitespace() || ch.is_ascii_punctuation()) {
i += ch.len_utf8();
} else {
break;
}
}
self.document
.clamp_position_forward(DocumentPosition::new(pos.block_index, i))
}
fn word_left_position(&self, pos: DocumentPosition) -> DocumentPosition {
let blocks = self.document.blocks();
if pos.block_index >= blocks.len() {
return self.document.clamp_position(pos);
}
let text = blocks[pos.block_index].to_plain_text();
let mut i = pos.offset.min(text.len());
if i == 0 {
if pos.block_index > 0 {
return DocumentPosition::new(
pos.block_index - 1,
blocks[pos.block_index - 1].text_len(),
);
}
return self.document.clamp_position(pos);
}
while i > 0 {
let (prev_i, ch) = {
let mut it = text[..i].char_indices();
let (prev_idx, prev_ch) = it.next_back().unwrap();
(prev_idx, prev_ch)
};
if ch.is_whitespace() || ch.is_ascii_punctuation() {
i = prev_i;
} else {
break;
}
}
while i > 0 {
let (prev_i, ch) = {
let mut it = text[..i].char_indices();
let (prev_idx, prev_ch) = it.next_back().unwrap();
(prev_idx, prev_ch)
};
if !(ch.is_whitespace() || ch.is_ascii_punctuation()) {
i = prev_i;
} else {
break;
}
}
self.document
.clamp_position(DocumentPosition::new(pos.block_index, i))
}
pub fn move_cursor_left_extend(&mut self) {
self.normalize_cursor();
let new_pos = if self.document.block_count() == 0 {
self.cursor
} else if self.cursor.offset > 0 {
self.document.previous_grapheme_position(self.cursor)
} else if self.cursor.block_index > 0 {
let blocks = self.document.blocks();
DocumentPosition::new(
self.cursor.block_index - 1,
blocks[self.cursor.block_index - 1].text_len(),
)
} else {
self.cursor
};
if new_pos != self.cursor {
self.extend_selection_to(new_pos);
}
}
pub fn move_cursor_right_extend(&mut self) {
self.normalize_cursor();
let block_count = self.document.block_count();
if block_count == 0 {
return;
}
let current_index = self.cursor.block_index.min(block_count - 1);
let blocks = self.document.blocks();
let block_len = blocks[current_index].text_len();
let new_pos = if self.cursor.offset < block_len {
self.document.next_grapheme_position(self.cursor)
} else if current_index + 1 < block_count {
DocumentPosition::new(current_index + 1, 0)
} else {
self.cursor
};
if new_pos != self.cursor {
self.extend_selection_to(new_pos);
}
}
pub fn move_cursor_up_extend(&mut self) {
if self.cursor.block_index > 0 {
let blocks = self.document.blocks();
let new_block_len = blocks[self.cursor.block_index - 1].text_len();
let new_pos = DocumentPosition::new(
self.cursor.block_index - 1,
self.cursor.offset.min(new_block_len),
);
self.extend_selection_to(new_pos);
}
}
pub fn move_cursor_down_extend(&mut self) {
let blocks = self.document.blocks();
if self.cursor.block_index < blocks.len() - 1 {
let new_block_len = blocks[self.cursor.block_index + 1].text_len();
let new_pos = DocumentPosition::new(
self.cursor.block_index + 1,
self.cursor.offset.min(new_block_len),
);
self.extend_selection_to(new_pos);
}
}
pub fn move_cursor_to_line_start_extend(&mut self) {
let new_pos = DocumentPosition::new(self.cursor.block_index, 0);
if new_pos != self.cursor {
self.extend_selection_to(new_pos);
}
}
pub fn move_cursor_to_line_end_extend(&mut self) {
let blocks = self.document.blocks();
if self.cursor.block_index < blocks.len() {
let new_pos = DocumentPosition::new(
self.cursor.block_index,
blocks[self.cursor.block_index].text_len(),
);
if new_pos != self.cursor {
self.extend_selection_to(new_pos);
}
}
}
pub fn toggle_heading(&mut self) -> EditResult {
let block_index = self.cursor.block_index;
if block_index >= self.document.block_count() {
return Err(EditError::InvalidBlockIndex);
}
let blocks = self.document.blocks_mut();
let block = &mut blocks[block_index];
block.block_type = match &block.block_type {
BlockType::Paragraph => BlockType::Heading { level: 1 },
BlockType::Heading { level: 1 } => BlockType::Heading { level: 2 },
BlockType::Heading { level: 2 } => BlockType::Heading { level: 3 },
BlockType::Heading { level: 3 } => BlockType::Paragraph,
BlockType::Heading { level } => BlockType::Heading {
level: (*level % 3) + 1,
},
BlockType::ListItem { .. } => BlockType::Heading { level: 1 },
BlockType::CodeBlock { .. } => BlockType::Heading { level: 1 },
BlockType::BlockQuote => BlockType::Heading { level: 1 },
BlockType::Table { .. } => return Ok(()),
};
Ok(())
}
pub fn insert_inline_at_cursor(&mut self, inline: InlineContent) -> EditResult {
if self.document.is_empty() {
let mut block = Block::paragraph();
block.content.push(inline);
let text_len = block.text_len();
self.document.add_block(block);
self.cursor = DocumentPosition::new(0, text_len);
return Ok(());
}
let block_index = self.cursor.block_index;
if block_index >= self.document.block_count() {
return Err(EditError::InvalidBlockIndex);
}
if self.selection.is_some() {
self.delete_selection()?;
}
let offset = self.cursor.offset;
let (left, right) = {
let blocks = self.document.blocks();
let block = &blocks[block_index];
Self::split_content_at_static(&block.content, offset)
};
let blocks = self.document.blocks_mut();
let block = &mut blocks[block_index];
block.content = left;
block.content.push(inline);
block.content.extend(right);
let inserted_len = match block.content.get(block.content.len().saturating_sub(1)) {
Some(InlineContent::Text(run)) => run.len(),
Some(InlineContent::Link { content, .. }) => content.iter().map(|c| c.text_len()).sum(),
Some(InlineContent::HardBreak) => 1,
_ => 0,
};
self.cursor.offset = offset + inserted_len;
self.selection = None;
Ok(())
}
pub fn replace_selection_with_link(&mut self, destination: &str, text: &str) -> EditResult {
self.delete_selection()?;
self.insert_link_at_cursor(destination, text)
}
pub fn insert_link_at_cursor(&mut self, destination: &str, text: &str) -> EditResult {
let link_inline = InlineContent::Link {
link: Link {
destination: destination.to_string(),
title: None,
},
content: vec![InlineContent::Text(TextRun::plain(text))],
};
self.insert_inline_at_cursor(link_inline)
}
pub fn edit_link_at(
&mut self,
block_index: usize,
inline_index: usize,
destination: &str,
text: &str,
) -> EditResult {
if block_index >= self.document.block_count() {
return Err(EditError::InvalidBlockIndex);
}
let blocks = self.document.blocks_mut();
let block = &mut blocks[block_index];
if inline_index >= block.content.len() {
return Err(EditError::InvalidPosition);
}
if let InlineContent::Link { link, content } = &mut block.content[inline_index] {
link.destination = destination.to_string();
*content = vec![InlineContent::Text(TextRun::plain(text))];
Ok(())
} else {
Err(EditError::InvalidPosition)
}
}
pub fn remove_link_at(&mut self, block_index: usize, inline_index: usize) -> EditResult {
if block_index >= self.document.block_count() {
return Err(EditError::InvalidBlockIndex);
}
let blocks = self.document.blocks_mut();
let block = &mut blocks[block_index];
if inline_index >= block.content.len() {
return Err(EditError::InvalidPosition);
}
if let InlineContent::Link { content, .. } = block.content.remove(inline_index) {
for (i, item) in content.into_iter().enumerate() {
block.content.insert(inline_index + i, item);
}
Ok(())
} else {
Err(EditError::InvalidPosition)
}
}
pub fn text_in_range(&self, start: DocumentPosition, end: DocumentPosition) -> String {
let doc = self.document();
if doc.block_count() == 0 {
return String::new();
}
let mut s = String::new();
let (mut a, mut b) = (start, end);
if a.block_index > b.block_index || (a.block_index == b.block_index && a.offset > b.offset)
{
std::mem::swap(&mut a, &mut b);
}
for bi in a.block_index..=b.block_index {
let block = &doc.blocks()[bi];
let text = block.to_plain_text();
let from = if bi == a.block_index {
a.offset.min(text.len())
} else {
0
};
let to = if bi == b.block_index {
b.offset.min(text.len())
} else {
text.len()
};
if from < to {
if !s.is_empty() {
s.push_str("\n\n");
}
s.push_str(&text[from..to]);
}
}
s
}
pub fn toggle_bold(&mut self) -> EditResult {
self.toggle_style_attribute(|style| {
style.bold = !style.bold;
})
}
pub fn toggle_italic(&mut self) -> EditResult {
self.toggle_style_attribute(|style| {
style.italic = !style.italic;
})
}
fn toggle_style_attribute<F>(&mut self, mut apply_style: F) -> EditResult
where
F: FnMut(&mut TextStyle),
{
let Some((start, end)) = self.selection else {
return Ok(());
};
let (start, end) = if start.block_index < end.block_index
|| (start.block_index == end.block_index && start.offset <= end.offset)
{
(start, end)
} else {
(end, start)
};
if start.block_index == end.block_index {
let block_index = start.block_index;
if block_index >= self.document.block_count() {
return Err(EditError::InvalidBlockIndex);
}
let (content_before, selected_content, content_after) = {
let blocks = self.document.blocks();
let block = &blocks[block_index];
Self::split_content_for_style(&block.content, start.offset, end.offset)
};
let styled_content = Self::map_style_on_runs(selected_content, &mut apply_style);
let blocks = self.document.blocks_mut();
let block = &mut blocks[block_index];
block.content = content_before;
block.content.extend(styled_content);
block.content.extend(content_after);
return Ok(());
}
let blocks_len = self.document.block_count();
if start.block_index >= blocks_len || end.block_index >= blocks_len {
return Err(EditError::InvalidBlockIndex);
}
{
let blocks = self.document.blocks();
let block = &blocks[start.block_index];
let block_len = block.text_len();
let (before, selected, after) =
Self::split_content_for_style(&block.content, start.offset, block_len);
let styled = Self::map_style_on_runs(selected, &mut apply_style);
let blocks = self.document.blocks_mut();
let block_mut = &mut blocks[start.block_index];
block_mut.content = before.into_iter().chain(styled).chain(after).collect();
}
if end.block_index > start.block_index + 1 {
for i in (start.block_index + 1)..end.block_index {
let styled = {
let blocks = self.document.blocks();
let b = &blocks[i];
Self::map_style_on_runs(b.content.clone(), &mut apply_style)
};
let blocks = self.document.blocks_mut();
blocks[i].content = styled;
}
}
{
let (before, selected, after) = {
let blocks = self.document.blocks();
let block = &blocks[end.block_index];
Self::split_content_for_style(&block.content, 0, end.offset)
};
let styled = Self::map_style_on_runs(selected, &mut apply_style);
let blocks = self.document.blocks_mut();
let block_mut = &mut blocks[end.block_index];
block_mut.content = before.into_iter().chain(styled).chain(after).collect();
}
Ok(())
}
fn split_content_for_style(
content: &[InlineContent],
start_offset: usize,
end_offset: usize,
) -> (Vec<InlineContent>, Vec<InlineContent>, Vec<InlineContent>) {
let mut before = Vec::new();
let mut selected = Vec::new();
let mut after = Vec::new();
let mut current_offset = 0;
for item in content {
let item_len = item.text_len();
let item_start = current_offset;
let item_end = current_offset + item_len;
if item_end <= start_offset {
before.push(item.clone());
} else if item_start >= end_offset {
after.push(item.clone());
} else if item_start >= start_offset && item_end <= end_offset {
selected.push(item.clone());
} else {
match item {
InlineContent::Text(run) => {
let text = &run.text;
let sel_start_in_run = start_offset.saturating_sub(item_start);
let sel_end_in_run = end_offset.saturating_sub(item_start).min(item_len);
if sel_start_in_run > 0 {
let mut before_run = run.clone();
before_run.text = text[..sel_start_in_run].to_string();
before.push(InlineContent::Text(before_run));
}
if sel_end_in_run > sel_start_in_run {
let mut selected_run = run.clone();
selected_run.text = text[sel_start_in_run..sel_end_in_run].to_string();
selected.push(InlineContent::Text(selected_run));
}
if sel_end_in_run < item_len {
let mut after_run = run.clone();
after_run.text = text[sel_end_in_run..].to_string();
after.push(InlineContent::Text(after_run));
}
}
_ => {
if item_start < start_offset {
before.push(item.clone());
} else if item_start < end_offset {
selected.push(item.clone());
} else {
after.push(item.clone());
}
}
}
}
current_offset += item_len;
}
(before, selected, after)
}
pub fn toggle_list(&mut self) -> EditResult {
let block_count = self.document.block_count();
if block_count == 0 {
return Err(EditError::InvalidBlockIndex);
}
let selection_blocks = self.collect_selection_block_indices();
if !selection_blocks.is_empty() {
let blocks_snapshot = self.document.blocks();
let all_plain_bullets = selection_blocks.iter().all(|&idx| {
matches!(
blocks_snapshot.get(idx).map(|b| &b.block_type),
Some(BlockType::ListItem {
ordered: false,
number: None,
checkbox: None,
})
)
});
if all_plain_bullets {
return self.set_block_type(BlockType::Paragraph);
}
return self.set_block_type(BlockType::ListItem {
ordered: false,
number: None,
checkbox: None,
});
}
let block_index = self.cursor.block_index;
if block_index >= block_count {
return Err(EditError::InvalidBlockIndex);
}
let convert_ordered_to_bullets = {
let blocks = self.document.blocks();
matches!(
blocks.get(block_index).map(|b| &b.block_type),
Some(BlockType::ListItem { ordered: true, .. })
)
};
if convert_ordered_to_bullets {
let (start, end) = {
let blocks = self.document.blocks();
let mut start = block_index;
while start > 0 {
if let BlockType::ListItem { ordered: true, .. } = blocks[start - 1].block_type
{
start -= 1;
} else {
break;
}
}
let mut end = block_index;
while end + 1 < blocks.len() {
if let BlockType::ListItem { ordered: true, .. } = blocks[end + 1].block_type {
end += 1;
} else {
break;
}
}
(start, end)
};
let blocks = self.document.blocks_mut();
for block in blocks.iter_mut().take(end + 1).skip(start) {
block.block_type = BlockType::ListItem {
ordered: false,
number: None,
checkbox: None,
};
}
self.trigger_paragraph_change();
return Ok(());
}
let convert_checklist_to_bullets = {
let blocks = self.document.blocks();
matches!(
blocks.get(block_index).map(|b| &b.block_type),
Some(BlockType::ListItem {
ordered: false,
checkbox: Some(_),
..
})
)
};
if convert_checklist_to_bullets {
let (start, end) = {
let blocks = self.document.blocks();
let mut start = block_index;
while start > 0 {
if let BlockType::ListItem {
ordered: false,
checkbox: Some(_),
..
} = blocks[start - 1].block_type
{
start -= 1;
} else {
break;
}
}
let mut end = block_index;
while end + 1 < blocks.len() {
if let BlockType::ListItem {
ordered: false,
checkbox: Some(_),
..
} = blocks[end + 1].block_type
{
end += 1;
} else {
break;
}
}
(start, end)
};
let blocks = self.document.blocks_mut();
for block in blocks.iter_mut().take(end + 1).skip(start) {
block.block_type = BlockType::ListItem {
ordered: false,
number: None,
checkbox: None,
};
}
self.trigger_paragraph_change();
return Ok(());
}
let blocks = self.document.blocks_mut();
let block = &mut blocks[block_index];
block.block_type = match &block.block_type {
BlockType::ListItem { ordered: false, .. } => BlockType::Paragraph,
BlockType::ListItem { ordered: true, .. } => BlockType::ListItem {
ordered: false,
number: None,
checkbox: None,
},
BlockType::Paragraph | BlockType::Heading { .. } => BlockType::ListItem {
ordered: false,
number: None,
checkbox: None,
},
BlockType::CodeBlock { .. } => BlockType::ListItem {
ordered: false,
number: None,
checkbox: None,
},
BlockType::BlockQuote => BlockType::ListItem {
ordered: false,
number: None,
checkbox: None,
},
BlockType::Table { .. } => return Ok(()),
};
self.trigger_paragraph_change();
Ok(())
}
pub fn toggle_checklist(&mut self) -> EditResult {
let block_count = self.document.block_count();
if block_count == 0 {
return Err(EditError::InvalidBlockIndex);
}
let selection_blocks = self.collect_selection_block_indices();
if !selection_blocks.is_empty() {
let blocks_snapshot = self.document.blocks();
let all_checklists = selection_blocks.iter().all(|&idx| {
matches!(
blocks_snapshot.get(idx).map(|b| &b.block_type),
Some(BlockType::ListItem {
ordered: false,
number: None,
checkbox: Some(_),
})
)
});
if all_checklists {
return self.set_block_type(BlockType::ListItem {
ordered: false,
number: None,
checkbox: None,
});
}
return self.set_block_type(BlockType::ListItem {
ordered: false,
number: None,
checkbox: Some(false),
});
}
let block_index = self.cursor.block_index;
if block_index >= block_count {
return Err(EditError::InvalidBlockIndex);
}
let current_type = {
let blocks = self.document.blocks();
blocks[block_index].block_type.clone()
};
match current_type {
BlockType::Table { .. } => Ok(()),
BlockType::ListItem { ordered: true, .. } => {
let (start, end) = {
let blocks = self.document.blocks();
let mut start = block_index;
while start > 0 {
if let BlockType::ListItem { ordered: true, .. } =
blocks[start - 1].block_type
{
start -= 1;
} else {
break;
}
}
let mut end = block_index;
while end + 1 < blocks.len() {
if let BlockType::ListItem { ordered: true, .. } =
blocks[end + 1].block_type
{
end += 1;
} else {
break;
}
}
(start, end)
};
let blocks = self.document.blocks_mut();
for block in blocks.iter_mut().take(end + 1).skip(start) {
block.block_type = BlockType::ListItem {
ordered: false,
number: None,
checkbox: Some(false),
};
}
self.trigger_paragraph_change();
Ok(())
}
BlockType::ListItem {
ordered: false,
checkbox: Some(_),
..
} => {
let blocks = self.document.blocks_mut();
blocks[block_index].block_type = BlockType::Paragraph;
self.trigger_paragraph_change();
Ok(())
}
BlockType::ListItem {
ordered: false,
checkbox: None,
..
} => {
let (start, end) = {
let blocks = self.document.blocks();
let mut start = block_index;
while start > 0 {
if let BlockType::ListItem {
ordered: false,
checkbox: None,
..
} = blocks[start - 1].block_type
{
start -= 1;
} else {
break;
}
}
let mut end = block_index;
while end + 1 < blocks.len() {
if let BlockType::ListItem {
ordered: false,
checkbox: None,
..
} = blocks[end + 1].block_type
{
end += 1;
} else {
break;
}
}
(start, end)
};
let blocks = self.document.blocks_mut();
for block in blocks.iter_mut().take(end + 1).skip(start) {
block.block_type = BlockType::ListItem {
ordered: false,
number: None,
checkbox: Some(false),
};
}
self.trigger_paragraph_change();
Ok(())
}
BlockType::Paragraph
| BlockType::Heading { .. }
| BlockType::CodeBlock { .. }
| BlockType::BlockQuote => {
let blocks = self.document.blocks_mut();
blocks[block_index].block_type = BlockType::ListItem {
ordered: false,
number: None,
checkbox: Some(false),
};
self.trigger_paragraph_change();
Ok(())
}
}
}
pub fn toggle_checkmark_at(&mut self, block_index: usize) -> Result<bool, EditError> {
if block_index >= self.document.block_count() {
return Err(EditError::InvalidBlockIndex);
}
let blocks = self.document.blocks_mut();
if let BlockType::ListItem {
checkbox: Some(state),
..
} = &mut blocks[block_index].block_type
{
*state = !*state;
return Ok(true);
}
Ok(false)
}
pub fn toggle_current_checkmark(&mut self) -> Result<bool, EditError> {
let block_index = self.cursor.block_index;
self.toggle_checkmark_at(block_index)
}
fn ordered_start_number_for_selection(&self, selection_blocks: &[usize]) -> u64 {
if selection_blocks.is_empty() {
return 1;
}
let blocks = self.document.blocks();
if blocks.is_empty() {
return 1;
}
let first_idx = selection_blocks[0].min(blocks.len() - 1);
if let Some(BlockType::ListItem {
ordered: true,
number,
..
}) = blocks.get(first_idx).map(|b| &b.block_type)
{
return number.unwrap_or(1);
}
if first_idx == 0 {
return 1;
}
if let Some(BlockType::ListItem { ordered: true, .. }) =
blocks.get(first_idx - 1).map(|b| &b.block_type)
{
let mut run_start = first_idx - 1;
while run_start > 0 {
if let BlockType::ListItem { ordered: true, .. } = blocks[run_start - 1].block_type
{
run_start -= 1;
} else {
break;
}
}
let first_number = match blocks[run_start].block_type {
BlockType::ListItem {
ordered: true,
number,
..
} => number.unwrap_or(1),
_ => 1,
};
let run_len = first_idx - run_start;
return first_number + run_len as u64;
}
1
}
pub fn toggle_ordered_list(&mut self) -> EditResult {
let block_count = self.document.block_count();
if block_count == 0 {
return Err(EditError::InvalidBlockIndex);
}
let selection_blocks = self.collect_selection_block_indices();
if !selection_blocks.is_empty() {
let blocks_snapshot = self.document.blocks();
let all_ordered = selection_blocks.iter().all(|&idx| {
matches!(
blocks_snapshot.get(idx).map(|b| &b.block_type),
Some(BlockType::ListItem { ordered: true, .. })
)
});
if all_ordered {
let result = self.set_block_type(BlockType::Paragraph);
if result.is_ok()
&& let Some(&last_idx) = selection_blocks.last()
&& last_idx + 1 < block_count
{
self.renumber_ordered_from(last_idx + 1, 1);
}
return result;
}
let start_number = self.ordered_start_number_for_selection(&selection_blocks);
return self.set_block_type(BlockType::ListItem {
ordered: true,
number: Some(start_number),
checkbox: None,
});
}
let block_index = self.cursor.block_index;
if block_index >= block_count {
return Err(EditError::InvalidBlockIndex);
}
let current_type = {
let blocks = self.document.blocks();
blocks[block_index].block_type.clone()
};
match current_type {
BlockType::Table { .. } => Ok(()),
BlockType::ListItem { ordered: true, .. } => {
let (_run_start, run_end, _first_num) = {
let blocks = self.document.blocks();
let mut start = block_index;
while start > 0 {
if let BlockType::ListItem { ordered: true, .. } =
blocks[start - 1].block_type
{
start -= 1;
} else {
break;
}
}
let mut end = block_index;
while end + 1 < blocks.len() {
if let BlockType::ListItem { ordered: true, .. } =
blocks[end + 1].block_type
{
end += 1;
} else {
break;
}
}
let first_num = match blocks[start].block_type {
BlockType::ListItem {
ordered: true,
number,
..
} => number.unwrap_or(1),
_ => 1,
};
(start, end, first_num)
};
{
let blocks = self.document.blocks_mut();
blocks[block_index].block_type = BlockType::Paragraph;
}
if block_index < run_end {
let mut n: u64 = 1;
let blocks = self.document.blocks_mut();
for block in blocks.iter_mut().take(run_end + 1).skip(block_index + 1) {
if let BlockType::ListItem { ordered: true, .. } = block.block_type {
block.block_type = BlockType::ListItem {
ordered: true,
number: Some(n),
checkbox: None,
};
n += 1;
} else {
break;
}
}
}
self.trigger_paragraph_change();
Ok(())
}
BlockType::ListItem { ordered: false, .. } => {
let (start, end) = {
let blocks = self.document.blocks();
let mut start = block_index;
while start > 0 {
if let BlockType::ListItem { ordered: false, .. } =
blocks[start - 1].block_type
{
start -= 1;
} else {
break;
}
}
let mut end = block_index;
while end + 1 < blocks.len() {
if let BlockType::ListItem { ordered: false, .. } =
blocks[end + 1].block_type
{
end += 1;
} else {
break;
}
}
(start, end)
};
let blocks = self.document.blocks_mut();
for (num, block) in (1u64..).zip(blocks.iter_mut().take(end + 1).skip(start)) {
block.block_type = BlockType::ListItem {
ordered: true,
number: Some(num),
checkbox: None,
};
}
self.trigger_paragraph_change();
Ok(())
}
BlockType::Paragraph
| BlockType::Heading { .. }
| BlockType::CodeBlock { .. }
| BlockType::BlockQuote => {
let (prev_is_ord, next_is_ord) = {
let blocks = self.document.blocks();
let prev = if block_index > 0 {
matches!(
blocks[block_index - 1].block_type,
BlockType::ListItem { ordered: true, .. }
)
} else {
false
};
let next = if block_index + 1 < blocks.len() {
matches!(
blocks[block_index + 1].block_type,
BlockType::ListItem { ordered: true, .. }
)
} else {
false
};
(prev, next)
};
let current_number = if prev_is_ord {
let (prev_start, prev_end, prev_first) = {
let blocks = self.document.blocks();
let mut start = block_index - 1;
while start > 0 {
if let BlockType::ListItem { ordered: true, .. } =
blocks[start - 1].block_type
{
start -= 1;
} else {
break;
}
}
let first = match blocks[start].block_type {
BlockType::ListItem {
ordered: true,
number,
..
} => number.unwrap_or(1),
_ => 1,
};
(start, block_index - 1, first)
};
let prev_len = prev_end - prev_start + 1;
Some(prev_first + prev_len as u64)
} else {
Some(1)
};
{
let blocks = self.document.blocks_mut();
blocks[block_index].block_type = BlockType::ListItem {
ordered: true,
number: current_number,
checkbox: None,
};
}
if next_is_ord {
let (next_start, next_end) = {
let blocks = self.document.blocks();
let start = block_index + 1; let mut end = start;
while end + 1 < blocks.len() {
if let BlockType::ListItem { ordered: true, .. } =
blocks[end + 1].block_type
{
end += 1;
} else {
break;
}
}
(start, end)
};
let start_num = current_number.unwrap_or(1) + 1;
let blocks = self.document.blocks_mut();
for (n, block) in
(start_num..).zip(blocks.iter_mut().take(next_end + 1).skip(next_start))
{
block.block_type = BlockType::ListItem {
ordered: true,
number: Some(n),
checkbox: None,
};
}
}
self.trigger_paragraph_change();
Ok(())
}
}
}
pub fn toggle_quote(&mut self) -> EditResult {
let block_index = self.cursor.block_index;
if block_index >= self.document.block_count() {
return Err(EditError::InvalidBlockIndex);
}
let blocks = self.document.blocks_mut();
blocks[block_index].block_type = match blocks[block_index].block_type {
BlockType::BlockQuote => BlockType::Paragraph,
_ => BlockType::BlockQuote,
};
self.trigger_paragraph_change();
Ok(())
}
pub fn toggle_code_block(&mut self) -> EditResult {
let block_index = self.cursor.block_index;
if block_index >= self.document.block_count() {
return Err(EditError::InvalidBlockIndex);
}
let blocks = self.document.blocks_mut();
blocks[block_index].block_type = match &blocks[block_index].block_type {
BlockType::CodeBlock { .. } => BlockType::Paragraph,
_ => BlockType::CodeBlock { language: None },
};
self.trigger_paragraph_change();
Ok(())
}
pub fn set_block_type(&mut self, block_type: BlockType) -> EditResult {
let block_count = self.document.block_count();
if block_count == 0 {
return Err(EditError::InvalidBlockIndex);
}
let mut target_blocks = self.collect_selection_block_indices();
if target_blocks.is_empty() {
let cursor_index = self.cursor.block_index;
if cursor_index >= block_count {
return Err(EditError::InvalidBlockIndex);
}
target_blocks.push(cursor_index);
}
target_blocks.sort_unstable();
target_blocks.dedup();
if target_blocks.iter().any(|&idx| idx >= block_count) {
return Err(EditError::InvalidBlockIndex);
}
let ordered_conversion = matches!(block_type, BlockType::ListItem { ordered: true, .. });
let ordered_start_number = if let BlockType::ListItem {
ordered: true,
number,
..
} = &block_type
{
number.unwrap_or(1)
} else {
0
};
let new_types: Vec<BlockType> = (0..target_blocks.len())
.map(|i| match &block_type {
BlockType::Paragraph => BlockType::Paragraph,
BlockType::Heading { level } => BlockType::Heading { level: *level },
BlockType::BlockQuote => BlockType::BlockQuote,
BlockType::CodeBlock { language } => BlockType::CodeBlock {
language: language.clone(),
},
BlockType::ListItem {
ordered, checkbox, ..
} => {
if *ordered {
BlockType::ListItem {
ordered: true,
number: Some(ordered_start_number + i as u64),
checkbox: *checkbox,
}
} else {
BlockType::ListItem {
ordered: false,
number: None,
checkbox: *checkbox,
}
}
}
BlockType::Table { rows } => BlockType::Table { rows: rows.clone() },
})
.collect();
{
let blocks = self.document.blocks_mut();
for (idx, new_type) in target_blocks.iter().copied().zip(new_types) {
blocks[idx].block_type = new_type;
}
}
if ordered_conversion
&& let Some(last_idx) = target_blocks.last().copied()
&& last_idx + 1 < block_count
{
let next_number = ordered_start_number + target_blocks.len() as u64;
self.renumber_ordered_from(last_idx + 1, next_number);
}
self.trigger_paragraph_change();
Ok(())
}
pub fn move_blocks_up(&mut self) -> Result<bool, EditError> {
self.move_blocks(true)
}
pub fn move_blocks_down(&mut self) -> Result<bool, EditError> {
self.move_blocks(false)
}
fn move_blocks(&mut self, up: bool) -> Result<bool, EditError> {
let block_count = self.document.block_count();
if block_count < 2 {
return Ok(false);
}
let indices = self.collect_selection_block_indices();
let (first, last) = match (indices.first(), indices.last()) {
(Some(&f), Some(&l)) => (f, l),
_ => {
let idx = self.cursor.block_index;
if idx >= block_count {
return Err(EditError::InvalidBlockIndex);
}
(idx, idx)
}
};
let touched = if up {
if first == 0 {
return Ok(false);
}
let blocks = self.document.blocks_mut();
let moved = blocks.remove(first - 1);
blocks.insert(last, moved);
self.offset_positions(-1);
(first - 1, last)
} else {
if last + 1 >= block_count {
return Ok(false);
}
let blocks = self.document.blocks_mut();
let moved = blocks.remove(last + 1);
blocks.insert(first, moved);
self.offset_positions(1);
(first, last + 1)
};
self.renumber_ordered_runs_in(touched.0, touched.1);
self.trigger_paragraph_change();
Ok(true)
}
fn offset_positions(&mut self, delta: isize) {
let max_index = self.document.block_count().saturating_sub(1) as isize;
let shift = |pos: DocumentPosition| {
let block_index = (pos.block_index as isize + delta).clamp(0, max_index) as usize;
DocumentPosition::new(block_index, pos.offset)
};
self.cursor = shift(self.cursor);
if let Some((start, end)) = self.selection {
self.selection = Some((shift(start), shift(end)));
}
}
fn renumber_ordered_runs_in(&mut self, lo: usize, hi: usize) {
let block_count = self.document.block_count();
if block_count == 0 {
return;
}
let lo = lo.min(block_count - 1);
let hi = hi.min(block_count - 1);
let is_ordered = |editor: &Self, idx: usize| {
matches!(
editor.document.blocks()[idx].block_type,
BlockType::ListItem { ordered: true, .. }
)
};
let mut start = lo;
while start > 0 && is_ordered(self, start) && is_ordered(self, start - 1) {
start -= 1;
}
let mut i = start;
while i <= hi {
if is_ordered(self, i) {
let start_number = match self.document.blocks()[i].block_type {
BlockType::ListItem {
ordered: true,
number,
..
} => number.unwrap_or(1),
_ => 1,
};
self.renumber_ordered_from(i, start_number);
while i < block_count && is_ordered(self, i) {
i += 1;
}
} else {
i += 1;
}
}
}
pub fn toggle_code(&mut self) -> EditResult {
self.toggle_style_attribute(|style| {
style.code = !style.code;
})
}
pub fn toggle_strikethrough(&mut self) -> EditResult {
self.toggle_style_attribute(|style| {
style.strikethrough = !style.strikethrough;
})
}
pub fn toggle_underline(&mut self) -> EditResult {
self.toggle_style_attribute(|style| {
style.underline = !style.underline;
})
}
pub fn toggle_highlight(&mut self) -> EditResult {
self.toggle_style_attribute(|style| {
style.highlight = !style.highlight;
})
}
pub fn clear_formatting(&mut self) -> EditResult {
let Some((start, end)) = self.selection else {
return Ok(());
};
let (start, end) = if start.block_index < end.block_index
|| (start.block_index == end.block_index && start.offset <= end.offset)
{
(start, end)
} else {
(end, start)
};
if start.block_index == end.block_index {
let block_index = start.block_index;
if block_index >= self.document.block_count() {
return Err(EditError::InvalidBlockIndex);
}
let (before, selected, after) = {
let blocks = self.document.blocks();
let block = &blocks[block_index];
Self::split_content_for_style(&block.content, start.offset, end.offset)
};
let mut clear = |style: &mut TextStyle| {
*style = TextStyle::default();
};
let cleared = Self::map_style_on_runs(selected, &mut clear);
let blocks = self.document.blocks_mut();
let block_mut = &mut blocks[block_index];
block_mut.content = before.into_iter().chain(cleared).chain(after).collect();
return Ok(());
}
let blocks_len = self.document.block_count();
if start.block_index >= blocks_len || end.block_index >= blocks_len {
return Err(EditError::InvalidBlockIndex);
}
let mut clear = |style: &mut TextStyle| {
*style = TextStyle::default();
};
{
let (before, selected, after) = {
let blocks = self.document.blocks();
let block = &blocks[start.block_index];
let len = block.text_len();
Self::split_content_for_style(&block.content, start.offset, len)
};
let cleared = Self::map_style_on_runs(selected, &mut clear);
let blocks = self.document.blocks_mut();
let block_mut = &mut blocks[start.block_index];
block_mut.content = before.into_iter().chain(cleared).chain(after).collect();
}
if end.block_index > start.block_index + 1 {
for i in (start.block_index + 1)..end.block_index {
let cleared_vec = {
let blocks = self.document.blocks();
let b = &blocks[i];
Self::map_style_on_runs(b.content.clone(), &mut clear)
};
let blocks = self.document.blocks_mut();
blocks[i].content = cleared_vec;
}
}
{
let (before, selected, after) = {
let blocks = self.document.blocks();
let block = &blocks[end.block_index];
Self::split_content_for_style(&block.content, 0, end.offset)
};
let cleared = Self::map_style_on_runs(selected, &mut clear);
let blocks = self.document.blocks_mut();
let block_mut = &mut blocks[end.block_index];
block_mut.content = before.into_iter().chain(cleared).chain(after).collect();
}
Ok(())
}
pub fn get_selection_text(&self) -> String {
let Some((start, end)) = self.selection else {
return String::new();
};
let (start, end) = if start.block_index < end.block_index
|| (start.block_index == end.block_index && start.offset <= end.offset)
{
(start, end)
} else {
(end, start)
};
if start.block_index == end.block_index {
let blocks = self.document.blocks();
if start.block_index >= blocks.len() {
return String::new();
}
let block = &blocks[start.block_index];
let text = block.to_plain_text();
if start.offset < text.len() {
let end_offset = end.offset.min(text.len());
return text[start.offset..end_offset].to_string();
}
} else {
let blocks = self.document.blocks();
let mut result = String::new();
for (block_idx, block) in blocks
.iter()
.enumerate()
.take(end.block_index.min(blocks.len() - 1) + 1)
.skip(start.block_index)
{
let text = block.to_plain_text();
if block_idx == start.block_index {
result.push_str(&text[start.offset..]);
} else if block_idx == end.block_index {
let end_offset = end.offset.min(text.len());
result.push_str(&text[..end_offset]);
} else {
result.push_str(&text);
}
if block_idx < end.block_index {
result.push('\n');
}
}
return result;
}
String::new()
}
pub fn get_selection_document(&self) -> Option<StructuredDocument> {
let (start, end) = self.selection?;
let (start, end) = if start.block_index < end.block_index
|| (start.block_index == end.block_index && start.offset <= end.offset)
{
(start, end)
} else {
(end, start)
};
let blocks = self.document.blocks();
if start.block_index >= blocks.len() {
return None;
}
let mut doc = StructuredDocument::new();
if start.block_index == end.block_index {
let block = &blocks[start.block_index];
doc.add_block(Self::extract_block_range(block, start.offset, end.offset));
} else {
let last = end.block_index.min(blocks.len() - 1);
for (idx, block) in blocks
.iter()
.enumerate()
.take(last + 1)
.skip(start.block_index)
{
let extracted = if idx == start.block_index {
Self::extract_block_range(block, start.offset, block.text_len())
} else if idx == last {
Self::extract_block_range(block, 0, end.offset)
} else {
block.clone()
};
doc.add_block(extracted);
}
}
if doc.blocks().iter().all(Block::is_empty) {
None
} else {
Some(doc)
}
}
fn extract_block_range(block: &Block, start: usize, end: usize) -> Block {
let mut head = block.clone();
let tail = head.split_content_at(start);
let mut result = Block::new(block.block_type.clone());
result.content = tail;
let _ = result.split_content_at(end.saturating_sub(start));
result
}
pub fn cut(&mut self) -> Result<String, EditError> {
let text = self.get_selection_text();
if !text.is_empty() {
self.delete_selection()?;
}
Ok(text)
}
pub fn copy(&self) -> String {
self.get_selection_text()
}
pub fn paste(&mut self, text: &str) -> EditResult {
let normalized = normalize_plain_text(text);
if normalized.is_empty() {
return Ok(());
}
let normalized_text = normalized.as_ref();
let paragraphs: Vec<&str> = normalized_text.split('\n').collect();
if let Some((start, end)) = self.selection {
self.document.replace_range(start, end, normalized_text);
let new_cursor = self.cursor_after_plain_insert(start, ¶graphs);
self.cursor = new_cursor;
self.selection = None;
Ok(())
} else if paragraphs.len() > 1 {
let cursor_pos = self.cursor;
self.document
.replace_range(cursor_pos, cursor_pos, normalized_text);
let new_cursor = self.cursor_after_plain_insert(cursor_pos, ¶graphs);
self.cursor = new_cursor;
Ok(())
} else {
self.insert_text(normalized_text)
}
}
pub fn insert_document(&mut self, document: &Document) -> EditResult {
let structured = Self::structured_from_tdoc(document)?;
self.insert_structured_fragment(&structured)
}
fn find_content_at_offset_static(content: &[InlineContent], offset: usize) -> (usize, usize) {
let mut current_offset = 0;
for (idx, item) in content.iter().enumerate() {
let item_len = item.text_len();
if current_offset + item_len >= offset {
return (idx, offset - current_offset);
}
current_offset += item_len;
}
(content.len(), 0)
}
fn split_content_at_static(
content: &[InlineContent],
offset: usize,
) -> (Vec<InlineContent>, Vec<InlineContent>) {
let (idx, content_offset) = Self::find_content_at_offset_static(content, offset);
let mut left = content[..idx].to_vec();
let mut right = content[idx..].to_vec();
if idx < content.len()
&& let Some(InlineContent::Text(run)) = content.get(idx)
&& content_offset > 0
{
if content_offset == run.len() {
left.push(InlineContent::Text(run.clone()));
right.remove(0);
} else if content_offset < run.len() {
let (left_run, right_run) = run.split_at(content_offset);
left.push(InlineContent::Text(left_run));
right.remove(0);
right.insert(0, InlineContent::Text(right_run));
}
}
(left, right)
}
fn map_style_on_runs<F>(items: Vec<InlineContent>, apply: &mut F) -> Vec<InlineContent>
where
F: FnMut(&mut TextStyle),
{
items
.into_iter()
.map(|item| match item {
InlineContent::Text(mut run) => {
apply(&mut run.style);
InlineContent::Text(run)
}
InlineContent::Link { link, content } => {
let mapped = Self::map_style_on_runs(content, apply);
InlineContent::Link {
link,
content: mapped,
}
}
other => other,
})
.collect()
}
}
impl Default for StructuredEditor {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn test_insert_text() {
let mut editor = StructuredEditor::new();
editor.insert_text("Hello").unwrap();
assert_eq!(editor.document().to_plain_text(), "Hello");
assert_eq!(editor.cursor().offset, 5);
}
#[test]
fn selection_document_preserves_block_types_and_styles() {
let mut editor = StructuredEditor::new();
{
let doc = editor.document_mut();
*doc = StructuredDocument::new();
let mut heading = Block::heading(1);
heading
.content
.push(InlineContent::Text(TextRun::plain("Title")));
doc.add_block(heading);
let mut para = Block::paragraph();
para.content
.push(InlineContent::Text(TextRun::plain("Hello ")));
para.content.push(InlineContent::Text(TextRun::new(
"world",
TextStyle::bold(),
)));
doc.add_block(para);
}
editor.set_selection(DocumentPosition::new(0, 2), DocumentPosition::new(1, 8));
let sel = editor
.get_selection_document()
.expect("selection should produce a document");
assert_eq!(sel.block_count(), 2);
assert!(matches!(
sel.blocks()[0].block_type,
BlockType::Heading { level: 1 }
));
assert_eq!(sel.blocks()[0].to_plain_text(), "tle");
assert!(matches!(sel.blocks()[1].block_type, BlockType::Paragraph));
assert_eq!(sel.blocks()[1].to_plain_text(), "Hello wo");
let bold_text: String = sel.blocks()[1]
.content
.iter()
.filter_map(|c| match c {
InlineContent::Text(run) if run.style.bold => Some(run.text.clone()),
_ => None,
})
.collect();
assert_eq!(bold_text, "wo");
}
#[test]
fn selection_document_none_without_selection() {
let mut editor = StructuredEditor::new();
editor.insert_text("Hello").unwrap();
assert!(editor.get_selection_document().is_none());
}
#[test]
fn test_insert_text_multiple() {
let mut editor = StructuredEditor::new();
editor.insert_text("Hello").unwrap();
editor.insert_text(" world").unwrap();
assert_eq!(editor.document().to_plain_text(), "Hello world");
}
#[test]
fn test_delete_backward() {
let mut editor = StructuredEditor::new();
editor.insert_text("Hello").unwrap();
editor.delete_backward().unwrap();
assert_eq!(editor.document().to_plain_text(), "Hell");
assert_eq!(editor.cursor().offset, 4);
}
#[test]
fn cursor_respects_grapheme_clusters() {
let mut editor = StructuredEditor::new();
let flag = "\u{1F1FA}\u{1F1F8}";
editor.insert_text(flag).unwrap();
assert_eq!(editor.cursor().offset, flag.len());
editor.set_cursor(DocumentPosition::new(0, flag.len() / 2));
assert_eq!(
editor.cursor().offset,
0,
"cursor should snap to grapheme start"
);
editor.move_cursor_right();
assert_eq!(editor.cursor().offset, flag.len());
editor.move_cursor_left();
assert_eq!(editor.cursor().offset, 0);
}
#[test]
fn deletion_respects_grapheme_clusters() {
let mut editor = StructuredEditor::new();
let flag = "\u{1F1FA}\u{1F1F8}";
editor.insert_text(flag).unwrap();
editor.set_cursor(DocumentPosition::new(0, flag.len()));
editor.delete_backward().unwrap();
assert_eq!(editor.document().to_plain_text(), "");
assert_eq!(editor.cursor().offset, 0);
editor.insert_text(flag).unwrap();
editor.set_cursor(DocumentPosition::new(0, 0));
editor.delete_forward().unwrap();
assert_eq!(editor.document().to_plain_text(), "");
assert_eq!(editor.cursor().offset, 0);
}
#[test]
fn selection_respects_grapheme_clusters() {
let mut editor = StructuredEditor::new();
let flag = "\u{1F1FA}\u{1F1F8}";
editor.insert_text(flag).unwrap();
editor.move_cursor_to_line_start();
editor.move_cursor_right_extend();
let (start, end) = editor
.selection()
.expect("selection should be created when extending");
assert_eq!(start.offset, 0);
assert_eq!(end.offset, flag.len());
assert_eq!(editor.cursor().offset, flag.len());
}
#[test]
fn test_insert_newline() {
let mut editor = StructuredEditor::new();
editor.insert_text("Hello").unwrap();
editor.insert_newline().unwrap();
editor.insert_text("World").unwrap();
assert_eq!(editor.document().block_count(), 2);
assert_eq!(editor.cursor().block_index, 1);
}
#[test]
fn test_insert_hard_break_in_text() {
let mut editor = StructuredEditor::new();
editor.insert_text("HelloWorld").unwrap();
editor.set_cursor(DocumentPosition::new(0, 5));
editor.insert_hard_break().unwrap();
let block = &editor.document().blocks()[0];
assert_eq!(block.to_plain_text(), "Hello\nWorld");
assert!(matches!(block.content[1], InlineContent::HardBreak));
assert_eq!(editor.cursor(), DocumentPosition::new(0, 6));
}
#[test]
fn test_insert_text_after_trailing_hard_break_appends() {
let mut editor = StructuredEditor::new();
editor.insert_text("Hello").unwrap();
editor.insert_hard_break().unwrap();
editor.insert_text("World").unwrap();
let block = &editor.document().blocks()[0];
assert_eq!(block.content.len(), 3, "Expected text, break, text runs");
assert!(
matches!(block.content[1], InlineContent::HardBreak),
"Second inline should remain the hard break"
);
assert!(
matches!(block.content[2], InlineContent::Text(_)),
"Inserted text should appear after the hard break"
);
assert_eq!(block.to_plain_text(), "Hello\nWorld");
assert_eq!(
editor.cursor(),
DocumentPosition::new(0, block.text_len()),
"Cursor should advance past inserted text"
);
}
#[test]
fn test_cursor_movement() {
let mut editor = StructuredEditor::new();
editor.insert_text("Hello").unwrap();
editor.move_cursor_left();
assert_eq!(editor.cursor().offset, 4);
editor.move_cursor_to_line_start();
assert_eq!(editor.cursor().offset, 0);
editor.move_cursor_to_line_end();
assert_eq!(editor.cursor().offset, 5);
}
#[test]
fn test_insert_text_inside_link() {
let mut editor = StructuredEditor::new();
editor.insert_text("ab").unwrap();
editor.insert_link_at_cursor("dest", "XY").unwrap();
editor.insert_text("cd").unwrap();
editor.set_cursor(DocumentPosition::new(0, 3));
editor.insert_text("!").unwrap();
assert_eq!(editor.document().to_plain_text(), "abX!Ycd");
let block = &editor.document().blocks()[0];
assert!(matches!(block.content[0], InlineContent::Text(_)));
if let InlineContent::Link { content, .. } = &block.content[1] {
let inner_text: String = content.iter().map(|c| c.to_plain_text()).collect();
assert_eq!(inner_text, "X!Y");
} else {
panic!("Expected a link at index 1");
}
}
#[test]
fn test_insert_text_at_start_of_link_inserts_before() {
let mut editor = StructuredEditor::new();
editor.insert_text("ab").unwrap();
editor.insert_link_at_cursor("dest", "XY").unwrap();
editor.insert_text("cd").unwrap();
editor.set_cursor(DocumentPosition::new(0, 2));
editor.insert_text("!").unwrap();
assert_eq!(editor.document().to_plain_text(), "ab!XYcd");
let block = &editor.document().blocks()[0];
assert!(matches!(block.content[0], InlineContent::Text(_)));
if let InlineContent::Link { content, .. } = &block.content[1] {
let inner_text: String = content.iter().map(|c| c.to_plain_text()).collect();
assert_eq!(inner_text, "XY");
} else {
panic!("Expected a link at index 1");
}
}
#[test]
fn test_insert_text_at_end_of_link_inserts_after() {
let mut editor = StructuredEditor::new();
editor.insert_text("ab").unwrap();
editor.insert_link_at_cursor("dest", "XY").unwrap();
editor.insert_text("cd").unwrap();
editor.set_cursor(DocumentPosition::new(0, 4));
editor.insert_text("!").unwrap();
assert_eq!(editor.document().to_plain_text(), "abXY!cd");
let block = &editor.document().blocks()[0];
if let InlineContent::Link { content, .. } = &block.content[1] {
let inner_text: String = content.iter().map(|c| c.to_plain_text()).collect();
assert_eq!(inner_text, "XY");
} else {
panic!("Expected a link at index 1");
}
assert!(matches!(block.content[2], InlineContent::Text(_)));
}
#[test]
fn test_toggle_list_clears_checklist_run() {
let mut editor = StructuredEditor::new();
{
let mut doc = StructuredDocument::new();
for i in 0..3 {
let mut block = Block::new(BlockType::ListItem {
ordered: false,
number: None,
checkbox: Some(i % 2 == 0),
});
block
.content
.push(InlineContent::Text(TextRun::plain(format!("Item {i}"))));
doc.add_block(block);
}
*editor.document_mut() = doc;
}
editor.set_cursor(DocumentPosition::new(1, 0));
editor.toggle_list().unwrap();
let blocks = editor.document().blocks();
assert!(blocks.iter().all(|block| {
matches!(
block.block_type,
BlockType::ListItem {
ordered: false,
number: None,
checkbox: None,
}
)
}));
}
#[test]
fn test_set_block_type_multi_block_bullet() {
let mut editor = StructuredEditor::new();
let mut doc = StructuredDocument::new();
for text in ["One", "Two", "Three"] {
let block = Block::paragraph().with_plain_text(text);
doc.add_block(block);
}
let last_len = doc.blocks().last().unwrap().text_len();
*editor.document_mut() = doc;
editor.set_selection(
DocumentPosition::new(0, 0),
DocumentPosition::new(2, last_len),
);
editor
.set_block_type(BlockType::ListItem {
ordered: false,
number: None,
checkbox: None,
})
.unwrap();
let blocks = editor.document().blocks();
assert!(blocks.iter().all(|block| {
matches!(
block.block_type,
BlockType::ListItem {
ordered: false,
number: None,
checkbox: None,
}
)
}));
}
#[test]
fn test_set_block_type_multi_block_ordered() {
let mut editor = StructuredEditor::new();
let mut doc = StructuredDocument::new();
for text in ["One", "Two", "Three"] {
let block = Block::paragraph().with_plain_text(text);
doc.add_block(block);
}
let last_len = doc.blocks().last().unwrap().text_len();
*editor.document_mut() = doc;
editor.set_selection(
DocumentPosition::new(0, 0),
DocumentPosition::new(2, last_len),
);
editor
.set_block_type(BlockType::ListItem {
ordered: true,
number: Some(1),
checkbox: None,
})
.unwrap();
let blocks = editor.document().blocks();
for (i, block) in blocks.iter().enumerate() {
match block.block_type {
BlockType::ListItem {
ordered: true,
number,
checkbox: None,
} => assert_eq!(number, Some(1 + i as u64)),
ref other => panic!("unexpected block type: {:?}", other),
}
}
}
#[test]
fn test_toggle_list_multi_block_selection() {
let mut editor = StructuredEditor::new();
let mut doc = StructuredDocument::new();
for text in ["One", "Two", "Three"] {
let block = Block::paragraph().with_plain_text(text);
doc.add_block(block);
}
let last_len = doc.blocks().last().unwrap().text_len();
*editor.document_mut() = doc;
editor.set_selection(
DocumentPosition::new(0, 0),
DocumentPosition::new(2, last_len),
);
editor.toggle_list().unwrap();
{
let blocks = editor.document().blocks();
assert!(blocks.iter().all(|block| {
matches!(
block.block_type,
BlockType::ListItem {
ordered: false,
number: None,
checkbox: None,
}
)
}));
}
editor.set_selection(
DocumentPosition::new(0, 0),
DocumentPosition::new(2, last_len),
);
editor.toggle_list().unwrap();
let blocks = editor.document().blocks();
assert!(
blocks
.iter()
.all(|block| matches!(block.block_type, BlockType::Paragraph))
);
}
#[test]
fn test_toggle_ordered_list_multi_block_selection() {
let mut editor = StructuredEditor::new();
let mut doc = StructuredDocument::new();
for text in ["One", "Two", "Three"] {
let block = Block::paragraph().with_plain_text(text);
doc.add_block(block);
}
let last_len = doc.blocks().last().unwrap().text_len();
*editor.document_mut() = doc;
editor.set_selection(
DocumentPosition::new(0, 0),
DocumentPosition::new(2, last_len),
);
editor.toggle_ordered_list().unwrap();
{
let blocks = editor.document().blocks();
for (i, block) in blocks.iter().enumerate() {
match block.block_type {
BlockType::ListItem {
ordered: true,
number,
checkbox: None,
} => assert_eq!(number, Some(1 + i as u64)),
ref other => panic!("expected ordered list item, found {:?}", other),
}
}
}
editor.set_selection(
DocumentPosition::new(0, 0),
DocumentPosition::new(2, last_len),
);
editor.toggle_ordered_list().unwrap();
let blocks = editor.document().blocks();
assert!(
blocks
.iter()
.all(|block| matches!(block.block_type, BlockType::Paragraph))
);
}
#[test]
fn test_toggle_ordered_list_continues_numbering_after_run() {
let mut editor = StructuredEditor::new();
let mut doc = StructuredDocument::new();
doc.add_block(
Block::new(BlockType::ListItem {
ordered: true,
number: Some(3),
checkbox: None,
})
.with_plain_text("Item 3"),
);
doc.add_block(
Block::new(BlockType::ListItem {
ordered: true,
number: Some(4),
checkbox: None,
})
.with_plain_text("Item 4"),
);
doc.add_block(Block::paragraph().with_plain_text("Next"));
doc.add_block(Block::paragraph().with_plain_text("Another"));
let last_len = doc.blocks().last().unwrap().text_len();
*editor.document_mut() = doc;
editor.set_selection(
DocumentPosition::new(2, 0),
DocumentPosition::new(3, last_len),
);
editor.toggle_ordered_list().unwrap();
let blocks = editor.document().blocks();
assert_eq!(
blocks[2].block_type,
BlockType::ListItem {
ordered: true,
number: Some(5),
checkbox: None
}
);
assert_eq!(
blocks[3].block_type,
BlockType::ListItem {
ordered: true,
number: Some(6),
checkbox: None
}
);
}
#[test]
fn test_backspace_inside_link() {
let mut editor = StructuredEditor::new();
editor.insert_text("ab").unwrap();
editor.insert_link_at_cursor("dest", "XYZ").unwrap();
editor.insert_text("cd").unwrap();
editor.set_cursor(DocumentPosition::new(0, 4));
editor.delete_backward().unwrap();
assert_eq!(editor.document().to_plain_text(), "abXZcd");
let block = &editor.document().blocks()[0];
if let InlineContent::Link { content, .. } = &block.content[1] {
let inner_text: String = content.iter().map(|c| c.to_plain_text()).collect();
assert_eq!(inner_text, "XZ");
} else {
panic!("Expected a link at index 1");
}
}
#[test]
fn test_delete_forward_inside_link() {
let mut editor = StructuredEditor::new();
editor.insert_text("ab").unwrap();
editor.insert_link_at_cursor("dest", "XYZ").unwrap();
editor.insert_text("cd").unwrap();
editor.set_cursor(DocumentPosition::new(0, 3));
editor.delete_forward().unwrap();
assert_eq!(editor.document().to_plain_text(), "abXZcd");
let block = &editor.document().blocks()[0];
assert_eq!(block.content.len(), 3);
if let InlineContent::Link { content, .. } = &block.content[1] {
let inner_text: String = content.iter().map(|c| c.to_plain_text()).collect();
assert_eq!(inner_text, "XZ");
} else {
panic!("Expected a link at index 1");
}
}
#[test]
fn test_enter_inside_link_splits_and_preserves_links() {
let mut editor = StructuredEditor::new();
editor.insert_text("A ").unwrap();
editor.insert_link_at_cursor("dest", "bc").unwrap();
editor.insert_text(" D").unwrap();
editor.set_cursor(DocumentPosition::new(0, 3));
editor.insert_newline().unwrap();
assert_eq!(editor.document().block_count(), 2);
assert_eq!(editor.document().blocks()[0].to_plain_text(), "A b");
assert_eq!(editor.document().blocks()[1].to_plain_text(), "c D");
if let InlineContent::Link { link, content } = &editor.document().blocks()[0].content[1] {
assert_eq!(link.destination, "dest");
let inner_text: String = content.iter().map(|c| c.to_plain_text()).collect();
assert_eq!(inner_text, "b");
} else {
panic!("Expected a link in first paragraph after split");
}
if let InlineContent::Link { link, content } = &editor.document().blocks()[1].content[0] {
assert_eq!(link.destination, "dest");
let inner_text: String = content.iter().map(|c| c.to_plain_text()).collect();
assert_eq!(inner_text, "c");
} else {
panic!("Expected a link at start of second paragraph after split");
}
}
#[test]
fn test_delete_selection_across_blocks() {
let mut editor = StructuredEditor::new();
editor.insert_text("First para").unwrap();
editor.insert_newline().unwrap();
editor.insert_text("Second").unwrap();
editor.insert_newline().unwrap();
editor.insert_text("Third para").unwrap();
let start = DocumentPosition::new(0, 3);
let end = DocumentPosition::new(2, 2);
editor.set_selection(start, end);
editor.delete_selection().unwrap();
assert_eq!(editor.document().block_count(), 1);
assert_eq!(editor.document().blocks()[0].to_plain_text(), "Firird para");
assert_eq!(editor.cursor(), DocumentPosition::new(0, 3));
}
#[test]
fn test_toggle_bold_across_blocks() {
let mut editor = StructuredEditor::new();
editor.insert_text("First para").unwrap();
editor.insert_newline().unwrap();
editor.insert_text("Second").unwrap();
editor.insert_newline().unwrap();
editor.insert_text("Third para").unwrap();
let start = DocumentPosition::new(0, 3); let end = DocumentPosition::new(2, 2); editor.set_selection(start, end);
editor.toggle_bold().unwrap();
let doc = editor.document();
let b0 = &doc.blocks()[0];
let parts0: Vec<(String, bool)> = b0
.content
.iter()
.filter_map(|c| {
if let InlineContent::Text(run) = c {
Some((run.text.clone(), run.style.bold))
} else {
None
}
})
.collect();
assert!(parts0.len() >= 2);
assert_eq!(parts0[0].0, "Fir");
assert!(!parts0[0].1);
assert!(parts0[1].1);
let b1 = &doc.blocks()[1];
let parts1: Vec<bool> = b1
.content
.iter()
.filter_map(|c| {
if let InlineContent::Text(run) = c {
Some(run.style.bold)
} else {
None
}
})
.collect();
assert!(!parts1.is_empty());
assert!(parts1.into_iter().all(|b| b));
let b2 = &doc.blocks()[2];
let parts2: Vec<(String, bool)> = b2
.content
.iter()
.filter_map(|c| {
if let InlineContent::Text(run) = c {
Some((run.text.clone(), run.style.bold))
} else {
None
}
})
.collect();
assert!(parts2.len() >= 2);
assert_eq!(parts2[0].0, "Th");
assert!(parts2[0].1);
assert!(!parts2.last().unwrap().1);
}
#[test]
fn test_select_all() {
let mut editor = StructuredEditor::new();
editor.insert_text("Hello").unwrap();
editor.insert_newline().unwrap();
editor.insert_text("World").unwrap();
editor.select_all();
let sel = editor.selection().unwrap();
assert_eq!(sel.0, DocumentPosition::new(0, 0));
assert_eq!(sel.1, DocumentPosition::new(1, 5));
assert_eq!(editor.cursor(), DocumentPosition::new(1, 5));
}
#[test]
fn test_paste_multiline_without_selection_inserts_paragraphs() {
let mut editor = StructuredEditor::new();
editor.insert_text("Hello").unwrap();
editor.set_cursor(DocumentPosition::new(0, 5));
editor.paste("Line1\nLine2\nLine3").unwrap();
assert_eq!(editor.document().block_count(), 3);
assert_eq!(editor.document().blocks()[0].to_plain_text(), "HelloLine1");
assert_eq!(editor.document().blocks()[1].to_plain_text(), "Line2");
assert_eq!(editor.document().blocks()[2].to_plain_text(), "Line3");
assert_eq!(editor.cursor(), DocumentPosition::new(2, 5));
}
#[test]
fn test_paste_multiline_replaces_selection() {
let mut editor = StructuredEditor::new();
editor.insert_text("First para").unwrap();
editor.insert_newline().unwrap();
editor.insert_text("Second").unwrap();
let start = DocumentPosition::new(0, 0);
let end = DocumentPosition::new(1, 6);
editor.set_selection(start, end);
editor.paste("LineA\nLineB").unwrap();
assert_eq!(editor.document().block_count(), 2);
assert_eq!(editor.document().blocks()[0].to_plain_text(), "LineA");
assert_eq!(editor.document().blocks()[1].to_plain_text(), "LineB");
assert_eq!(editor.cursor(), DocumentPosition::new(1, 5));
assert!(editor.selection().is_none());
}
#[test]
fn test_insert_document_merges_plain_paragraph() {
use tdoc::ftml;
let mut editor = StructuredEditor::new();
editor.insert_text("Hello ").unwrap();
let doc = ftml! {
p { "dear " b { "friend" } }
};
editor.insert_document(&doc).unwrap();
let block = &editor.document().blocks()[0];
let parts: Vec<(String, bool)> = block
.content
.iter()
.filter_map(|c| {
if let InlineContent::Text(run) = c {
Some((run.text.clone(), run.style.bold))
} else {
None
}
})
.collect();
assert_eq!(parts[0].0, "Hello dear ");
assert!(!parts[0].1);
assert_eq!(parts[1].0, "friend");
assert!(parts[1].1);
}
#[test]
fn test_insert_document_from_html_inserts_blocks() {
use std::io::Cursor;
let mut editor = StructuredEditor::new();
editor.insert_text("Intro").unwrap();
editor.insert_newline().unwrap();
editor.insert_text("Tail").unwrap();
editor.set_cursor(DocumentPosition::new(1, 0));
let html_doc = tdoc::html::parse(Cursor::new(
"<h2>Greeting</h2><p>Bold <strong>news</strong></p>",
))
.unwrap();
editor.insert_document(&html_doc).unwrap();
assert_eq!(editor.document().block_count(), 4);
assert_eq!(
editor.document().blocks()[1].block_type,
BlockType::Heading { level: 2 }
);
assert_eq!(
editor.document().blocks()[2].block_type,
BlockType::Paragraph
);
assert_eq!(editor.document().blocks()[3].to_plain_text(), "Tail");
assert_eq!(editor.cursor(), DocumentPosition::new(2, 9));
}
#[test]
fn test_word_navigation() {
let mut editor = StructuredEditor::new();
editor.insert_text("Hello world").unwrap();
assert_eq!(editor.cursor(), DocumentPosition::new(0, 12));
editor.move_word_left();
assert_eq!(editor.cursor(), DocumentPosition::new(0, 7));
editor.move_word_right_extend();
let sel = editor.selection().unwrap();
assert_eq!(sel.0, DocumentPosition::new(0, 7));
assert_eq!(sel.1, DocumentPosition::new(0, 12));
}
fn editor_with_paragraphs(texts: &[&str]) -> StructuredEditor {
let mut doc = StructuredDocument::new();
for t in texts {
doc.add_block(Block::paragraph().with_plain_text(*t));
}
StructuredEditor::with_document(doc)
}
fn paragraph_texts(editor: &StructuredEditor) -> Vec<String> {
editor
.document()
.blocks()
.iter()
.map(|b| b.to_plain_text())
.collect()
}
fn sample_table() -> Block {
Block::table(vec![TableRow::new(vec![
TableCell::new(false, vec![InlineContent::Text(TextRun::plain("a"))]),
TableCell::new(false, vec![InlineContent::Text(TextRun::plain("b"))]),
])])
}
fn block_types(editor: &StructuredEditor) -> Vec<BlockType> {
editor
.document()
.blocks()
.iter()
.map(|b| b.block_type.clone())
.collect()
}
fn editor_with_blocks(blocks: Vec<Block>) -> StructuredEditor {
let mut doc = StructuredDocument::new();
for b in blocks {
doc.add_block(b);
}
StructuredEditor::with_document(doc)
}
#[test]
fn backspace_on_table_removes_it() {
let mut editor = editor_with_blocks(vec![
Block::paragraph().with_plain_text("before"),
sample_table(),
Block::paragraph().with_plain_text("after"),
]);
editor.set_cursor(DocumentPosition::new(1, 0));
editor.delete_backward().unwrap();
assert_eq!(paragraph_texts(&editor), vec!["before", "after"]);
assert!(!block_types(&editor).iter().any(is_table));
assert_eq!(editor.cursor(), DocumentPosition::new(0, "before".len()));
}
#[test]
fn backspace_just_after_table_removes_table() {
let mut editor = editor_with_blocks(vec![
Block::paragraph().with_plain_text("before"),
sample_table(),
Block::paragraph().with_plain_text("after"),
]);
editor.set_cursor(DocumentPosition::new(2, 0));
editor.delete_backward().unwrap();
assert_eq!(paragraph_texts(&editor), vec!["before", "after"]);
assert!(!block_types(&editor).iter().any(is_table));
assert_eq!(editor.cursor(), DocumentPosition::new(1, 0));
}
#[test]
fn delete_forward_on_table_removes_it() {
let mut editor = editor_with_blocks(vec![
sample_table(),
Block::paragraph().with_plain_text("after"),
]);
editor.set_cursor(DocumentPosition::new(0, 0));
editor.delete_forward().unwrap();
assert_eq!(paragraph_texts(&editor), vec!["after"]);
assert!(!block_types(&editor).iter().any(is_table));
}
#[test]
fn delete_forward_before_table_removes_it() {
let mut editor = editor_with_blocks(vec![
Block::paragraph().with_plain_text("before"),
sample_table(),
]);
editor.set_cursor(DocumentPosition::new(0, "before".len()));
editor.delete_forward().unwrap();
assert_eq!(paragraph_texts(&editor), vec!["before"]);
assert!(!block_types(&editor).iter().any(is_table));
assert_eq!(editor.cursor(), DocumentPosition::new(0, "before".len()));
}
#[test]
fn backspace_removes_table_as_only_block_keeps_a_paragraph() {
let mut editor = editor_with_blocks(vec![sample_table()]);
editor.set_cursor(DocumentPosition::new(0, 0));
editor.delete_backward().unwrap();
assert_eq!(editor.document().block_count(), 1);
assert!(matches!(
editor.document().blocks()[0].block_type,
BlockType::Paragraph
));
}
#[test]
fn typing_on_table_is_a_no_op() {
let mut editor = editor_with_blocks(vec![sample_table()]);
editor.set_cursor(DocumentPosition::new(0, 0));
editor.insert_text("x").unwrap();
assert!(matches!(
editor.document().blocks()[0].block_type,
BlockType::Table { .. }
));
assert!(editor.document().blocks()[0].content.is_empty());
}
#[test]
fn selection_replacing_a_table_removes_it() {
let mut editor = editor_with_blocks(vec![
Block::paragraph().with_plain_text("before"),
sample_table(),
Block::paragraph().with_plain_text("after"),
]);
editor.set_selection(
DocumentPosition::new(0, "before".len()),
DocumentPosition::new(2, 0),
);
editor.insert_text("X").unwrap();
assert!(!block_types(&editor).iter().any(is_table));
assert_eq!(editor.document().to_plain_text(), "beforeXafter");
}
#[test]
fn deleting_selection_starting_at_table_does_not_trap_text() {
let mut editor = editor_with_blocks(vec![
sample_table(),
Block::paragraph().with_plain_text("tail"),
]);
editor.set_selection(DocumentPosition::new(0, 0), DocumentPosition::new(1, 2));
editor.delete_selection().unwrap();
assert!(!block_types(&editor).iter().any(is_table));
assert_eq!(editor.document().to_plain_text(), "il");
}
fn is_table(bt: &BlockType) -> bool {
matches!(bt, BlockType::Table { .. })
}
#[test]
fn move_block_up_swaps_with_previous_and_follows_cursor() {
let mut editor = editor_with_paragraphs(&["A", "B", "C"]);
editor.set_cursor(DocumentPosition::new(1, 1));
assert_eq!(editor.move_blocks_up(), Ok(true));
assert_eq!(paragraph_texts(&editor), ["B", "A", "C"]);
assert_eq!(editor.cursor(), DocumentPosition::new(0, 1));
}
#[test]
fn move_block_down_swaps_with_next_and_follows_cursor() {
let mut editor = editor_with_paragraphs(&["A", "B", "C"]);
editor.set_cursor(DocumentPosition::new(1, 0));
assert_eq!(editor.move_blocks_down(), Ok(true));
assert_eq!(paragraph_texts(&editor), ["A", "C", "B"]);
assert_eq!(editor.cursor(), DocumentPosition::new(2, 0));
}
#[test]
fn move_block_up_at_top_is_noop() {
let mut editor = editor_with_paragraphs(&["A", "B"]);
editor.set_cursor(DocumentPosition::new(0, 0));
assert_eq!(editor.move_blocks_up(), Ok(false));
assert_eq!(paragraph_texts(&editor), ["A", "B"]);
assert_eq!(editor.cursor(), DocumentPosition::new(0, 0));
}
#[test]
fn move_block_down_at_bottom_is_noop() {
let mut editor = editor_with_paragraphs(&["A", "B"]);
editor.set_cursor(DocumentPosition::new(1, 0));
assert_eq!(editor.move_blocks_down(), Ok(false));
assert_eq!(paragraph_texts(&editor), ["A", "B"]);
assert_eq!(editor.cursor(), DocumentPosition::new(1, 0));
}
#[test]
fn move_block_in_single_block_document_is_noop() {
let mut editor = editor_with_paragraphs(&["Only"]);
assert_eq!(editor.move_blocks_up(), Ok(false));
assert_eq!(editor.move_blocks_down(), Ok(false));
assert_eq!(paragraph_texts(&editor), ["Only"]);
}
#[test]
fn move_selected_block_group_moves_together() {
let mut editor = editor_with_paragraphs(&["A", "B", "C", "D"]);
editor.set_selection(DocumentPosition::new(1, 0), DocumentPosition::new(2, 1));
assert_eq!(editor.move_blocks_down(), Ok(true));
assert_eq!(paragraph_texts(&editor), ["A", "D", "B", "C"]);
let (start, end) = editor.selection().unwrap();
assert_eq!(start, DocumentPosition::new(2, 0));
assert_eq!(end, DocumentPosition::new(3, 1));
}
#[test]
fn move_ordered_list_item_renumbers_run() {
let mut doc = StructuredDocument::new();
for n in 1..=3u64 {
doc.add_block(
Block::new(BlockType::ListItem {
ordered: true,
number: Some(n),
checkbox: None,
})
.with_plain_text(format!("item {n}")),
);
}
let mut editor = StructuredEditor::with_document(doc);
editor.set_cursor(DocumentPosition::new(2, 0));
assert_eq!(editor.move_blocks_up(), Ok(true));
assert_eq!(paragraph_texts(&editor), ["item 1", "item 3", "item 2"]);
let numbers: Vec<Option<u64>> = editor
.document()
.blocks()
.iter()
.map(|b| match b.block_type {
BlockType::ListItem { number, .. } => number,
_ => None,
})
.collect();
assert_eq!(numbers, [Some(1), Some(2), Some(3)]);
}
#[test]
fn undo_redo_round_trips_typing() {
let t = Instant::now();
let mut editor = StructuredEditor::new();
editor.insert_text("Hello").unwrap();
editor.commit_undo_step(UndoKind::Typing, t);
assert_eq!(editor.document().to_plain_text(), "Hello");
assert!(editor.undo());
assert_eq!(editor.document().to_plain_text(), "");
assert!(!editor.can_undo());
assert!(editor.redo());
assert_eq!(editor.document().to_plain_text(), "Hello");
assert!(!editor.can_redo());
}
#[test]
fn undo_coalesces_rapid_consecutive_typing() {
let t = Instant::now();
let mut editor = StructuredEditor::new();
for (i, ch) in ["H", "i", "!"].iter().enumerate() {
editor.insert_text(ch).unwrap();
editor.commit_undo_step(UndoKind::Typing, t + Duration::from_millis(i as u64 * 100));
}
assert_eq!(editor.document().to_plain_text(), "Hi!");
assert!(editor.undo());
assert_eq!(editor.document().to_plain_text(), "");
assert!(!editor.can_undo());
}
#[test]
fn idle_pause_breaks_typing_coalescing() {
let t = Instant::now();
let mut editor = StructuredEditor::new();
editor.insert_text("para").unwrap();
editor.commit_undo_step(UndoKind::Typing, t);
editor.insert_text(" fix").unwrap();
editor.commit_undo_step(
UndoKind::Typing,
t + UNDO_COALESCE_IDLE + Duration::from_millis(1),
);
assert_eq!(editor.document().to_plain_text(), "para fix");
assert!(editor.undo());
assert_eq!(editor.document().to_plain_text(), "para");
assert!(editor.undo());
assert_eq!(editor.document().to_plain_text(), "");
}
#[test]
fn cursor_move_breaks_typing_coalescing() {
let t = Instant::now();
let mut editor = StructuredEditor::new();
editor.insert_text("ab").unwrap();
editor.commit_undo_step(UndoKind::Typing, t);
editor.set_cursor(DocumentPosition::new(0, 0));
editor.commit_undo_step(UndoKind::Typing, t);
editor.insert_text("X").unwrap();
editor.commit_undo_step(UndoKind::Typing, t);
assert_eq!(editor.document().to_plain_text(), "Xab");
assert!(editor.undo());
assert_eq!(editor.document().to_plain_text(), "ab");
assert!(editor.undo());
assert_eq!(editor.document().to_plain_text(), "");
}
#[test]
fn a_new_edit_invalidates_redo() {
let t = Instant::now();
let mut editor = StructuredEditor::new();
editor.insert_text("a").unwrap();
editor.commit_undo_step(UndoKind::Typing, t);
editor.set_cursor(editor.cursor());
editor.insert_text("b").unwrap();
editor.commit_undo_step(UndoKind::Typing, t);
assert!(editor.undo()); assert!(editor.can_redo());
editor.insert_text("c").unwrap();
editor.commit_undo_step(UndoKind::Typing, t);
assert!(!editor.can_redo());
assert_eq!(editor.document().to_plain_text(), "ac");
}
#[test]
fn discrete_edits_are_separate_undo_steps() {
let t = Instant::now();
let mut editor = StructuredEditor::new();
editor.insert_text("word").unwrap();
editor.commit_undo_step(UndoKind::Typing, t);
editor.select_all();
editor.toggle_bold().unwrap();
editor.commit_undo_step(UndoKind::Other, t);
assert!(editor.undo());
assert_eq!(editor.document().to_plain_text(), "word");
assert!(editor.undo());
assert_eq!(editor.document().to_plain_text(), "");
}
#[test]
fn undo_restores_caret_to_where_editing_resumed() {
let t = Instant::now();
let mut editor = StructuredEditor::new();
editor.insert_text("abc").unwrap();
editor.commit_undo_step(UndoKind::Typing, t);
editor.set_cursor(DocumentPosition::new(0, 0));
editor.commit_undo_step(UndoKind::Other, t);
editor.insert_text("Z").unwrap();
editor.commit_undo_step(UndoKind::Typing, t);
assert_eq!(editor.document().to_plain_text(), "Zabc");
assert!(editor.undo());
assert_eq!(editor.document().to_plain_text(), "abc");
assert_eq!(editor.cursor(), DocumentPosition::new(0, 0));
}
#[test]
fn reset_undo_history_keeps_content_but_clears_stacks() {
let t = Instant::now();
let mut editor = StructuredEditor::new();
editor.insert_text("data").unwrap();
editor.commit_undo_step(UndoKind::Typing, t);
assert!(editor.can_undo());
editor.reset_undo_history();
assert!(!editor.can_undo());
assert!(!editor.can_redo());
assert_eq!(editor.document().to_plain_text(), "data");
assert!(!editor.undo());
}
#[test]
fn undo_with_uncommitted_edit_is_not_lost() {
let t = Instant::now();
let mut editor = StructuredEditor::new();
editor.insert_text("first").unwrap();
editor.commit_undo_step(UndoKind::Typing, t);
editor.insert_text(" second").unwrap();
assert!(editor.undo());
assert_eq!(editor.document().to_plain_text(), "first");
assert!(editor.redo());
assert_eq!(editor.document().to_plain_text(), "first second");
}
}