mod helpers;
use helpers::{
NOTO_SANS, Typesetter, assert_caret_is_real, make_block, make_block_at, make_cell_at,
make_typesetter,
};
use text_typeset::layout::block::{BlockLayoutParams, FragmentParams};
use text_typeset::layout::frame::{FrameBorderStyle, FrameLayoutParams, FramePosition};
use text_typeset::layout::paragraph::Alignment;
use text_typeset::layout::table::TableLayoutParams;
use text_typeset::{DecorationKind, HitRegion, UnderlineStyle, VerticalAlignment};
#[test]
fn hit_test_on_text_returns_some() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "Hello world")]);
ts.render();
let result = ts.hit_test(40.0, 10.0);
assert!(result.is_some(), "hit test on text should return Some");
}
#[test]
fn hit_test_returns_correct_block_id() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "First"), make_block(2, "Second")]);
let r1 = ts.hit_test(10.0, 5.0);
assert!(r1.is_some());
assert_eq!(r1.unwrap().block_id, 1);
let height = ts.content_height();
let r2 = ts.hit_test(10.0, height - 5.0);
assert!(r2.is_some());
assert_eq!(r2.unwrap().block_id, 2);
}
#[test]
fn hit_test_position_increases_left_to_right() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "ABCDEFGHIJ")]);
let r_left = ts.hit_test(5.0, 10.0);
let r_mid = ts.hit_test(60.0, 10.0);
let r_right = ts.hit_test(120.0, 10.0);
assert!(r_left.is_some());
assert!(r_mid.is_some());
assert!(r_right.is_some());
let pos_left = r_left.unwrap().position;
let pos_mid = r_mid.unwrap().position;
let pos_right = r_right.unwrap().position;
assert!(
pos_left <= pos_mid && pos_mid <= pos_right,
"positions should increase left-to-right: {} <= {} <= {}",
pos_left,
pos_mid,
pos_right
);
}
#[test]
fn hit_test_past_line_end() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "Hi")]);
let result = ts.hit_test(700.0, 10.0);
assert!(result.is_some());
let result = result.unwrap();
assert!(
matches!(result.region, HitRegion::PastLineEnd),
"far right should be PastLineEnd, got {:?}",
std::mem::discriminant(&result.region)
);
}
#[test]
fn hit_test_left_margin_region() {
let mut ts = make_typesetter();
let mut block = make_block(1, "Hello");
block.left_margin = 50.0;
ts.layout_blocks(vec![block]);
let result = ts.hit_test(10.0, 10.0);
assert!(result.is_some());
assert!(
matches!(result.unwrap().region, HitRegion::LeftMargin),
"click in left margin should return LeftMargin region"
);
}
#[test]
fn caret_rect_at_position_zero() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "Hello")]);
let rect = ts.caret_rect(0);
assert_caret_is_real(rect, "position 0");
assert!(
rect[0] < 10.0,
"caret x at pos 0 should be near 0, got {}",
rect[0]
);
assert!(rect[2] > 0.0, "caret should have positive width");
assert!(rect[3] > 0.0, "caret should have positive height");
}
#[test]
fn caret_rect_moves_right_with_position() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "ABCDEF")]);
let rect0 = ts.caret_rect(0);
let rect3 = ts.caret_rect(3);
let rect6 = ts.caret_rect(6);
assert_caret_is_real(rect0, "position 0");
assert_caret_is_real(rect3, "position 3");
assert_caret_is_real(rect6, "position 6");
assert!(
rect3[0] > rect0[0],
"caret at pos 3 ({}) should be right of pos 0 ({})",
rect3[0],
rect0[0]
);
assert!(
rect6[0] > rect3[0],
"caret at pos 6 ({}) should be right of pos 3 ({})",
rect6[0],
rect3[0]
);
}
#[test]
fn caret_rect_height_matches_line_height() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "Hello")]);
let rect = ts.caret_rect(0);
assert!(
rect[3] > 10.0 && rect[3] < 40.0,
"caret height {} should be reasonable for 16px font",
rect[3]
);
}
#[test]
fn hit_test_with_scroll_offset() {
let mut ts = Typesetter::new();
let face = ts.register_font(NOTO_SANS);
ts.set_default_font(face, 16.0);
ts.set_viewport(800.0, 600.0);
let blocks: Vec<_> = (0..10)
.map(|i| {
let mut b = make_block(i, &format!("Paragraph {i} text."));
b.position = i * 20; b
})
.collect();
ts.layout_blocks(blocks);
let r0 = ts.hit_test(10.0, 5.0);
assert!(r0.is_some());
assert_eq!(r0.unwrap().block_id, 0);
ts.set_scroll_offset(100.0);
let r_scrolled = ts.hit_test(10.0, 5.0);
assert!(r_scrolled.is_some());
assert_ne!(
r_scrolled.unwrap().block_id,
0,
"scrolled hit test should be in a different block"
);
}
#[test]
fn cursor_produces_caret_decoration() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "Hello world")]);
ts.set_cursor(&text_typeset::CursorDisplay {
position: 5,
anchor: 5,
visible: true,
selected_cells: vec![],
});
let frame = ts.render();
let carets: Vec<_> = frame
.decorations
.iter()
.filter(|d| d.kind == text_typeset::DecorationKind::Cursor)
.collect();
assert_eq!(carets.len(), 1, "should have exactly one cursor caret");
assert!(carets[0].rect[2] > 0.0, "caret should have positive width");
assert!(carets[0].rect[3] > 0.0, "caret should have positive height");
}
#[test]
fn invisible_cursor_produces_no_caret() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "Hello")]);
ts.set_cursor(&text_typeset::CursorDisplay {
position: 3,
anchor: 3,
visible: false, selected_cells: vec![],
});
let frame = ts.render();
let carets: Vec<_> = frame
.decorations
.iter()
.filter(|d| d.kind == text_typeset::DecorationKind::Cursor)
.collect();
assert!(
carets.is_empty(),
"invisible cursor should produce no caret"
);
}
#[test]
fn selection_produces_highlight_rects() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "Hello world")]);
ts.set_cursor(&text_typeset::CursorDisplay {
position: 0,
anchor: 5, 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"
);
for sel in &selections {
assert!(sel.rect[2] > 0.0, "selection width should be positive");
assert!(sel.rect[3] > 0.0, "selection height should be positive");
}
}
#[test]
fn no_selection_when_anchor_equals_position() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "Hello")]);
ts.set_cursor(&text_typeset::CursorDisplay {
position: 3,
anchor: 3, 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(),
"no selection when anchor == position"
);
}
#[test]
fn multi_line_selection_extends_to_viewport_width() {
let mut ts = make_typesetter(); ts.layout_blocks(vec![
make_block(1, "Short line."),
make_block(2, "Another line."),
]);
ts.set_cursor(&text_typeset::CursorDisplay {
position: 0,
anchor: 24, 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,
"multi-block selection should produce at least 2 selection rects, got {}",
selections.len()
);
let first_sel = &selections[0];
let sel_right_edge = first_sel.rect[0] + first_sel.rect[2];
assert!(
sel_right_edge > 700.0,
"first line selection should extend to near viewport width (800), got right edge at {}",
sel_right_edge
);
}
#[test]
fn single_line_selection_does_not_extend() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "Hello world, this is text.")]);
ts.set_cursor(&text_typeset::CursorDisplay {
position: 6,
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());
let sel = &selections[0];
let sel_width = sel.rect[2];
assert!(
sel_width < 200.0,
"single-line selection should NOT extend to viewport width, got width {}",
sel_width
);
}
#[test]
fn multiple_cursors() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "ABCDEFGHIJ")]);
ts.set_cursors(&[
text_typeset::CursorDisplay {
position: 2,
anchor: 2,
visible: true,
selected_cells: vec![],
},
text_typeset::CursorDisplay {
position: 7,
anchor: 7,
visible: true,
selected_cells: vec![],
},
]);
let frame = ts.render();
let carets: Vec<_> = frame
.decorations
.iter()
.filter(|d| d.kind == text_typeset::DecorationKind::Cursor)
.collect();
assert_eq!(carets.len(), 2, "should have two cursor carets");
assert!(
carets[1].rect[0] > carets[0].rect[0],
"second caret x ({}) should be > first caret x ({})",
carets[1].rect[0],
carets[0].rect[0]
);
}
#[test]
fn block_visual_info_returns_data() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "Hello")]);
let info = ts.block_visual_info(1);
assert!(info.is_some());
let info = info.unwrap();
assert_eq!(info.block_id, 1);
assert!(info.height > 0.0);
}
#[test]
fn block_visual_info_nonexistent_returns_none() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "Hello")]);
assert!(ts.block_visual_info(999).is_none());
}
#[test]
fn ensure_caret_visible_when_already_visible() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "Hello")]);
ts.set_cursor(&text_typeset::CursorDisplay {
position: 0,
anchor: 0,
visible: true,
selected_cells: vec![],
});
let result = ts.ensure_caret_visible();
assert!(
result.is_none(),
"caret should already be visible in a large viewport"
);
}
#[test]
fn ensure_caret_visible_scrolls_down_when_needed() {
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);
let blocks: Vec<_> = (0..20)
.map(|i| {
let mut b = make_block(i, &format!("Paragraph {i}."));
b.position = i * 20;
b
})
.collect();
ts.layout_blocks(blocks);
ts.set_cursor(&text_typeset::CursorDisplay {
position: 300, anchor: 300,
visible: true,
selected_cells: vec![],
});
let result = ts.ensure_caret_visible();
assert!(
result.is_some(),
"should need to scroll to make caret at position 300 visible"
);
assert!(
result.unwrap() > 0.0,
"scroll offset should be positive to reveal the caret"
);
}
#[test]
fn scroll_to_position_changes_offset() {
let mut ts = make_typesetter();
let blocks: Vec<_> = (0..10)
.map(|i| {
let mut b = make_block(i, &format!("Paragraph {i}."));
b.position = i * 20;
b
})
.collect();
ts.layout_blocks(blocks);
let offset = ts.scroll_to_position(100);
assert!(
offset >= 0.0,
"scroll_to_position should return non-negative offset"
);
}
#[test]
fn hit_test_below_all_content() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "Short")]);
let result = ts.hit_test(10.0, 500.0);
assert!(
result.is_some(),
"below-content hit test should still return a result"
);
let result = result.unwrap();
assert!(
matches!(result.region, HitRegion::BelowContent),
"far below content should be BelowContent, got {:?}",
result.region
);
}
#[test]
fn hit_test_above_all_content_returns_first_block() {
let mut ts = make_typesetter();
let mut b1 = make_block(1, "First");
b1.position = 0;
let mut b2 = make_block(2, "Last");
b2.position = 10;
ts.layout_blocks(vec![b1, b2]);
let result = ts.hit_test(10.0, -500.0);
assert!(
result.is_some(),
"above-content hit test should return a result"
);
let hit = result.unwrap();
assert_eq!(
hit.block_id, 1,
"hit test above all content should return the first block, not the last (got block {})",
hit.block_id
);
assert_eq!(
hit.position, 0,
"hit test above all content should return start of first block (pos 0), got {}",
hit.position
);
}
#[test]
fn caret_rect_at_end_of_document() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "Hello")]);
let rect = ts.caret_rect(5);
assert!(
rect[2] > 0.0 && rect[3] > 0.0,
"caret at end should have size"
);
}
#[test]
fn caret_rect_with_no_layout() {
let ts = make_typesetter();
let rect = ts.caret_rect(0);
assert!(rect[3] > 0.0, "fallback caret should have positive height");
}
#[test]
fn hit_test_between_blocks_below_content() {
let mut ts = make_typesetter();
let mut block = make_block(1, "Short");
block.top_margin = 0.0;
block.bottom_margin = 100.0; ts.layout_blocks(vec![block]);
let result = ts.hit_test(10.0, 50.0);
assert!(result.is_some());
let hit = result.unwrap();
assert_eq!(hit.block_id, 1, "should return the only block");
}
#[test]
fn hit_test_link_region_detected() {
let mut ts = make_typesetter();
let block = BlockLayoutParams {
block_id: 1,
position: 0,
text: "Click here".to_string(),
fragments: vec![FragmentParams {
text: "Click here".to_string(),
offset: 0,
length: 10,
font_family: None,
font_weight: None,
font_bold: None,
font_italic: None,
font_point_size: None,
underline_style: UnderlineStyle::Single,
overline: false,
strikeout: false,
is_link: true,
letter_spacing: 0.0,
word_spacing: 0.0,
foreground_color: None,
underline_color: None,
background_color: None,
anchor_href: None,
tooltip: None,
vertical_alignment: VerticalAlignment::Normal,
image_name: None,
image_width: 0.0,
image_height: 0.0,
}],
alignment: Alignment::Left,
top_margin: 0.0,
bottom_margin: 0.0,
left_margin: 0.0,
right_margin: 0.0,
text_indent: 0.0,
list_marker: String::new(),
list_indent: 0.0,
tab_positions: vec![],
line_height_multiplier: None,
non_breakable_lines: false,
checkbox: None,
background_color: None,
};
ts.layout_blocks(vec![block]);
let result = ts.hit_test(30.0, 10.0);
assert!(result.is_some());
match result.unwrap().region {
HitRegion::Link { .. } => {} other => panic!(
"expected Link region, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn caret_rect_on_second_line() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(
1,
"First line that wraps to a second line at narrow width.",
)]);
ts.set_viewport(150.0, 600.0);
ts.layout_blocks(vec![make_block(
1,
"First line that wraps to a second line at narrow width.",
)]);
let rect_line1 = ts.caret_rect(0);
let rect_line2 = ts.caret_rect(30); assert!(
rect_line2[1] > rect_line1[1],
"caret on second line ({}) should be below first line ({})",
rect_line2[1],
rect_line1[1]
);
}
#[test]
fn selection_highlights_text_inside_table_cell() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block_at(1, 0, "AB")]);
ts.add_table(&TableLayoutParams {
table_id: 10,
rows: 1,
columns: 1,
column_widths: vec![],
border_width: 1.0,
cell_spacing: 0.0,
cell_padding: 4.0,
cells: vec![make_cell_at(0, 0, 100, 3, "Hello")],
});
ts.set_cursor(&text_typeset::CursorDisplay {
position: 8,
anchor: 3,
visible: true,
selected_cells: vec![],
});
let block1_height = ts.block_visual_info(1).unwrap().height;
let frame = ts.render();
let sel_rects: Vec<_> = frame
.decorations
.iter()
.filter(|d| d.kind == DecorationKind::Selection)
.collect();
assert!(
!sel_rects.is_empty(),
"selection inside a table cell should produce selection rects"
);
for r in &sel_rects {
assert!(
r.rect[1] >= block1_height - 5.0,
"table selection rect y ({}) should be below block 1 (height {})",
r.rect[1],
block1_height
);
}
}
#[test]
fn selection_highlights_text_inside_frame() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block_at(1, 0, "AB")]);
ts.add_frame(&FrameLayoutParams {
frame_id: 20,
position: FramePosition::Inline,
width: None,
height: None,
margin_top: 0.0,
margin_bottom: 0.0,
margin_left: 0.0,
margin_right: 0.0,
padding: 4.0,
border_width: 1.0,
border_style: FrameBorderStyle::Full,
blocks: vec![make_block_at(200, 3, "World")],
tables: vec![],
frames: vec![],
});
ts.set_cursor(&text_typeset::CursorDisplay {
position: 8,
anchor: 3,
visible: true,
selected_cells: vec![],
});
let frame = ts.render();
let sel_rects: Vec<[f32; 4]> = frame
.decorations
.iter()
.filter(|d| d.kind == DecorationKind::Selection)
.map(|d| d.rect)
.collect();
assert!(
!sel_rects.is_empty(),
"selection inside a frame should produce selection rects"
);
let block1_height = ts.block_visual_info(1).unwrap().height;
for r in &sel_rects {
assert!(
r[1] >= block1_height - 5.0,
"frame selection rect y ({}) should be below block 1 (height {})",
r[1],
block1_height
);
}
}
#[test]
fn hit_test_inside_table_cell_returns_correct_block() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block_at(1, 0, "AB")]);
ts.add_table(&TableLayoutParams {
table_id: 10,
rows: 1,
columns: 1,
column_widths: vec![],
border_width: 1.0,
cell_spacing: 0.0,
cell_padding: 4.0,
cells: vec![make_cell_at(0, 0, 100, 3, "Hello")],
});
ts.render();
let block1_info = ts.block_visual_info(1).unwrap();
let table_y = block1_info.y + block1_info.height;
let result = ts.hit_test(50.0, table_y + 10.0);
assert!(
result.is_some(),
"hit test inside table cell should return a result"
);
let result = result.unwrap();
assert_eq!(
result.block_id, 100,
"hit test should return the table cell block id"
);
assert!(
result.position >= 3,
"position should be >= 3 (start of cell text)"
);
}
#[test]
fn hit_test_returns_text_region_for_table_cell_content() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block_at(1, 0, "AB")]);
ts.add_table(&TableLayoutParams {
table_id: 10,
rows: 1,
columns: 1,
column_widths: vec![],
border_width: 1.0,
cell_spacing: 0.0,
cell_padding: 4.0,
cells: vec![make_cell_at(0, 0, 100, 3, "Hello")],
});
ts.render();
let block1_info = ts.block_visual_info(1).unwrap();
let table_y = block1_info.y + block1_info.height;
let result = ts.hit_test(50.0, table_y + 10.0).unwrap();
assert!(
matches!(result.region, HitRegion::Text | HitRegion::PastLineEnd),
"hit test on table cell text should return Text or PastLineEnd region, got {:?}",
result.region
);
}
#[test]
fn caret_rect_inside_table_cell_has_valid_position() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block_at(1, 0, "AB")]);
ts.add_table(&TableLayoutParams {
table_id: 10,
rows: 1,
columns: 1,
column_widths: vec![],
border_width: 1.0,
cell_spacing: 0.0,
cell_padding: 4.0,
cells: vec![make_cell_at(0, 0, 100, 3, "Hello")],
});
ts.render();
let rect = ts.caret_rect(3);
assert_caret_is_real(rect, "position 3 inside table cell");
let block1_info = ts.block_visual_info(1).unwrap();
let table_top = block1_info.y + block1_info.height;
assert!(
rect[1] >= table_top - 5.0,
"caret rect y ({}) should be at or below the table top ({})",
rect[1],
table_top
);
assert!(rect[3] > 0.0, "caret height should be positive");
}
#[test]
fn caret_rect_inside_table_advances_with_position() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block_at(1, 0, "AB")]);
ts.add_table(&TableLayoutParams {
table_id: 10,
rows: 1,
columns: 1,
column_widths: vec![],
border_width: 1.0,
cell_spacing: 0.0,
cell_padding: 4.0,
cells: vec![make_cell_at(0, 0, 100, 3, "Hello")],
});
ts.render();
let rect_start = ts.caret_rect(3); let rect_mid = ts.caret_rect(5); assert!(
rect_mid[0] > rect_start[0],
"caret at position 5 ({}) should be right of position 3 ({})",
rect_mid[0],
rect_start[0]
);
}
#[test]
fn hit_test_below_frame_content_does_not_stick_to_frame() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block_at(1, 0, "AB")]);
ts.add_frame(&FrameLayoutParams {
frame_id: 20,
position: FramePosition::Inline,
width: None,
height: None,
margin_top: 4.0,
margin_bottom: 4.0,
margin_left: 16.0,
margin_right: 0.0,
padding: 8.0,
border_width: 3.0,
border_style: FrameBorderStyle::LeftOnly,
blocks: vec![make_block_at(100, 3, "CD")],
tables: vec![],
frames: vec![],
});
let caret = ts.caret_rect(5);
let line_height = caret[3];
let target_y = caret[1] + line_height;
let result = ts.hit_test(50.0, target_y);
if let Some(hit) = &result {
assert_ne!(
hit.block_id, 100,
"hit_test below frame content should not return the frame's block"
);
}
}
#[test]
fn hit_test_inside_frame_returns_frame_block() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block_at(1, 0, "AB")]);
ts.add_frame(&FrameLayoutParams {
frame_id: 20,
position: FramePosition::Inline,
width: None,
height: None,
margin_top: 4.0,
margin_bottom: 4.0,
margin_left: 16.0,
margin_right: 0.0,
padding: 8.0,
border_width: 3.0,
border_style: FrameBorderStyle::LeftOnly,
blocks: vec![make_block_at(100, 3, "Hello")],
tables: vec![],
frames: vec![],
});
ts.render();
let block1_info = ts.block_visual_info(1).unwrap();
let frame_content_y = block1_info.y + block1_info.height + 4.0 + 3.0 + 8.0 + 5.0;
let result = ts.hit_test(50.0, frame_content_y);
assert!(
result.is_some(),
"hit test inside frame should return a result"
);
let hit = result.unwrap();
assert_eq!(
hit.block_id, 100,
"hit test inside frame should return the frame's block id"
);
assert!(
hit.position >= 3,
"position should be >= 3 (start of frame block text)"
);
}
#[test]
fn hit_test_inside_frame_returns_text_region() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block_at(1, 0, "AB")]);
ts.add_frame(&FrameLayoutParams {
frame_id: 20,
position: FramePosition::Inline,
width: None,
height: None,
margin_top: 0.0,
margin_bottom: 0.0,
margin_left: 0.0,
margin_right: 0.0,
padding: 4.0,
border_width: 0.0,
border_style: FrameBorderStyle::None,
blocks: vec![make_block_at(100, 3, "Hello world")],
tables: vec![],
frames: vec![],
});
ts.render();
let block1_info = ts.block_visual_info(1).unwrap();
let frame_content_y = block1_info.y + block1_info.height + 4.0 + 5.0;
let result = ts.hit_test(50.0, frame_content_y).unwrap();
assert!(
matches!(result.region, HitRegion::Text | HitRegion::PastLineEnd),
"hit test on frame text should return Text or PastLineEnd, got {:?}",
result.region
);
}
#[test]
fn caret_rect_inside_frame_has_valid_position() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block_at(1, 0, "AB")]);
ts.add_frame(&FrameLayoutParams {
frame_id: 20,
position: FramePosition::Inline,
width: None,
height: None,
margin_top: 4.0,
margin_bottom: 4.0,
margin_left: 16.0,
margin_right: 0.0,
padding: 8.0,
border_width: 3.0,
border_style: FrameBorderStyle::LeftOnly,
blocks: vec![make_block_at(100, 3, "Hello")],
tables: vec![],
frames: vec![],
});
ts.render();
let rect = ts.caret_rect(3);
assert_caret_is_real(rect, "position 3 inside frame");
let block1_info = ts.block_visual_info(1).unwrap();
let frame_top = block1_info.y + block1_info.height;
assert!(
rect[1] >= frame_top - 5.0,
"caret rect y ({}) should be at or below the frame top ({})",
rect[1],
frame_top
);
assert!(rect[3] > 0.0, "caret height should be positive");
}
#[test]
fn caret_rect_inside_frame_advances_with_position() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block_at(1, 0, "AB")]);
ts.add_frame(&FrameLayoutParams {
frame_id: 20,
position: FramePosition::Inline,
width: None,
height: None,
margin_top: 0.0,
margin_bottom: 0.0,
margin_left: 0.0,
margin_right: 0.0,
padding: 4.0,
border_width: 0.0,
border_style: FrameBorderStyle::None,
blocks: vec![make_block_at(100, 3, "Hello")],
tables: vec![],
frames: vec![],
});
ts.render();
let rect_start = ts.caret_rect(3); let rect_mid = ts.caret_rect(5); assert!(
rect_mid[0] > rect_start[0],
"caret at position 5 ({}) should be right of position 3 ({})",
rect_mid[0],
rect_start[0]
);
}
#[test]
fn hit_test_on_last_paragraph_after_cut_from_middle() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![
make_block_at(1, 0, "First"),
make_block_at(2, 6, "Second"),
make_block_at(3, 13, "Third"),
]);
ts.render();
let last_caret_before = ts.caret_rect(18);
ts.relayout_block(&make_block_at(2, 6, "ond"));
ts.render();
let hit = ts
.hit_test(200.0, last_caret_before[1] + last_caret_before[3] / 2.0)
.expect("click inside last paragraph should hit");
assert_eq!(
hit.position, 15,
"clicking at the visual end of the last paragraph after cut must map to \
the new max cursor position (15), not the stale pre-cut value"
);
}
#[test]
fn relayout_frame_block_renders_new_glyphs() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block_at(1, 0, "AB")]);
ts.add_frame(&FrameLayoutParams {
frame_id: 20,
position: FramePosition::Inline,
width: None,
height: None,
margin_top: 0.0,
margin_bottom: 0.0,
margin_left: 0.0,
margin_right: 0.0,
padding: 4.0,
border_width: 0.0,
border_style: FrameBorderStyle::None,
blocks: vec![make_block_at(100, 3, "Hi")],
tables: vec![],
frames: vec![],
});
let frame1 = ts.render();
let glyph_count_before = frame1.glyphs.len();
let caret_before = ts.caret_rect(4);
ts.relayout_block(&make_block_at(100, 3, "Hxi"));
let frame2 = ts.render();
let glyph_count_after = frame2.glyphs.len();
assert!(
glyph_count_after > glyph_count_before,
"after relayout with longer text, glyph count should increase: {} -> {}",
glyph_count_before,
glyph_count_after
);
let caret_after = ts.caret_rect(5);
assert!(
caret_after[0] > caret_before[0],
"caret after typing should be right of previous caret: {} -> {}",
caret_before[0],
caret_after[0]
);
}
#[test]
fn relayout_frame_block_caret_advances_correctly() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block_at(1, 0, "AB")]);
ts.add_frame(&FrameLayoutParams {
frame_id: 20,
position: FramePosition::Inline,
width: None,
height: None,
margin_top: 0.0,
margin_bottom: 0.0,
margin_left: 0.0,
margin_right: 0.0,
padding: 4.0,
border_width: 0.0,
border_style: FrameBorderStyle::None,
blocks: vec![make_block_at(100, 3, "Hello")],
tables: vec![],
frames: vec![],
});
ts.render();
let caret_h = ts.caret_rect(3); let caret_e = ts.caret_rect(4);
ts.relayout_block(&make_block_at(100, 3, "HXello"));
ts.render();
let caret_h_after = ts.caret_rect(3);
let caret_x_after = ts.caret_rect(5);
assert!(
(caret_h_after[0] - caret_h[0]).abs() < 1.0,
"start of block caret should stay at same x: {} vs {}",
caret_h[0],
caret_h_after[0]
);
assert!(
caret_x_after[0] > caret_e[0],
"caret after inserted 'X' at pos 5 ({}) should be right of old pos 4 ({})",
caret_x_after[0],
caret_e[0]
);
}
#[test]
fn caret_rect_inside_nested_frame() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block_at(1, 0, "AB")]);
ts.add_frame(&FrameLayoutParams {
frame_id: 20,
position: FramePosition::Inline,
width: None,
height: None,
margin_top: 0.0,
margin_bottom: 0.0,
margin_left: 0.0,
margin_right: 0.0,
padding: 4.0,
border_width: 0.0,
border_style: FrameBorderStyle::None,
blocks: vec![make_block_at(100, 3, "Outer")],
tables: vec![],
frames: vec![(
1,
FrameLayoutParams {
frame_id: 30,
position: FramePosition::Inline,
width: None,
height: None,
margin_top: 0.0,
margin_bottom: 0.0,
margin_left: 0.0,
margin_right: 0.0,
padding: 4.0,
border_width: 0.0,
border_style: FrameBorderStyle::None,
blocks: vec![make_block_at(200, 9, "Inner")],
tables: vec![],
frames: vec![],
},
)],
});
ts.render();
let rect = ts.caret_rect(9);
assert_caret_is_real(rect, "position 9 inside nested frame");
assert!(
rect[3] > 0.0,
"caret inside nested frame should have valid height (not fallback)"
);
assert!(
rect[1] > 0.0,
"caret y inside nested frame ({}) should be positive (below top-level content)",
rect[1]
);
}
#[test]
fn hit_test_inside_nested_frame_returns_inner_block() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block_at(1, 0, "AB")]);
ts.add_frame(&FrameLayoutParams {
frame_id: 20,
position: FramePosition::Inline,
width: None,
height: None,
margin_top: 0.0,
margin_bottom: 0.0,
margin_left: 0.0,
margin_right: 0.0,
padding: 4.0,
border_width: 0.0,
border_style: FrameBorderStyle::None,
blocks: vec![make_block_at(100, 3, "Outer")],
tables: vec![],
frames: vec![(
1,
FrameLayoutParams {
frame_id: 30,
position: FramePosition::Inline,
width: None,
height: None,
margin_top: 0.0,
margin_bottom: 0.0,
margin_left: 0.0,
margin_right: 0.0,
padding: 4.0,
border_width: 0.0,
border_style: FrameBorderStyle::None,
blocks: vec![make_block_at(200, 9, "Inner")],
tables: vec![],
frames: vec![],
},
)],
});
ts.render();
let caret = ts.caret_rect(9);
assert!(
caret[1] > 0.0,
"caret_rect should return valid position for nested frame content (got y={})",
caret[1]
);
let result = ts.hit_test(caret[0] + 10.0, caret[1] + caret[3] / 2.0);
assert!(
result.is_some(),
"hit test inside nested frame should return a result"
);
let hit = result.unwrap();
assert_eq!(
hit.block_id, 200,
"hit test inside nested frame should return the inner block id"
);
}
#[test]
fn selection_inside_nested_frame() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block_at(1, 0, "AB")]);
ts.add_frame(&FrameLayoutParams {
frame_id: 20,
position: FramePosition::Inline,
width: None,
height: None,
margin_top: 0.0,
margin_bottom: 0.0,
margin_left: 0.0,
margin_right: 0.0,
padding: 4.0,
border_width: 0.0,
border_style: FrameBorderStyle::None,
blocks: vec![make_block_at(100, 3, "Outer")],
tables: vec![],
frames: vec![(
1,
FrameLayoutParams {
frame_id: 30,
position: FramePosition::Inline,
width: None,
height: None,
margin_top: 0.0,
margin_bottom: 0.0,
margin_left: 0.0,
margin_right: 0.0,
padding: 4.0,
border_width: 0.0,
border_style: FrameBorderStyle::None,
blocks: vec![make_block_at(200, 9, "Inner")],
tables: vec![],
frames: vec![],
},
)],
});
ts.set_cursor(&text_typeset::CursorDisplay {
position: 9,
anchor: 14,
visible: true,
selected_cells: vec![],
});
let frame = ts.render();
let sel_rects: Vec<_> = frame
.decorations
.iter()
.filter(|d| d.kind == DecorationKind::Selection)
.collect();
assert!(
!sel_rects.is_empty(),
"selection inside nested frame should produce selection rects"
);
}
#[test]
fn hit_test_plain_block_has_no_table_id() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "Hello world")]);
ts.render();
let result = ts.hit_test(40.0, 10.0).unwrap();
assert_eq!(
result.table_id, None,
"block-only hit should have table_id=None, got {:?}",
result.table_id
);
}