mod helpers;
use helpers::{
NOTO_SANS, Typesetter, assert_caret_is_real, assert_no_glyph_overlap, make_typesetter,
};
use text_document::TextDocument;
#[test]
fn plain_text_document_renders_glyphs() {
let doc = TextDocument::new();
doc.set_plain_text("Hello, world!").unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
let frame = ts.render();
assert!(
!frame.glyphs.is_empty(),
"plain text document should produce glyph quads"
);
assert!(frame.atlas_dirty);
assert!(frame.atlas_width > 0);
assert_no_glyph_overlap(frame);
}
#[test]
fn html_document_renders_glyphs() {
let doc = TextDocument::new();
let op = doc
.set_html("<p>Bold <b>text</b> and <i>italic</i>.</p>")
.unwrap();
op.wait().unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
let frame = ts.render();
assert!(
!frame.glyphs.is_empty(),
"HTML document should produce glyph quads"
);
}
#[test]
fn markdown_document_renders() {
let doc = TextDocument::new();
let op = doc
.set_markdown("# Heading\n\nParagraph text.\n\n- Item one\n- Item two")
.unwrap();
op.wait().unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
let frame = ts.render();
assert!(
!frame.glyphs.is_empty(),
"Markdown document should produce glyph quads"
);
assert!(
ts.content_height() > 0.0,
"document should have positive content height"
);
}
#[test]
fn multi_paragraph_document_has_increasing_y() {
let doc = TextDocument::new();
doc.set_plain_text("First paragraph.\n\nSecond paragraph.\n\nThird paragraph.")
.unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
let frame = ts.render();
let mut ys: Vec<f32> = frame.glyphs.iter().map(|g| g.screen[1]).collect();
ys.sort_by(|a, b| a.partial_cmp(b).unwrap());
ys.dedup_by(|a, b| (*a - *b).abs() < 1.0);
assert!(
ys.len() >= 3,
"3 paragraphs should produce glyphs at 3+ distinct y positions, got {}",
ys.len()
);
}
#[test]
fn hit_test_on_document_returns_valid_position() {
let doc = TextDocument::new();
doc.set_plain_text("Hello world").unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
let _ = ts.render();
let result = ts.hit_test(40.0, 10.0);
assert!(result.is_some(), "hit test should return a result");
let result = result.unwrap();
assert!(
result.position <= 11, "hit test position {} should be within document bounds",
result.position
);
}
#[test]
fn cursor_and_selection_on_document() {
let doc = TextDocument::new();
doc.set_plain_text("Select this text.").unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
ts.set_cursor(&text_typeset::CursorDisplay {
position: 7,
anchor: 11,
visible: true,
selected_cells: vec![],
});
let frame = ts.render();
let selections: Vec<_> = frame
.decorations
.iter()
.filter(|d| d.kind == text_typeset::DecorationKind::Selection)
.collect();
assert!(
!selections.is_empty(),
"selection should produce highlight rects"
);
let carets: Vec<_> = frame
.decorations
.iter()
.filter(|d| d.kind == text_typeset::DecorationKind::Cursor)
.collect();
assert_eq!(carets.len(), 1, "should have one caret");
}
#[test]
fn html_with_formatting_renders_decorations() {
let doc = TextDocument::new();
let op = doc
.set_html("<p><u>Underlined</u> and <s>strikethrough</s> text.</p>")
.unwrap();
op.wait().unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
let frame = ts.render();
let underlines: Vec<_> = frame
.decorations
.iter()
.filter(|d| d.kind == text_typeset::DecorationKind::Underline)
.collect();
assert!(
!underlines.is_empty(),
"HTML <u> tag should produce Underline decorations"
);
}
#[test]
fn document_with_table_renders() {
let doc = TextDocument::new();
let cursor = doc.cursor();
cursor.insert_table(2, 2).unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
let frame = ts.render();
let borders: Vec<_> = frame
.decorations
.iter()
.filter(|d| d.kind == text_typeset::DecorationKind::TableBorder)
.collect();
assert!(
!borders.is_empty(),
"document with table should produce border decorations"
);
}
#[test]
fn incremental_update_after_edit() {
let doc = TextDocument::new();
doc.set_plain_text("Short.").unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
let frame1 = ts.render();
let count1 = frame1.glyphs.len();
let cursor = doc.cursor();
cursor.set_position(6, text_document::MoveMode::MoveAnchor);
cursor.insert_text(" More text here.").unwrap();
let flow2 = doc.snapshot_flow();
ts.layout_full(&flow2);
let frame2 = ts.render();
let count2 = frame2.glyphs.len();
assert!(
count2 > count1,
"adding text should produce more glyphs: {} -> {}",
count1,
count2
);
}
#[test]
fn text_then_table_then_text_renders_all() {
let doc = TextDocument::new();
doc.set_plain_text("Before table.").unwrap();
let cursor = doc.cursor();
cursor.move_position(
text_document::MoveOperation::End,
text_document::MoveMode::MoveAnchor,
1,
);
cursor.insert_block().unwrap();
cursor.insert_table(1, 2).unwrap();
cursor.move_position(
text_document::MoveOperation::End,
text_document::MoveMode::MoveAnchor,
1,
);
cursor.insert_block().unwrap();
cursor.insert_text("After table.").unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
let frame = ts.render();
assert!(
frame.glyphs.len() >= 20,
"mixed block+table+block should produce many glyphs, got {}",
frame.glyphs.len()
);
let borders: Vec<_> = frame
.decorations
.iter()
.filter(|d| d.kind == text_typeset::DecorationKind::TableBorder)
.collect();
assert!(
!borders.is_empty(),
"table between text blocks should still produce borders"
);
}
#[test]
fn heading_renders_larger_than_body() {
let doc = TextDocument::new();
let op = doc
.set_markdown("# Big Heading\n\nNormal paragraph.")
.unwrap();
op.wait().unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
let frame = ts.render();
if frame.glyphs.len() >= 2 {
let mut ys_and_heights: Vec<(f32, f32)> = frame
.glyphs
.iter()
.map(|g| (g.screen[1], g.screen[3]))
.collect();
ys_and_heights.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let first_line_height = ys_and_heights.first().map(|g| g.1).unwrap_or(0.0);
let last_line_height = ys_and_heights.last().map(|g| g.1).unwrap_or(0.0);
assert!(
first_line_height > last_line_height * 1.2,
"heading glyph height ({}) should be >1.2x body glyph height ({})",
first_line_height,
last_line_height
);
}
}
#[test]
fn empty_document_renders_without_panic() {
let doc = TextDocument::new();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
let frame = ts.render();
assert!(frame.atlas_width > 0 || frame.glyphs.is_empty());
}
#[test]
fn content_height_grows_with_content() {
let doc = TextDocument::new();
doc.set_plain_text("One line").unwrap();
let flow1 = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow1);
let h1 = ts.content_height();
let doc2 = TextDocument::new();
doc2.set_plain_text("Line one.\n\nLine two.\n\nLine three.\n\nLine four.")
.unwrap();
let flow2 = doc2.snapshot_flow();
ts.layout_full(&flow2);
let h2 = ts.content_height();
assert!(
h2 > h1,
"more content should produce greater height: {} vs {}",
h1,
h2
);
}
#[test]
fn hit_test_below_blockquote_lands_on_block_after() {
let doc = TextDocument::new();
let op = doc
.set_markdown("Before\n\n> Quoted text\n\nAfter")
.unwrap();
op.wait().unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
ts.render();
let content_h = ts.content_height();
let result = ts.hit_test(10.0, content_h - 5.0);
assert!(
result.is_some(),
"hit test near bottom should return a result"
);
let hit = result.unwrap();
assert!(
hit.position >= "Before\n\n> Quoted text\n\n".len() - 5,
"hit test at bottom should be in the 'After' block, got position {}",
hit.position
);
}
#[test]
fn caret_rect_moves_through_blockquote() {
let doc = TextDocument::new();
let op = doc.set_markdown("AB\n\n> CD\n\nEF").unwrap();
op.wait().unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
ts.render();
let rect_before = ts.caret_rect(0);
let rect_quote = ts.caret_rect(4);
let rect_after = ts.caret_rect(8);
assert!(
rect_quote[1] > rect_before[1],
"caret in blockquote ({}) should be below caret before ({})",
rect_quote[1],
rect_before[1]
);
assert!(
rect_after[1] > rect_quote[1],
"caret after blockquote ({}) should be below caret in blockquote ({})",
rect_after[1],
rect_quote[1]
);
assert!(rect_before[3] > 0.0);
assert!(rect_quote[3] > 0.0);
assert!(rect_after[3] > 0.0);
}
#[test]
fn selection_spanning_blockquote_boundary() {
let doc = TextDocument::new();
let op = doc.set_markdown("AB\n\n> CD\n\nEF").unwrap();
op.wait().unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
ts.set_cursor(&text_typeset::CursorDisplay {
position: 0,
anchor: 10,
visible: true,
selected_cells: vec![],
});
let frame = ts.render();
let selections: Vec<_> = frame
.decorations
.iter()
.filter(|d| d.kind == text_typeset::DecorationKind::Selection)
.collect();
assert!(
selections.len() >= 2,
"cross-blockquote selection should produce multiple rects, got {}",
selections.len()
);
}
#[test]
fn ensure_caret_visible_inside_blockquote() {
let doc = TextDocument::new();
let mut md = String::new();
for i in 0..20 {
md.push_str(&format!("Paragraph {}.\n\n", i));
}
md.push_str("> Deep blockquote text\n\n");
md.push_str("Final paragraph.");
let op = doc.set_markdown(&md).unwrap();
op.wait().unwrap();
let flow = doc.snapshot_flow();
let mut ts = Typesetter::new();
let face = ts.register_font(NOTO_SANS);
ts.set_default_font(face, 16.0);
ts.set_viewport(800.0, 50.0);
ts.layout_full(&flow);
ts.render();
let h = ts.content_height();
let hit = ts.hit_test(10.0, h - 5.0);
assert!(hit.is_some(), "should find content at bottom");
let deep_pos = hit.unwrap().position;
ts.set_cursor(&text_typeset::CursorDisplay {
position: deep_pos,
anchor: deep_pos,
visible: true,
selected_cells: vec![],
});
let result = ts.ensure_caret_visible();
assert!(
result.is_some(),
"should need to scroll to reveal caret near bottom of long document"
);
assert!(result.unwrap() > 0.0, "scroll offset should be positive");
}
#[test]
fn caret_rect_after_edit_inside_blockquote() {
let doc = TextDocument::new();
let op = doc.set_markdown("Before\n\n> Short\n\nAfter").unwrap();
op.wait().unwrap();
let flow = doc.snapshot_flow();
let mut ts = Typesetter::new();
let face = ts.register_font(NOTO_SANS);
ts.set_default_font(face, 16.0);
ts.set_viewport(200.0, 600.0);
ts.layout_full(&flow);
ts.render();
let h = ts.content_height();
let after_hit = ts.hit_test(10.0, h - 5.0);
assert!(after_hit.is_some(), "should find the After block");
let after_pos = after_hit.unwrap().position;
let rect_before_edit = ts.caret_rect(after_pos);
assert_caret_is_real(rect_before_edit, "After block before edit");
assert!(
rect_before_edit[3] > 0.0,
"caret for 'After' should have valid height before edit"
);
let doc2 = TextDocument::new();
let op = doc2
.set_markdown("Before\n\n> This is a much longer blockquote that takes more vertical space than the short one did before\n\nAfter")
.unwrap();
op.wait().unwrap();
let flow2 = doc2.snapshot_flow();
ts.layout_full(&flow2);
ts.render();
let h2 = ts.content_height();
let after_hit2 = ts.hit_test(10.0, h2 - 5.0);
assert!(
after_hit2.is_some(),
"should find After block in new layout"
);
let after_pos2 = after_hit2.unwrap().position;
let rect_after_edit = ts.caret_rect(after_pos2);
assert_caret_is_real(rect_after_edit, "After block after edit");
assert!(
rect_after_edit[3] > 0.0,
"caret after edit should have valid height"
);
assert!(
rect_after_edit[1] > rect_before_edit[1],
"caret for 'After' should move down after blockquote grows: {} -> {}",
rect_before_edit[1],
rect_after_edit[1]
);
}
#[test]
fn scroll_to_position_inside_blockquote() {
let doc = TextDocument::new();
let mut md = String::new();
for i in 0..15 {
md.push_str(&format!("Paragraph {}.\n\n", i));
}
md.push_str("> Blockquote deep in document\n\n");
let op = doc.set_markdown(&md).unwrap();
op.wait().unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
ts.render();
let h = ts.content_height();
let hit = ts.hit_test(10.0, h - 5.0);
assert!(hit.is_some());
let deep_pos = hit.unwrap().position;
let offset = ts.scroll_to_position(deep_pos);
assert!(
offset > 0.0,
"scroll_to_position inside blockquote should produce positive offset"
);
}
#[test]
fn caret_rect_inside_nested_blockquote() {
let doc = TextDocument::new();
let op = doc
.set_markdown("Before\n\n> Outer\n>\n> > Inner nested\n\nAfter\n")
.unwrap();
op.wait().unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
ts.render();
let h = ts.content_height();
let mut inner_pos = None;
for y_probe in (0..(h as i32)).step_by(2) {
if let Some(hit) = ts.hit_test(80.0, y_probe as f32) {
let rect = ts.caret_rect(hit.position);
if rect[0] > 50.0 && rect[3] > 0.0 {
inner_pos = Some(hit.position);
break;
}
}
}
let pos = inner_pos.expect("should find a position inside nested blockquote");
let rect = ts.caret_rect(pos);
assert!(
rect[3] > 0.0,
"caret inside nested blockquote should have valid height"
);
assert!(
rect[1] > 0.0,
"caret y inside nested blockquote should be positive"
);
}
#[test]
fn hit_test_inside_nested_blockquote_returns_inner_block() {
let doc = TextDocument::new();
let op = doc
.set_markdown("Before\n\n> Outer\n>\n> > Inner nested\n\nAfter\n")
.unwrap();
op.wait().unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
ts.render();
let h = ts.content_height();
let mut block_ids = std::collections::HashSet::new();
for y_probe in (0..(h as i32)).step_by(3) {
if let Some(hit) = ts.hit_test(80.0, y_probe as f32) {
block_ids.insert(hit.block_id);
}
}
assert!(
block_ids.len() >= 4,
"should find at least 4 distinct blocks (got {}): {:?}",
block_ids.len(),
block_ids
);
}
#[test]
fn selection_spanning_nested_blockquote() {
let doc = TextDocument::new();
let op = doc
.set_markdown("Before\n\n> Outer quote\n>\n> > Inner nested\n\nAfter\n")
.unwrap();
op.wait().unwrap();
let flow = doc.snapshot_flow();
let mut ts = make_typesetter();
ts.layout_full(&flow);
ts.render();
let h = ts.content_height();
let mut positions = Vec::new();
for y_probe in (0..(h as i32)).step_by(3) {
if let Some(hit) = ts.hit_test(80.0, y_probe as f32)
&& positions
.last()
.is_none_or(|&(_, last_bid)| last_bid != hit.block_id)
{
positions.push((hit.position, hit.block_id));
}
}
assert!(
positions.len() >= 3,
"should find at least 3 distinct block positions (got {})",
positions.len()
);
let start = positions[0].0;
let end = positions.last().unwrap().0 + 3;
ts.set_cursor(&text_typeset::CursorDisplay {
position: start,
anchor: end,
visible: true,
selected_cells: vec![],
});
let frame = ts.render();
let sel_rects: Vec<_> = frame
.decorations
.iter()
.filter(|d| d.kind == text_typeset::DecorationKind::Selection)
.collect();
assert!(
sel_rects.len() >= 2,
"selection spanning nested blockquote should produce multiple rects (got {})",
sel_rects.len()
);
}
#[test]
fn caret_rect_after_edit_inside_nested_blockquote() {
let doc = TextDocument::new();
let op = doc
.set_markdown("Before\n\n> Outer\n>\n> > Short\n\nAfter\n")
.unwrap();
op.wait().unwrap();
let mut ts = make_typesetter();
ts.set_viewport(200.0, 600.0);
let flow = doc.snapshot_flow();
ts.layout_full(&flow);
ts.render();
let h = ts.content_height();
let after_hit = ts.hit_test(10.0, h - 5.0).expect("should find After block");
let rect_before = ts.caret_rect(after_hit.position);
assert_caret_is_real(rect_before, "After block before nested bq edit");
let long_text = "Before\n\n> Outer\n>\n> > This is a much longer piece of text that should wrap and push the After block down significantly\n\nAfter\n";
let op = doc.set_markdown(long_text).unwrap();
op.wait().unwrap();
let flow2 = doc.snapshot_flow();
ts.layout_full(&flow2);
ts.render();
let h2 = ts.content_height();
let after_hit2 = ts
.hit_test(10.0, h2 - 5.0)
.expect("should find After block after edit");
let rect_after = ts.caret_rect(after_hit2.position);
assert_caret_is_real(rect_after, "After block after nested bq edit");
assert!(
rect_after[1] > rect_before[1],
"caret for After should move down after nested blockquote grows: {} -> {}",
rect_before[1],
rect_after[1]
);
}
#[test]
fn incremental_relayout_blockquote_shows_new_glyph() {
let doc = TextDocument::new();
let op = doc.set_markdown("Before\n\n> Hello\n\nAfter\n").unwrap();
op.wait().unwrap();
let mut ts = make_typesetter();
let flow = doc.snapshot_flow();
ts.layout_full(&flow);
let frame1 = ts.render();
let glyph_count_before = frame1.glyphs.len();
let h = ts.content_height();
let mut bq_pos = None;
for y in (0..(h as i32)).step_by(2) {
if let Some(hit) = ts.hit_test(60.0, y as f32) {
let rect = ts.caret_rect(hit.position);
if rect[0] > 20.0 {
bq_pos = Some(hit.position);
break;
}
}
}
let pos = bq_pos.expect("should find a position inside the blockquote");
let cursor = doc.cursor();
cursor.set_position(pos, text_document::MoveMode::MoveAnchor);
cursor.insert_text("X").unwrap();
let block_snapshot = doc
.snapshot_block_at_position(pos)
.expect("should find block at cursor position");
let block_params = text_typeset::bridge::convert_block(&block_snapshot);
ts.relayout_block(&block_params);
let frame2 = ts.render();
let glyph_count_after = frame2.glyphs.len();
assert!(
glyph_count_after > glyph_count_before,
"after inserting a character in blockquote via relayout_block, \
glyph count should increase: {} -> {}",
glyph_count_before,
glyph_count_after
);
}
#[test]
fn render_block_only_for_frame_block_shows_new_glyph() {
let doc = TextDocument::new();
let op = doc.set_markdown("Before\n\n> Hello\n\nAfter\n").unwrap();
op.wait().unwrap();
let mut ts = make_typesetter();
let flow = doc.snapshot_flow();
ts.layout_full(&flow);
let frame1 = ts.render();
let glyph_count_before = frame1.glyphs.len();
let h = ts.content_height();
let mut bq_pos = None;
let mut bq_block_id = None;
for y in (0..(h as i32)).step_by(2) {
if let Some(hit) = ts.hit_test(60.0, y as f32) {
if ts.block_visual_info(hit.block_id).is_none() {
bq_pos = Some(hit.position);
bq_block_id = Some(hit.block_id);
break;
}
}
}
let pos = bq_pos.expect("should find a position inside the blockquote frame");
let found_block_id = bq_block_id.unwrap();
let cursor = doc.cursor();
cursor.set_position(pos, text_document::MoveMode::MoveAnchor);
cursor.insert_text("X").unwrap();
let block_snapshot = doc
.snapshot_block_at_position(pos)
.expect("should find block at cursor position");
let block_params = text_typeset::bridge::convert_block(&block_snapshot);
let block_id = block_params.block_id;
assert_eq!(
block_id, found_block_id,
"block_id from snapshot should match the one found by hit_test"
);
ts.relayout_block(&block_params);
let frame2 = ts.render_block_only(block_id);
let glyph_count_after = frame2.glyphs.len();
assert!(
glyph_count_after > glyph_count_before,
"render_block_only for a frame block should show the new character: {} -> {}",
glyph_count_before,
glyph_count_after
);
}
#[test]
fn cursor_reaches_all_positions_in_frame_block_after_insert() {
let doc = TextDocument::new();
let op = doc.set_markdown("Before\n\n> Hello\n\nAfter\n").unwrap();
op.wait().unwrap();
let mut ts = make_typesetter();
let flow = doc.snapshot_flow();
ts.layout_full(&flow);
ts.render();
let h = ts.content_height();
let mut bq_pos = None;
let mut bq_block_id = None;
for y in (0..(h as i32)).step_by(2) {
if let Some(hit) = ts.hit_test(60.0, y as f32)
&& ts.block_visual_info(hit.block_id).is_none()
{
bq_pos = Some(hit.position);
bq_block_id = Some(hit.block_id);
break;
}
}
let pos = bq_pos.expect("should find a position inside the blockquote frame");
let block_id = bq_block_id.unwrap();
let rect_before_end = ts.caret_rect(pos + 5);
assert!(
rect_before_end[2] > 0.0,
"caret should be visible at end of 'Hello'"
);
let cursor = doc.cursor();
cursor.set_position(pos, text_document::MoveMode::MoveAnchor);
cursor.insert_text("X").unwrap();
let block_snapshot = doc
.snapshot_block_at_position(pos)
.expect("should find block");
let block_params = text_typeset::bridge::convert_block(&block_snapshot);
assert_eq!(block_params.block_id, block_id);
ts.relayout_block(&block_params);
ts.render();
let block_position = block_params.position;
let text_len = block_params.text.chars().count();
for offset in 0..=text_len {
let abs_pos = block_position + offset;
let rect = ts.caret_rect(abs_pos);
assert!(
rect[2] > 0.0 && rect[3] > 0.0,
"caret_rect at offset {} (abs pos {}) should be valid, got {:?}",
offset,
abs_pos,
rect
);
}
let cursor2 = doc.cursor();
for offset in 0..=text_len {
let abs_pos = block_position + offset;
cursor2.set_position(abs_pos, text_document::MoveMode::MoveAnchor);
let actual = cursor2.position();
assert_eq!(
actual, abs_pos,
"text-document cursor should reach position {} but got {}",
abs_pos, actual
);
}
cursor2.set_position(block_position, text_document::MoveMode::MoveAnchor);
for offset in 1..=text_len {
cursor2.move_position(
text_document::MoveOperation::Right,
text_document::MoveMode::MoveAnchor,
1,
);
let expected = block_position + offset;
let actual = cursor2.position();
assert_eq!(
actual, expected,
"MoveRight step {} should reach position {} but cursor is at {}",
offset, expected, actual
);
}
let doc_len = doc.character_count();
cursor2.set_position(doc_len, text_document::MoveMode::MoveAnchor);
assert_eq!(
cursor2.position(),
doc_len,
"cursor should reach document end after insert"
);
cursor2.set_position(doc_len - 1, text_document::MoveMode::MoveAnchor);
assert_eq!(
cursor2.position(),
doc_len - 1,
"cursor should reach document length - 1 after insert"
);
let rect = ts.caret_rect(doc_len - 1);
assert!(
rect[3] > 0.0,
"caret at doc_len-1 ({}) should be valid, got {:?}",
doc_len - 1,
rect
);
}
#[test]
fn frame_block_wrapping_after_insert_grows_frame() {
let doc = TextDocument::new();
let op = doc.set_markdown("Before\n\n> Hello\n\nAfter\n").unwrap();
op.wait().unwrap();
let mut ts = make_typesetter();
ts.set_viewport(200.0, 600.0); let flow = doc.snapshot_flow();
ts.layout_full(&flow);
ts.render();
let h = ts.content_height();
let mut bq_pos = None;
let mut bq_block_id = None;
for y in (0..(h as i32)).step_by(2) {
if let Some(hit) = ts.hit_test(60.0, y as f32)
&& ts.block_visual_info(hit.block_id).is_none()
{
bq_pos = Some(hit.position);
bq_block_id = Some(hit.block_id);
break;
}
}
let pos = bq_pos.expect("should find a position inside the blockquote frame");
let block_id = bq_block_id.unwrap();
let height_before = ts.content_height();
let cursor = doc.cursor();
cursor.set_position(pos, text_document::MoveMode::MoveAnchor);
cursor
.insert_text("ABCDEFGHIJKLMNOPQRSTUVWXYZ1234")
.unwrap();
let block_snapshot = doc
.snapshot_block_at_position(pos)
.expect("should find block");
let block_params = text_typeset::bridge::convert_block(&block_snapshot);
assert_eq!(block_params.block_id, block_id);
ts.relayout_block(&block_params);
ts.render();
let height_after = ts.content_height();
assert!(
height_after > height_before,
"content height should increase after inserting enough text to wrap: {} -> {}",
height_before,
height_after
);
let text_len = block_params.text.chars().count();
let block_position = block_params.position;
for offset in 0..=text_len {
let abs_pos = block_position + offset;
let rect = ts.caret_rect(abs_pos);
assert!(
rect[3] > 0.0,
"caret at offset {} (abs pos {}) should be valid after wrapping, got {:?}",
offset,
abs_pos,
rect
);
}
}
#[test]
fn frame_block_relayout_preserves_line_structure() {
let doc = TextDocument::new();
let long_text = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
let md = format!("Before\n\n> {}\n\nAfter\n", long_text);
let op = doc.set_markdown(&md).unwrap();
op.wait().unwrap();
let mut ts = make_typesetter();
ts.set_viewport(200.0, 600.0);
let flow = doc.snapshot_flow();
ts.layout_full(&flow);
ts.render();
let h = ts.content_height();
let mut bq_block_id = None;
for y in (0..(h as i32)).step_by(2) {
if let Some(hit) = ts.hit_test(60.0, y as f32)
&& ts.block_visual_info(hit.block_id).is_none()
{
bq_block_id = Some(hit.block_id);
break;
}
}
let _block_id = bq_block_id.expect("should find blockquote block");
let block_snapshot = doc
.snapshot_block_at_position(7) .expect("should find block");
let block_params = text_typeset::bridge::convert_block(&block_snapshot);
let text_len_chars = block_params.text.chars().count();
let caret_before_start = ts.caret_rect(block_params.position);
let caret_before_end = ts.caret_rect(block_params.position + text_len_chars);
ts.relayout_block(&block_params);
ts.render();
let caret_after_start = ts.caret_rect(block_params.position);
let caret_after_end = ts.caret_rect(block_params.position + text_len_chars);
assert!(
(caret_before_start[0] - caret_after_start[0]).abs() < 1.0
&& (caret_before_start[1] - caret_after_start[1]).abs() < 1.0,
"start caret should not move: {:?} -> {:?}",
caret_before_start,
caret_after_start
);
assert!(
(caret_before_end[0] - caret_after_end[0]).abs() < 1.0
&& (caret_before_end[1] - caret_after_end[1]).abs() < 1.0,
"end caret should not move after relayout with same text: {:?} -> {:?}",
caret_before_end,
caret_after_end
);
}
#[test]
fn render_block_only_frame_grows_on_wrap() {
let doc = TextDocument::new();
let op = doc.set_markdown("X\n\n> Hello\n\nY\n").unwrap();
op.wait().unwrap();
let mut ts = make_typesetter();
ts.set_viewport(200.0, 600.0);
let flow = doc.snapshot_flow();
ts.layout_full(&flow);
ts.render();
let h = ts.content_height();
let mut bq_pos = None;
for y in (0..(h as i32)).step_by(2) {
if let Some(hit) = ts.hit_test(60.0, y as f32)
&& ts.block_visual_info(hit.block_id).is_none()
{
bq_pos = Some(hit.position);
break;
}
}
let pos = bq_pos.expect("should find frame block");
let height_before = ts.content_height();
let glyphs_before = ts.render().glyphs.len();
let cursor = doc.cursor();
for i in 0..30 {
let insert_pos = pos + i;
cursor.set_position(insert_pos, text_document::MoveMode::MoveAnchor);
cursor.insert_text("W").unwrap();
let snap = doc
.snapshot_block_at_position(insert_pos)
.expect("block snap");
let params = text_typeset::bridge::convert_block(&snap);
ts.relayout_block(¶ms);
ts.render_block_only(params.block_id);
}
let height_after = ts.content_height();
let glyphs_final = ts.render().glyphs.len();
assert!(
height_after > height_before,
"frame should have grown: {} -> {}",
height_before,
height_after
);
assert!(
glyphs_final > glyphs_before,
"should have more glyphs: {} -> {}",
glyphs_before,
glyphs_final
);
}
#[test]
fn caret_rect_stays_in_frame_after_insert() {
let doc = TextDocument::new();
let op = doc.set_markdown("X\n\n> Hello\n\nY\n").unwrap();
op.wait().unwrap();
let mut ts = make_typesetter();
let flow = doc.snapshot_flow();
ts.layout_full(&flow);
ts.render();
let h = ts.content_height();
let mut frame_block_pos = None;
for y in (0..(h as i32)).step_by(2) {
if let Some(hit) = ts.hit_test(60.0, y as f32)
&& ts.block_visual_info(hit.block_id).is_none()
{
frame_block_pos = Some((hit.position, hit.block_id));
break;
}
}
let (bq_pos, _) = frame_block_pos.expect("should find blockquote block");
let mut y_block_pos = None;
for y in (0..(h as i32)).step_by(2) {
if let Some(hit) = ts.hit_test(60.0, y as f32) {
if hit.position > bq_pos && ts.block_visual_info(hit.block_id).is_some() {
y_block_pos = Some(hit.position);
break;
}
}
}
let y_pos = y_block_pos.expect("should find Y block");
let y_block_caret_before = ts.caret_rect(y_pos);
let cursor = doc.cursor();
cursor.set_position(bq_pos, text_document::MoveMode::MoveAnchor);
cursor.insert_text("Z").unwrap();
let snap = doc
.snapshot_block_at_position(bq_pos)
.expect("should find frame block");
assert!(
snap.text.contains("Z"),
"frame block text should contain 'Z', got {:?}",
snap.text
);
let params = text_typeset::bridge::convert_block(&snap);
ts.relayout_block(¶ms);
ts.render();
let frame_text_len = params.text.chars().count();
let end_of_frame = params.position + frame_text_len;
let frame_end_caret = ts.caret_rect(end_of_frame);
assert!(
frame_end_caret[1] < y_block_caret_before[1],
"caret at end of frame block (pos {}) should be above 'Y' block: \
frame_caret_y={} should be < y_block_y={}",
end_of_frame,
frame_end_caret[1],
y_block_caret_before[1]
);
}
#[test]
fn repeated_enter_at_end_of_frame_stays_inside() {
let doc = TextDocument::new();
let op = doc.set_markdown("Before\n\n> Hello\n\nAfter\n").unwrap();
op.wait().unwrap();
let mut ts = make_typesetter();
let flow = doc.snapshot_flow();
ts.layout_full(&flow);
ts.render();
let h = ts.content_height();
let mut bq_pos = None;
for y in (0..(h as i32)).step_by(2) {
if let Some(hit) = ts.hit_test(60.0, y as f32)
&& ts.block_visual_info(hit.block_id).is_none()
{
bq_pos = Some(hit.position);
break;
}
}
let bq_start = bq_pos.expect("should find position inside blockquote frame");
let bq_snap = doc
.snapshot_block_at_position(bq_start)
.expect("should find blockquote block");
let bq_text_len = bq_snap.text.chars().count();
let end_pos = bq_snap.position + bq_text_len;
let cursor = doc.cursor();
cursor.set_position(end_pos, text_document::MoveMode::MoveAnchor);
fn assert_cursor_in_frame(ts: &mut Typesetter, cursor_pos: usize, label: &str) {
let rect = ts.caret_rect(cursor_pos);
assert_caret_is_real(rect, label);
let hx = rect[0].max(1.0);
let hy = rect[1] + rect[3] * 0.5;
if let Some(hit) = ts.hit_test(hx, hy) {
assert!(
ts.block_visual_info(hit.block_id).is_none(),
"{}: cursor at pos {} landed on top-level block {} instead of frame block. \
caret_rect={:?}",
label,
cursor_pos,
hit.block_id,
rect
);
}
}
assert_cursor_in_frame(&mut ts, end_pos, "initial: end of Hello");
let chars = ['a', 'b', 'c'];
for (i, ch) in chars.iter().enumerate() {
cursor.insert_block().unwrap();
let flow = doc.snapshot_flow();
ts.layout_full(&flow);
ts.render();
let pos_after_enter = cursor.position();
assert_cursor_in_frame(
&mut ts,
pos_after_enter,
&format!("after Enter #{} (before '{}')", i + 1, ch),
);
cursor.insert_text(&ch.to_string()).unwrap();
let flow = doc.snapshot_flow();
ts.layout_full(&flow);
ts.render();
let pos_after_char = cursor.position();
assert_cursor_in_frame(
&mut ts,
pos_after_char,
&format!("after inserting '{}'", ch),
);
}
cursor.insert_block().unwrap();
let flow = doc.snapshot_flow();
ts.layout_full(&flow);
ts.render();
let pos_final = cursor.position();
let flow_final = doc.snapshot_flow();
let cursor_block_in_frame = flow_final.elements.iter().any(|elem| {
if let text_document::FlowElementSnapshot::Frame(f) = elem {
f.elements.iter().any(|inner| {
if let text_document::FlowElementSnapshot::Block(b) = inner {
b.position <= pos_final && pos_final <= b.position + b.text.chars().count()
} else {
false
}
})
} else {
false
}
});
assert!(
cursor_block_in_frame,
"BUG (text-document): after 4th Enter at end of blockquote frame, \
cursor position {} is not inside the frame in the flow snapshot. \
insert_block() at the end of the last block in a frame should create \
a new block inside the frame, not split the next top-level block.",
pos_final
);
}
#[test]
fn markdown_table_cells_render_at_distinct_positions() {
let doc = TextDocument::new();
let md = "\
| Column A | Column B |
|----------|----------|
| Cell one | Cell two with **bold** |
| Cell three | Cell four with `code` and *italic* |
Final paragraph after all elements.";
let op = doc.set_markdown(md).unwrap();
op.wait().unwrap();
let flow = doc.snapshot_flow();
let table_count = flow
.elements
.iter()
.filter(|e| matches!(e, text_document::FlowElementSnapshot::Table(_)))
.count();
assert!(
table_count >= 1,
"markdown table should produce a Table flow element, got {} tables out of {} elements",
table_count,
flow.elements.len()
);
let mut ts = make_typesetter();
ts.layout_full(&flow);
let frame = ts.render();
let table_borders = frame
.decorations
.iter()
.filter(|d| d.kind == text_typeset::DecorationKind::TableBorder)
.count();
assert!(
table_borders > 0,
"markdown table should produce table border decorations"
);
let mut ys: Vec<f32> = frame.glyphs.iter().map(|g| g.screen[1]).collect();
ys.sort_by(|a, b| a.partial_cmp(b).unwrap());
ys.dedup_by(|a, b| (*a - *b).abs() < 1.0);
assert!(
ys.len() >= 4,
"table rows + paragraph should produce 4+ distinct y lines, got {}",
ys.len()
);
}
#[test]
fn typing_in_all_table_cells_keeps_caret_inside_cell() {
let doc = TextDocument::new();
let md = "\
| Alpha | Beta |
|-------|------|
| Gamma | Delta |";
let op = doc.set_markdown(md).unwrap();
op.wait().unwrap();
let mut ts = Typesetter::new();
let face = ts.register_font(NOTO_SANS);
ts.set_default_font(face, 16.0);
ts.set_viewport(200.0, 600.0);
let flow = doc.snapshot_flow();
ts.layout_full(&flow);
let mut cell_block_ids: Vec<usize> = Vec::new();
for element in &flow.elements {
if let text_document::FlowElementSnapshot::Table(table) = element {
for cell in &table.cells {
for block in &cell.blocks {
cell_block_ids.push(block.block_id);
}
}
}
}
assert_eq!(
cell_block_ids.len(),
4,
"2x2 table should have 4 cell blocks, got {}",
cell_block_ids.len()
);
let col1_start_x = ts.caret_rect(find_block_text_start(&flow, cell_block_ids[1]))[0];
let cursor = doc.cursor();
let chars_40 = "a b c d e f g h i j k l m n o p q r s t \
u v w x y z A B C D E F G H I J K L M N";
let chars_10 = "a b c d e f g h i j";
for (cell_idx, &block_id) in cell_block_ids.iter().enumerate() {
let flow = doc.snapshot_flow();
ts.layout_full(&flow);
let end_pos = find_block_text_end(&flow, block_id);
cursor.set_position(end_pos, text_document::MoveMode::MoveAnchor);
let cell_left_x = ts.caret_rect(find_block_text_start(&flow, block_id))[0];
let is_col0 = cell_idx == 0 || cell_idx == 2;
let cell_right_x = if is_col0 { col1_start_x } else { 200.0 };
let text = if cell_idx == 0 { chars_40 } else { chars_10 };
for (char_idx, ch) in std::iter::once(' ').chain(text.chars()).enumerate() {
cursor.insert_text(&ch.to_string()).unwrap();
let flow = doc.snapshot_flow();
ts.layout_full(&flow);
let pos = cursor.position();
let rect = ts.caret_rect(pos);
let is_sentinel = rect[0] == 0.0 && rect[2] == 2.0 && rect[3] == 16.0;
assert!(
!is_sentinel,
"cell[{}] char {} '{}': caret fell back to sentinel at pos {}. caret={:?}",
cell_idx, char_idx, ch, pos, rect
);
assert_caret_is_real(
rect,
&format!("cell[{}] char {} '{}'", cell_idx, char_idx, ch),
);
assert!(
rect[0] >= cell_left_x - 2.0,
"cell[{}] char {} '{}': caret x ({}) jumped left of cell ({}). caret={:?}",
cell_idx,
char_idx,
ch,
rect[0],
cell_left_x,
rect
);
assert!(
rect[0] < cell_right_x,
"cell[{}] char {} '{}': caret x ({}) overflowed past cell right edge ({}). \
caret={:?}",
cell_idx,
char_idx,
ch,
rect[0],
cell_right_x,
rect
);
let row_top_y = ts.caret_rect(find_block_text_start(&flow, block_id))[1];
assert!(
rect[1] >= row_top_y - 1.0,
"cell[{}] char {} '{}': caret y ({}) is above row top ({}). caret={:?}",
cell_idx,
char_idx,
ch,
rect[1],
row_top_y,
rect
);
let hx = rect[0].max(1.0);
let hy = rect[1] + rect[3] * 0.5;
if let Some(hit) = ts.hit_test(hx, hy) {
assert!(
ts.is_block_in_table(hit.block_id),
"cell[{}] char {} '{}': caret escaped the table! \
hit block {} is not in table. caret={:?}",
cell_idx,
char_idx,
ch,
hit.block_id,
rect
);
}
}
}
fn find_block_text_start(flow: &text_document::FlowSnapshot, block_id: usize) -> usize {
for element in &flow.elements {
if let text_document::FlowElementSnapshot::Table(table) = element {
for cell in &table.cells {
for block in &cell.blocks {
if block.block_id == block_id {
return block.position;
}
}
}
}
}
panic!("block_id {} not found in flow snapshot", block_id);
}
fn find_block_text_end(flow: &text_document::FlowSnapshot, block_id: usize) -> usize {
for element in &flow.elements {
if let text_document::FlowElementSnapshot::Table(table) = element {
for cell in &table.cells {
for block in &cell.blocks {
if block.block_id == block_id {
return block.position + block.text.len();
}
}
}
}
}
panic!("block_id {} not found in flow snapshot", block_id);
}
}