use crate::styled_text::StyledLine;
use super::cursor::{DocPosition, Selection};
use super::document::{char_to_byte, Block, BlockKind, RichDocument};
use super::edit::split_spans;
fn block_range(start: DocPosition, end: DocPosition, doc_len: usize) -> Option<(usize, usize)> {
if doc_len == 0 {
return None;
}
let last = doc_len - 1;
let first_block = start.block.min(last);
let mut last_block = end.block.min(last);
if last_block > first_block && end.line == 0 && end.col == 0 {
last_block -= 1;
}
Some((first_block, last_block))
}
pub fn set_block_kind(doc: &mut RichDocument, range: Selection, kind: BlockKind) -> bool {
let (start, end) = range.ordered();
let Some((first, last)) = block_range(start, end, doc.blocks.len()) else {
return false;
};
let mut changed = false;
for idx in first..=last {
let Some(block) = doc.blocks.get_mut(idx) else {
break;
};
if block.kind != kind {
block.kind = kind.clone();
if matches!(kind, BlockKind::Heading(_)) {
block.indent = 0;
}
changed = true;
}
}
changed
}
pub fn toggle_block_kind(doc: &mut RichDocument, range: Selection, kind: BlockKind) -> bool {
let (start, end) = range.ordered();
let Some((first, last)) = block_range(start, end, doc.blocks.len()) else {
return false;
};
let mut all_already = true;
let mut any = false;
for idx in first..=last {
if let Some(block) = doc.blocks.get(idx) {
any = true;
if !block_kind_eq(&block.kind, &kind) {
all_already = false;
}
}
}
if !any {
return false;
}
let target = if all_already {
BlockKind::Paragraph
} else {
kind
};
set_block_kind(doc, range, target)
}
pub fn indent_blocks(doc: &mut RichDocument, range: Selection) -> bool {
let (start, end) = range.ordered();
let Some((first, last)) = block_range(start, end, doc.blocks.len()) else {
return false;
};
let mut changed = false;
for idx in first..=last {
let Some(block) = doc.blocks.get_mut(idx) else {
break;
};
if !block_supports_indent(&block.kind) {
continue;
}
let next = block.indent.saturating_add(1);
if next != block.indent {
block.indent = next;
changed = true;
}
}
changed
}
pub fn outdent_blocks(doc: &mut RichDocument, range: Selection) -> bool {
let (start, end) = range.ordered();
let Some((first, last)) = block_range(start, end, doc.blocks.len()) else {
return false;
};
let mut changed = false;
for idx in first..=last {
let Some(block) = doc.blocks.get_mut(idx) else {
break;
};
if !block_supports_indent(&block.kind) {
continue;
}
if block.indent > 0 {
block.indent -= 1;
changed = true;
}
}
changed
}
pub fn convert_selection_to_block(
doc: &mut RichDocument,
range: Selection,
kind: BlockKind,
) -> Option<usize> {
let (start, end) = range.ordered();
if start == end || start.block != end.block || start.line != end.line {
set_block_kind(doc, range, kind);
return None;
}
let block_idx = start.block;
let block = doc.blocks.get(block_idx)?.clone();
if block.lines.len() != 1 {
set_block_kind(doc, range, kind);
return None;
}
let line = &block.lines[0];
let line_len = line.text.chars().count();
if start.col == 0 && end.col >= line_len {
set_block_kind(doc, range, kind);
return None;
}
let start_byte = char_to_byte(&line.text, start.col);
let end_byte = char_to_byte(&line.text, end.col);
let prefix_text = line.text[..start_byte].to_string();
let middle_text = line.text[start_byte..end_byte].to_string();
let suffix_text = line.text[end_byte..].to_string();
let (prefix_spans, rest_spans) = split_spans(&line.spans, start_byte);
let middle_local_end = end_byte - start_byte;
let (middle_spans, suffix_spans) = split_spans(&rest_spans, middle_local_end);
let original_kind = block.kind.clone();
let original_indent = block.indent;
let middle_indent = if matches!(kind, BlockKind::Heading(_)) {
0
} else {
original_indent
};
let mut replacement: Vec<Block> = Vec::with_capacity(3);
let mut middle_offset = 0usize;
if !prefix_text.is_empty() {
replacement.push(Block {
kind: original_kind.clone(),
lines: vec![StyledLine {
text: prefix_text,
spans: prefix_spans,
}],
indent: original_indent,
});
middle_offset = 1;
}
replacement.push(Block {
kind: kind.clone(),
lines: vec![StyledLine {
text: middle_text,
spans: middle_spans,
}],
indent: middle_indent,
});
if !suffix_text.is_empty() {
replacement.push(Block {
kind: original_kind,
lines: vec![StyledLine {
text: suffix_text,
spans: suffix_spans,
}],
indent: original_indent,
});
}
doc.blocks.splice(block_idx..=block_idx, replacement);
Some(block_idx + middle_offset)
}
pub fn insert_divider_after(doc: &mut RichDocument, block_idx: usize) -> usize {
let insert_at = (block_idx + 1).min(doc.blocks.len());
doc.blocks.insert(insert_at, Block::divider());
let paragraph_at = insert_at + 1;
doc.blocks.insert(paragraph_at, Block::paragraph_empty());
paragraph_at
}
fn block_supports_indent(kind: &BlockKind) -> bool {
matches!(
kind,
BlockKind::Paragraph | BlockKind::BulletItem | BlockKind::NumberedItem
)
}
fn block_kind_eq(a: &BlockKind, b: &BlockKind) -> bool {
match (a, b) {
(BlockKind::Paragraph, BlockKind::Paragraph) => true,
(BlockKind::Heading(x), BlockKind::Heading(y)) => x == y,
(BlockKind::BulletItem, BlockKind::BulletItem) => true,
(BlockKind::NumberedItem, BlockKind::NumberedItem) => true,
(BlockKind::Quote, BlockKind::Quote) => true,
(BlockKind::Divider, BlockKind::Divider) => true,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::super::cursor::DocPosition;
use super::*;
use blinc_core::Color;
fn doc(blocks: Vec<Block>) -> RichDocument {
RichDocument::from_blocks(blocks)
}
fn sel(b1: usize, b2: usize) -> Selection {
Selection {
anchor: DocPosition::new(b1, 0, 0),
head: DocPosition::new(b2, 0, 1),
}
}
#[test]
fn set_kind_changes_one_block() {
let mut d = doc(vec![Block::paragraph("hi", Color::WHITE)]);
assert!(set_block_kind(&mut d, sel(0, 0), BlockKind::Heading(2)));
assert_eq!(d.blocks[0].kind, BlockKind::Heading(2));
}
#[test]
fn set_kind_no_op_when_already_matches() {
let mut d = doc(vec![Block::heading(1, "hi", Color::WHITE)]);
assert!(!set_block_kind(&mut d, sel(0, 0), BlockKind::Heading(1)));
}
#[test]
fn set_kind_clears_indent_for_heading() {
let mut blocks = vec![Block::bullet("hi", Color::WHITE)];
blocks[0].indent = 3;
let mut d = doc(blocks);
set_block_kind(&mut d, sel(0, 0), BlockKind::Heading(1));
assert_eq!(d.blocks[0].indent, 0);
}
#[test]
fn toggle_kind_reverts_to_paragraph_when_all_match() {
let mut d = doc(vec![
Block::bullet("a", Color::WHITE),
Block::bullet("b", Color::WHITE),
]);
toggle_block_kind(&mut d, sel(0, 1), BlockKind::BulletItem);
assert_eq!(d.blocks[0].kind, BlockKind::Paragraph);
assert_eq!(d.blocks[1].kind, BlockKind::Paragraph);
}
#[test]
fn toggle_kind_sets_when_some_dont_match() {
let mut d = doc(vec![
Block::bullet("a", Color::WHITE),
Block::paragraph("b", Color::WHITE),
]);
toggle_block_kind(&mut d, sel(0, 1), BlockKind::BulletItem);
assert_eq!(d.blocks[0].kind, BlockKind::BulletItem);
assert_eq!(d.blocks[1].kind, BlockKind::BulletItem);
}
#[test]
fn indent_increments_paragraph_and_bullets() {
let mut d = doc(vec![
Block::paragraph("a", Color::WHITE),
Block::bullet("b", Color::WHITE),
Block::heading(1, "c", Color::WHITE),
]);
indent_blocks(&mut d, sel(0, 2));
assert_eq!(d.blocks[0].indent, 1);
assert_eq!(d.blocks[1].indent, 1);
assert_eq!(d.blocks[2].indent, 0);
}
#[test]
fn outdent_floors_at_zero() {
let mut d = doc(vec![Block::paragraph("a", Color::WHITE)]);
let changed = outdent_blocks(&mut d, sel(0, 0));
assert!(!changed);
assert_eq!(d.blocks[0].indent, 0);
}
#[test]
fn outdent_walks_back_one_level() {
let mut blocks = vec![Block::bullet("a", Color::WHITE)];
blocks[0].indent = 2;
let mut d = doc(blocks);
outdent_blocks(&mut d, sel(0, 0));
assert_eq!(d.blocks[0].indent, 1);
}
#[test]
fn insert_divider_after_inserts_two_blocks() {
let mut d = doc(vec![
Block::paragraph("a", Color::WHITE),
Block::paragraph("b", Color::WHITE),
]);
let landing = insert_divider_after(&mut d, 0);
assert_eq!(d.blocks.len(), 4);
assert_eq!(d.blocks[1].kind, BlockKind::Divider);
assert_eq!(d.blocks[2].kind, BlockKind::Paragraph);
assert!(d.blocks[2].is_empty());
assert_eq!(landing, 2);
assert_eq!(d.blocks[0].lines[0].text, "a");
assert_eq!(d.blocks[3].lines[0].text, "b");
}
#[test]
fn set_kind_trims_trailing_col_zero_endpoint() {
let mut d = doc(vec![
Block::paragraph("first", Color::WHITE),
Block::paragraph("second", Color::WHITE),
]);
let range = Selection {
anchor: DocPosition::new(0, 0, 0),
head: DocPosition::new(1, 0, 0),
};
set_block_kind(&mut d, range, BlockKind::Heading(1));
assert_eq!(d.blocks[0].kind, BlockKind::Heading(1));
assert_eq!(d.blocks[1].kind, BlockKind::Paragraph);
}
#[test]
fn set_kind_includes_trailing_block_when_endpoint_is_inside_it() {
let mut d = doc(vec![
Block::paragraph("first", Color::WHITE),
Block::paragraph("second", Color::WHITE),
]);
let range = Selection {
anchor: DocPosition::new(0, 0, 0),
head: DocPosition::new(1, 0, 1),
};
set_block_kind(&mut d, range, BlockKind::Heading(1));
assert_eq!(d.blocks[0].kind, BlockKind::Heading(1));
assert_eq!(d.blocks[1].kind, BlockKind::Heading(1));
}
#[test]
fn collapsed_selection_at_block_zero_still_affects_block_zero() {
let mut d = doc(vec![Block::paragraph("hi", Color::WHITE)]);
let range = Selection {
anchor: DocPosition::new(0, 0, 0),
head: DocPosition::new(0, 0, 0),
};
set_block_kind(&mut d, range, BlockKind::Heading(1));
assert_eq!(d.blocks[0].kind, BlockKind::Heading(1));
}
#[test]
fn convert_selection_splits_into_three_blocks() {
let mut d = doc(vec![Block::paragraph("Hello world", Color::WHITE)]);
let range = Selection {
anchor: DocPosition::new(0, 0, 3),
head: DocPosition::new(0, 0, 9),
};
let middle = convert_selection_to_block(&mut d, range, BlockKind::Heading(2));
assert_eq!(middle, Some(1));
assert_eq!(d.blocks.len(), 3);
assert_eq!(d.blocks[0].kind, BlockKind::Paragraph);
assert_eq!(d.blocks[0].lines[0].text, "Hel");
assert_eq!(d.blocks[1].kind, BlockKind::Heading(2));
assert_eq!(d.blocks[1].lines[0].text, "lo wor");
assert_eq!(d.blocks[2].kind, BlockKind::Paragraph);
assert_eq!(d.blocks[2].lines[0].text, "ld");
}
#[test]
fn convert_selection_at_block_start_skips_prefix() {
let mut d = doc(vec![Block::paragraph("Hello world", Color::WHITE)]);
let range = Selection {
anchor: DocPosition::new(0, 0, 0),
head: DocPosition::new(0, 0, 5),
};
let middle = convert_selection_to_block(&mut d, range, BlockKind::Heading(1));
assert_eq!(middle, Some(0));
assert_eq!(d.blocks.len(), 2);
assert_eq!(d.blocks[0].kind, BlockKind::Heading(1));
assert_eq!(d.blocks[0].lines[0].text, "Hello");
assert_eq!(d.blocks[1].kind, BlockKind::Paragraph);
assert_eq!(d.blocks[1].lines[0].text, " world");
}
#[test]
fn convert_selection_at_block_end_skips_suffix() {
let mut d = doc(vec![Block::paragraph("Hello world", Color::WHITE)]);
let range = Selection {
anchor: DocPosition::new(0, 0, 6),
head: DocPosition::new(0, 0, 11),
};
let middle = convert_selection_to_block(&mut d, range, BlockKind::Heading(3));
assert_eq!(middle, Some(1));
assert_eq!(d.blocks.len(), 2);
assert_eq!(d.blocks[0].kind, BlockKind::Paragraph);
assert_eq!(d.blocks[0].lines[0].text, "Hello ");
assert_eq!(d.blocks[1].kind, BlockKind::Heading(3));
assert_eq!(d.blocks[1].lines[0].text, "world");
}
#[test]
fn convert_selection_full_line_returns_none_and_converts_in_place() {
let mut d = doc(vec![Block::paragraph("Hello", Color::WHITE)]);
let range = Selection {
anchor: DocPosition::new(0, 0, 0),
head: DocPosition::new(0, 0, 5),
};
let middle = convert_selection_to_block(&mut d, range, BlockKind::Heading(1));
assert_eq!(middle, None);
assert_eq!(d.blocks.len(), 1);
assert_eq!(d.blocks[0].kind, BlockKind::Heading(1));
}
#[test]
fn convert_selection_collapsed_falls_back_to_set_kind() {
let mut d = doc(vec![Block::paragraph("Hello", Color::WHITE)]);
let range = Selection {
anchor: DocPosition::new(0, 0, 2),
head: DocPosition::new(0, 0, 2),
};
let middle = convert_selection_to_block(&mut d, range, BlockKind::Heading(1));
assert_eq!(middle, None);
assert_eq!(d.blocks.len(), 1);
assert_eq!(d.blocks[0].kind, BlockKind::Heading(1));
}
#[test]
fn convert_selection_clears_indent_for_heading_only() {
let mut blocks = vec![Block::paragraph("Hello world", Color::WHITE)];
blocks[0].indent = 2;
let mut d = doc(blocks);
let range = Selection {
anchor: DocPosition::new(0, 0, 3),
head: DocPosition::new(0, 0, 8),
};
convert_selection_to_block(&mut d, range, BlockKind::Heading(2));
assert_eq!(d.blocks[0].indent, 2); assert_eq!(d.blocks[1].indent, 0); assert_eq!(d.blocks[2].indent, 2); }
#[test]
fn toggle_quote_sets_then_clears() {
let mut d = doc(vec![Block::paragraph("hi", Color::WHITE)]);
toggle_block_kind(&mut d, sel(0, 0), BlockKind::Quote);
assert_eq!(d.blocks[0].kind, BlockKind::Quote);
toggle_block_kind(&mut d, sel(0, 0), BlockKind::Quote);
assert_eq!(d.blocks[0].kind, BlockKind::Paragraph);
}
}