use parley::Alignment;
use super::draw::*;
use super::length::{pt, RichMargin};
use super::run::*;
use super::shape::*;
use super::style::{RichTextStyleSheet, StyleDelta};
use crate::brush::Brush;
use crate::color::Color;
use crate::geometry::Affine;
use crate::layout::Measure;
use crate::pick::PickId;
use crate::scene::recording::{Op, RecordingScene};
use crate::style_vocab::{HAlign, Palette};
use crate::text::rich::anchor::RichAnchor;
use crate::text::TextStyle;
fn palette() -> Palette {
Palette::new(
Color::from_rgba8(255, 255, 255, 255),
Color::from_rgba8(0, 0, 0, 255),
Color::from_rgba8(51, 105, 232, 255),
)
}
fn base_style() -> TextStyle {
TextStyle::new(14.0)
}
fn make(src: &str) -> RichTextRun {
let sheet = RichTextStyleSheet::new();
RichTextRun::new(
src,
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
)
}
fn draw(run: &RichTextRun) -> RecordingScene {
let mut scene = RecordingScene::default();
draw_rich_text(
&mut scene,
run,
0.0,
0.0,
RichAnchor::top_left(),
Affine::IDENTITY,
PickId::Skip,
);
scene
}
fn glyph_x_at_line(scene: &RecordingScene, line_ordinal: usize) -> Option<f32> {
let mut ops = scene.ops.iter().filter_map(|op| match op {
Op::DrawGlyphs(gr) => Some(gr),
_ => None,
});
let gr = ops.nth(line_ordinal)?;
gr.glyphs.iter().map(|g| g.x).fold(None::<f32>, |acc, x| {
Some(match acc {
None => x,
Some(a) => a.min(x),
})
})
}
#[test]
fn inline_code_span_emits_background_chip() {
let run = make("plain `x` more");
let scene = draw(&run);
let mut saw_fill = false;
let mut saw_glyphs_after_fill = false;
for op in &scene.ops {
match op {
Op::Fill { .. } => saw_fill = true,
Op::DrawGlyphs(_) if saw_fill => saw_glyphs_after_fill = true,
_ => {}
}
}
assert!(saw_fill, "expected a Fill op for the code chip");
assert!(saw_glyphs_after_fill, "glyphs should follow the chip fill");
}
#[test]
fn per_span_text_stroke_emits_outline_pass() {
let mut sheet = RichTextStyleSheet::new();
sheet.set(
"haloed",
crate::text::rich::style::StyleDelta {
color: Some(crate::style_vocab::ThemeColor::Fixed(Color::from_rgba8(
220, 30, 30, 255,
))),
text_stroke: Some(crate::style_vocab::ThemeColor::Fixed(Color::from_rgba8(
255, 255, 255, 255,
))),
text_stroke_width: Some(pt(2.0)),
..crate::text::rich::style::StyleDelta::empty()
},
);
let run = RichTextRun::new(
"before {.haloed word} after",
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
);
let mut scene = RecordingScene::default();
draw_rich_text(
&mut scene,
&run,
0.0,
0.0,
RichAnchor::top_left(),
Affine::IDENTITY,
PickId::Skip,
);
let has_stroked_pass = scene
.ops
.iter()
.any(|op| matches!(op, Op::DrawGlyphs(gr) if gr.style.is_some()));
assert!(
has_stroked_pass,
"expected a stroke-only glyph pass for the `haloed` span"
);
}
#[test]
fn block_level_border_does_not_double_render_as_inline() {
let mut sheet = RichTextStyleSheet::empty();
sheet.set(
"paragraph",
crate::text::rich::style::StyleDelta {
border_color: Some(crate::style_vocab::ThemeColor::Ink),
border_width: Some(RichMargin::all(pt(1.0))),
..crate::text::rich::style::StyleDelta::empty()
},
);
let run = RichTextRun::new(
"some text",
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
);
let mut scene = RecordingScene::default();
draw_rich_text(
&mut scene,
&run,
0.0,
0.0,
RichAnchor::top_left(),
Affine::IDENTITY,
PickId::Skip,
);
let strokes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count();
assert_eq!(
strokes, 1,
"expected exactly one border stroke (block-level)"
);
}
#[test]
fn list_container_margin_survives_a_re_break() {
let mut sheet = RichTextStyleSheet::new();
sheet.set(
"list",
StyleDelta {
margin: Some(RichMargin::new(pt(40.0), pt(0.0), pt(40.0), pt(0.0))),
..StyleDelta::empty()
},
);
let make_with = |src: &str, sheet: &RichTextStyleSheet| {
RichTextRun::new(
src,
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
sheet,
&palette(),
96.0,
)
};
let roomy = make_with("- alpha\n\nfollowing", &sheet);
let tight = make_with("- alpha\n\nfollowing", &RichTextStyleSheet::new());
let natural_delta = roomy.natural_height() - tight.natural_height();
assert!(
natural_delta > 20.0,
"the list's own margin should widen the natural stack (delta={natural_delta})"
);
let width = roomy.natural_width() as f32;
let roomy_broken = roomy.set_max_width(width, HAlign::Start) as f64;
let tight_broken = tight.set_max_width(width, HAlign::Start) as f64;
assert!(
(roomy_broken - tight_broken - natural_delta).abs() < 1.0,
"re-break lost the container margin ({roomy_broken} - {tight_broken} vs {natural_delta})"
);
}
#[test]
fn strikethrough_sits_above_baseline() {
let sheet = RichTextStyleSheet::new();
let run = RichTextRun::new(
"a ~~strike~~ b",
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
);
let mut scene = RecordingScene::default();
draw_rich_text(
&mut scene,
&run,
0.0,
0.0,
RichAnchor::top_left(),
Affine::IDENTITY,
PickId::Skip,
);
let baseline_y = scene
.ops
.iter()
.find_map(|op| match op {
Op::DrawGlyphs(gr) => gr.glyphs.first().map(|g| g.y),
_ => None,
})
.expect("baseline");
let fill_y0 = scene
.ops
.iter()
.find_map(|op| match op {
Op::Fill { path, .. } => Some(crate::geometry::Shape::bounding_box(path).y0 as f32),
_ => None,
})
.expect("strikethrough fill");
assert!(
fill_y0 < baseline_y,
"strikethrough rect (y0={fill_y0}) should sit ABOVE the baseline (y={baseline_y})"
);
}
#[test]
fn underline_sits_below_baseline() {
let sheet = RichTextStyleSheet::new();
let run = RichTextRun::new(
"a _under_ b",
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
);
let mut scene = RecordingScene::default();
draw_rich_text(
&mut scene,
&run,
0.0,
0.0,
RichAnchor::top_left(),
Affine::IDENTITY,
PickId::Skip,
);
let baseline_y = scene
.ops
.iter()
.find_map(|op| match op {
Op::DrawGlyphs(gr) => gr.glyphs.first().map(|g| g.y),
_ => None,
})
.expect("baseline");
let fill_y0 = scene
.ops
.iter()
.find_map(|op| match op {
Op::Fill { path, .. } => Some(crate::geometry::Shape::bounding_box(path).y0 as f32),
_ => None,
})
.expect("underline fill");
assert!(
fill_y0 > baseline_y,
"underline rect (y0={fill_y0}) should sit BELOW the baseline (y={baseline_y})"
);
}
#[test]
fn plain_text_shapes_and_measures() {
let run = make("hello world");
assert!(run.natural_width() > 0.0);
assert!(run.natural_height() > 0.0);
}
#[test]
fn bold_widens_natural_width_vs_plain() {
let plain = make("hello world");
let bold = make("**hello world**");
assert!(bold.natural_width() >= plain.natural_width());
}
#[test]
fn draw_emits_glyph_runs_with_per_range_brushes() {
let run = make("a {.red word} b");
let scene = draw(&run);
let glyph_runs: Vec<_> = scene
.ops
.iter()
.filter_map(|op| match op {
Op::DrawGlyphs(gr) => Some(gr),
_ => None,
})
.collect();
assert!(glyph_runs.len() >= 2, "expected multiple glyph runs");
let has_red = glyph_runs.iter().any(|gr| match &gr.brush {
Brush::Solid(c) => {
let [r, g, b, _] = c.components;
(r - 1.0).abs() < 1e-3 && g < 0.1 && b < 0.1
}
_ => false,
});
assert!(has_red);
}
#[test]
fn sup_offsets_glyphs_upward() {
let run = make("a ^2^ b");
let scene = draw(&run);
let mut ys: Vec<f32> = Vec::new();
for op in &scene.ops {
if let Op::DrawGlyphs(gr) = op {
for g in &gr.glyphs {
ys.push(g.y);
}
}
}
assert!(!ys.is_empty());
let min_y = ys.iter().cloned().fold(f32::INFINITY, f32::min);
let max_y = ys.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
assert!(max_y - min_y > 1.0);
}
#[test]
fn measure_impl_reports_positive_height() {
let run = make("hello **bold** world");
let h = run.height_at(200.0, 96.0);
assert!(h > 0.0);
}
#[test]
fn base_brush_flows_through_to_plain_runs() {
let sheet = RichTextStyleSheet::new();
let base_col = Color::from_rgba8(50, 100, 200, 255);
let run = RichTextRun::new(
"plain text",
&base_style(),
base_col,
&sheet,
&palette(),
96.0,
);
let scene = draw(&run);
let first = scene
.ops
.iter()
.find_map(|op| match op {
Op::DrawGlyphs(gr) => Some(gr),
_ => None,
})
.expect("glyph run");
match &first.brush {
Brush::Solid(c) => {
let [r, g, b, _] = c.components;
assert!((r - 50.0 / 255.0).abs() < 1e-2);
assert!((g - 100.0 / 255.0).abs() < 1e-2);
assert!((b - 200.0 / 255.0).abs() < 1e-2);
}
_ => panic!("expected solid brush"),
}
}
#[test]
fn heading_produces_larger_glyph_run_font_size() {
let plain = make("Big");
let heading = make("# Big");
let max_size = |run: &RichTextRun| {
let scene = draw(run);
scene
.ops
.iter()
.filter_map(|op| match op {
Op::DrawGlyphs(gr) => Some(gr.font_size),
_ => None,
})
.fold(0.0_f32, f32::max)
};
assert!(max_size(&heading) > max_size(&plain) * 1.5);
}
#[test]
fn size_selector_produces_larger_run_height() {
let plain = make("x");
let big = make("{.36 x}");
assert!(big.natural_height() > plain.natural_height());
}
#[test]
fn new_with_width_wraps_at_fixed_pixels() {
let sheet = RichTextStyleSheet::new();
let unwrapped = RichTextRun::new(
"one two three four five six seven eight",
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
);
let wrapped = RichTextRun::new_with_width(
"one two three four five six seven eight",
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
RichTextWidth::Fixed(60.0),
);
assert!(wrapped.current_height() > unwrapped.current_height());
}
#[test]
fn blockquote_indents_its_paragraph() {
let plain = make("hello world");
let quoted = make("> hello world");
let plain_x = glyph_x_at_line(&draw(&plain), 0).unwrap_or(0.0);
let quoted_x = glyph_x_at_line(&draw("ed), 0).unwrap_or(0.0);
assert!(
quoted_x > plain_x + 10.0,
"blockquote content should be indented (plain={plain_x}, quoted={quoted_x})"
);
}
#[test]
fn single_paragraph_box_is_tight_around_its_line() {
let run = make("**bold** and *italic*");
let line = run.layout_bounds().height as f64;
let total = run.natural_height();
assert!(
(total - line).abs() < 0.01,
"one-paragraph run should measure one line (line={line}, total={total})"
);
}
#[test]
fn leading_block_margin_collapses_out_of_the_box() {
let run = make("## Heading");
let ink_top = run.layout_bounds().ink_top;
assert!(
ink_top < 1.0,
"leading margin should collapse out of the box (ink_top={ink_top})"
);
}
#[test]
fn interior_block_margins_survive() {
let mut flat = RichTextStyleSheet::new();
flat.set(
"h2",
StyleDelta {
margin: Some(RichMargin::all(pt(0.0))),
..flat.get("h2").cloned().unwrap_or_default()
},
);
let spaced = make("intro\n\n## Heading\n\nbody");
let flattened = RichTextRun::new(
"intro\n\n## Heading\n\nbody",
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&flat,
&palette(),
96.0,
);
let delta = spaced.natural_height() - flattened.natural_height();
assert!(
delta > 10.0,
"interior heading margins should still add space (delta={delta})"
);
}
#[test]
fn sibling_margins_collapse_via_max_not_sum() {
let three = make("a\n\nb\n\nc");
let five = make("a\n\nb\n\nc\n\nd\n\ne");
let diff = five.natural_height() - three.natural_height();
assert!(
diff < 115.0,
"2 extra paragraphs should collapse to ≈97px of extra height, got {diff}"
);
}
#[test]
fn adjacent_heading_paragraph_margins_collapse() {
let with_heading = make("# Header\n\nBody");
let no_heading = make("Header\n\nBody");
assert!(with_heading.natural_height() > no_heading.natural_height());
}
#[test]
fn horizontal_rule_emits_single_top_stroke() {
let run = make("above\n\n---\n\nbelow");
let mut scene = RecordingScene::default();
draw_rich_text(
&mut scene,
&run,
0.0,
0.0,
RichAnchor::top_left(),
Affine::IDENTITY,
PickId::Skip,
);
let strokes: Vec<_> = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.collect();
assert_eq!(
strokes.len(),
1,
"hr should emit exactly one stroke (top edge only), got {}",
strokes.len()
);
}
#[test]
fn horizontal_rule_stretches_to_full_natural_width() {
let run = make("hello world hello world hello world\n\n---\n\nfollowing");
let paints = run.block_paints();
let hr = paints
.iter()
.find(|p| p.border.is_some() && p.background.is_none())
.expect("expected hr paint");
let width = hr.outer_rect.width();
assert!(
width > 50.0,
"hr should span at least a paragraph's width; got {width}"
);
assert!(
width as f32 >= run.natural_width() as f32 - 5.0,
"hr should span ≥ natural width ({} vs {})",
width,
run.natural_width()
);
}
#[test]
fn horizontal_rule_reserves_vertical_space() {
let without_hr = make("above\n\nbelow");
let with_hr = make("above\n\n---\n\nbelow");
assert!(
with_hr.natural_height() > without_hr.natural_height(),
"hr's margins should add vertical space (without: {}, with: {})",
without_hr.natural_height(),
with_hr.natural_height()
);
}
#[test]
fn blockquote_emits_single_left_edge_stroke() {
let run = make("> hello world");
let mut scene = RecordingScene::default();
draw_rich_text(
&mut scene,
&run,
0.0,
0.0,
RichAnchor::top_left(),
Affine::IDENTITY,
PickId::Skip,
);
let strokes: Vec<_> = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.collect();
assert_eq!(
strokes.len(),
1,
"blockquote should emit exactly one stroke (left edge only), got {}",
strokes.len()
);
}
#[test]
fn halign_start_maps_to_right_under_rtl() {
assert_eq!(hal_to_alignment(HAlign::Start, false), Alignment::Left);
assert_eq!(hal_to_alignment(HAlign::Start, true), Alignment::Right);
assert_eq!(hal_to_alignment(HAlign::End, false), Alignment::Right);
assert_eq!(hal_to_alignment(HAlign::End, true), Alignment::Left);
assert_eq!(hal_to_alignment(HAlign::Center, false), Alignment::Center);
assert_eq!(hal_to_alignment(HAlign::Center, true), Alignment::Center);
assert_eq!(hal_to_alignment(HAlign::Justify, true), Alignment::Justify);
}
#[test]
fn auto_direction_reads_parley_is_rtl_for_arabic() {
let sheet = RichTextStyleSheet::empty();
let run = RichTextRun::new(
"مرحبا",
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
);
let blocks = run.blocks.borrow();
assert!(
blocks.first().map(|bl| bl.is_rtl).unwrap_or(false),
"an Arabic-only paragraph should resolve to Rtl via parley::Layout::is_rtl"
);
}
#[test]
fn explicit_ltr_overrides_arabic_content_direction() {
let mut sheet = RichTextStyleSheet::empty();
sheet.set(
"paragraph",
crate::text::rich::style::StyleDelta {
text_direction: Some(crate::text::rich::style::Direction::Ltr),
..crate::text::rich::style::StyleDelta::empty()
},
);
let run = RichTextRun::new(
"مرحبا",
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
);
let blocks = run.blocks.borrow();
assert!(
!blocks.first().map(|bl| bl.is_rtl).unwrap_or(true),
"explicit Direction::Ltr must override parley's Rtl inference"
);
}
#[test]
fn rtl_blockquote_paints_right_edge_bar() {
let mut sheet = RichTextStyleSheet::empty();
sheet.set(
"block_quote",
crate::text::rich::style::StyleDelta {
text_direction: Some(crate::text::rich::style::Direction::Rtl),
border_color: Some(crate::style_vocab::ThemeColor::Ink),
border_width: Some(RichMargin::new(pt(0.0), pt(0.0), pt(0.0), pt(3.0))),
..crate::text::rich::style::StyleDelta::empty()
},
);
let run = RichTextRun::new(
"> quoted content",
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
);
let paints = run.block_paints();
let border = paints
.iter()
.find_map(|p| p.border.as_ref())
.expect("blockquote should have a border");
assert!(
border.widths_px[1] > 0.0,
"under Rtl the start-side bar should paint on the physical right (widths_px[1])"
);
assert!(
border.widths_px[3].abs() < 1e-3,
"physical left (widths_px[3]) should be zero"
);
}
#[test]
fn ltr_blockquote_still_paints_left_edge_bar() {
let mut sheet = RichTextStyleSheet::empty();
sheet.set(
"block_quote",
crate::text::rich::style::StyleDelta {
border_color: Some(crate::style_vocab::ThemeColor::Ink),
border_width: Some(RichMargin::new(pt(0.0), pt(0.0), pt(0.0), pt(3.0))),
..crate::text::rich::style::StyleDelta::empty()
},
);
let run = RichTextRun::new(
"> quoted content",
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
);
let paints = run.block_paints();
let border = paints
.iter()
.find_map(|p| p.border.as_ref())
.expect("blockquote should have a border");
assert!(
border.widths_px[3] > 0.0,
"under Ltr the start-side bar should paint on the physical left (widths_px[3])"
);
assert!(
border.widths_px[1].abs() < 1e-3,
"physical right (widths_px[1]) should be zero"
);
}
#[test]
fn uniform_border_emits_single_boxed_stroke() {
let mut sheet = RichTextStyleSheet::empty();
sheet.set(
"paragraph",
crate::text::rich::style::StyleDelta {
border_color: Some(crate::style_vocab::ThemeColor::Ink),
border_width: Some(RichMargin::all(pt(1.0))),
..crate::text::rich::style::StyleDelta::empty()
},
);
let run = RichTextRun::new(
"boxed",
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
);
let mut scene = RecordingScene::default();
draw_rich_text(
&mut scene,
&run,
0.0,
0.0,
RichAnchor::top_left(),
Affine::IDENTITY,
PickId::Skip,
);
let strokes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count();
assert_eq!(strokes, 1, "uniform border → one rectangular stroke");
}
#[test]
fn adjacent_partial_borders_collapse_into_one_polyline() {
let mut sheet = RichTextStyleSheet::empty();
sheet.set(
"paragraph",
crate::text::rich::style::StyleDelta {
border_color: Some(crate::style_vocab::ThemeColor::Ink),
border_width: Some(RichMargin {
top: pt(2.0),
right: pt(0.0),
bottom: pt(0.0),
left: pt(2.0),
}),
..crate::text::rich::style::StyleDelta::empty()
},
);
let run = RichTextRun::new(
"l shape",
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
);
let mut scene = RecordingScene::default();
draw_rich_text(
&mut scene,
&run,
0.0,
0.0,
RichAnchor::top_left(),
Affine::IDENTITY,
PickId::Skip,
);
let strokes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count();
assert_eq!(
strokes, 1,
"top + left same-width partial borders should collapse into one polyline"
);
}
#[test]
fn partial_borders_with_mismatched_widths_stay_separate() {
let mut sheet = RichTextStyleSheet::empty();
sheet.set(
"paragraph",
crate::text::rich::style::StyleDelta {
border_color: Some(crate::style_vocab::ThemeColor::Ink),
border_width: Some(RichMargin {
top: pt(1.0),
right: pt(0.0),
bottom: pt(0.0),
left: pt(4.0),
}),
..crate::text::rich::style::StyleDelta::empty()
},
);
let run = RichTextRun::new(
"mixed",
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
);
let mut scene = RecordingScene::default();
draw_rich_text(
&mut scene,
&run,
0.0,
0.0,
RichAnchor::top_left(),
Affine::IDENTITY,
PickId::Skip,
);
let strokes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count();
assert_eq!(strokes, 2, "different widths keep sides separate");
}
#[test]
fn dashed_border_carries_dash_pattern_through() {
use crate::scales::value::LinetypeStep;
use std::sync::Arc;
let mut sheet = RichTextStyleSheet::empty();
sheet.set(
"paragraph",
crate::text::rich::style::StyleDelta {
border_color: Some(crate::style_vocab::ThemeColor::Ink),
border_width: Some(RichMargin::all(pt(1.0))),
border_type: Some(Arc::from(vec![
LinetypeStep::Dash(4.0),
LinetypeStep::Gap(2.0),
])),
..crate::text::rich::style::StyleDelta::empty()
},
);
let run = RichTextRun::new(
"dashy",
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
);
let paints = run.block_paints();
let border = paints
.iter()
.find_map(|p| p.border.as_ref())
.expect("expected a border on the paragraph");
let pattern = border
.linetype_pt
.as_ref()
.expect("border_type should produce a linetype pattern");
assert_eq!(pattern.len(), 2);
use crate::scales::value::LinetypeStep::{Dash, Gap};
assert!(matches!(pattern[0], Dash(d) if d > 0.0));
assert!(matches!(pattern[1], Gap(g) if g > 0.0));
}
#[test]
fn loose_list_stacks_taller_than_tight_list() {
let tight = make("- alpha\n- beta\n- gamma");
let loose = make("- alpha\n\n- beta\n\n- gamma");
assert!(
loose.natural_height() > tight.natural_height() + 5.0,
"loose ({}) should stack taller than tight ({})",
loose.natural_height(),
tight.natural_height()
);
}
#[test]
fn nested_list_item_sits_further_right_than_outer() {
let run = make("- outer\n - inner");
let scene = draw(&run);
let mut by_line: std::collections::BTreeMap<i32, f32> = std::collections::BTreeMap::new();
for op in &scene.ops {
if let Op::DrawGlyphs(gr) = op {
if let Some(min_x) = gr.glyphs.iter().map(|g| g.x).fold(None::<f32>, |acc, x| {
Some(match acc {
None => x,
Some(a) => a.min(x),
})
}) {
let y_key = gr.glyphs[0].y as i32;
by_line
.entry(y_key)
.and_modify(|v| *v = v.min(min_x))
.or_insert(min_x);
}
}
}
let xs: Vec<f32> = by_line.into_values().collect();
assert!(xs.len() >= 2, "expected at least 2 lines, got {xs:?}");
assert!(
xs[1] > xs[0] + 10.0,
"nested item should be indented past outer (xs={xs:?})"
);
}
#[test]
fn list_item_hanging_shifts_continuation_lines() {
let src = "- one two three four five six seven eight nine";
let sheet = RichTextStyleSheet::new();
let run = RichTextRun::new_with_width(
src,
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
RichTextWidth::Fixed(100.0),
);
let scene = draw(&run);
let mut by_line: std::collections::BTreeMap<i32, f32> = std::collections::BTreeMap::new();
for op in &scene.ops {
if let Op::DrawGlyphs(gr) = op {
if let Some(min_x) = gr.glyphs.iter().map(|g| g.x).fold(None::<f32>, |acc, x| {
Some(match acc {
None => x,
Some(a) => a.min(x),
})
}) {
let y_key = gr.glyphs[0].y as i32;
by_line
.entry(y_key)
.and_modify(|v| *v = v.min(min_x))
.or_insert(min_x);
}
}
}
let xs: Vec<f32> = by_line.into_values().collect();
assert!(xs.len() >= 2, "expected at least 2 lines, got {xs:?}");
assert!(
xs[1] > xs[0] + 5.0,
"continuation line should be right of first (xs={xs:?})"
);
}
#[test]
fn code_block_emits_fill_before_glyphs() {
let run = make("```\nlet x = 1;\n```");
let scene = draw(&run);
let first_fill = scene
.ops
.iter()
.position(|op| matches!(op, Op::Fill { .. }));
let first_glyphs = scene
.ops
.iter()
.position(|op| matches!(op, Op::DrawGlyphs(_)));
let (fi, gi) = (first_fill.expect("fill"), first_glyphs.expect("glyphs"));
assert!(fi < gi, "Fill at {fi} should precede DrawGlyphs at {gi}");
}
#[test]
fn plain_text_emits_no_block_paints() {
let run = make("just plain");
let scene = draw(&run);
let fills = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count();
let strokes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count();
assert_eq!(fills, 0);
assert_eq!(strokes, 0);
}
#[test]
fn center_anchor_shifts_glyph_positions_by_half_width() {
let run = make("abcdef");
let width = run.natural_width() as f32;
let mut scene = RecordingScene::default();
draw_rich_text(
&mut scene,
&run,
100.0,
100.0,
RichAnchor::center(),
Affine::IDENTITY,
PickId::Skip,
);
let first_op = scene
.ops
.iter()
.find_map(|op| match op {
Op::DrawGlyphs(gr) => Some(gr),
_ => None,
})
.unwrap();
let first_g = first_op.glyphs.first().unwrap();
let transformed_x = first_op.transform.as_coeffs()[4] as f32 + first_g.x;
assert!(
(transformed_x - (100.0 - width * 0.5)).abs() < 2.0,
"first glyph should sit near (x - width/2), got {transformed_x} (expected ~{})",
100.0 - width * 0.5
);
}
#[test]
fn first_line_anchor_places_baseline_on_y() {
let run = make("hi");
let mut scene = RecordingScene::default();
draw_rich_text(
&mut scene,
&run,
0.0,
100.0,
RichAnchor::first_line_baseline(),
Affine::IDENTITY,
PickId::Skip,
);
let first = scene
.ops
.iter()
.find_map(|op| match op {
Op::DrawGlyphs(gr) => Some(gr),
_ => None,
})
.unwrap();
let coeffs = first.transform.as_coeffs();
let g = first.glyphs.first().unwrap();
let screen_y = coeffs[5] as f32 + g.y;
assert!(
(screen_y - 100.0).abs() < 0.5,
"first baseline should land at y = 100; got screen y = {screen_y}"
);
}
#[test]
fn breaking_at_the_natural_width_reproduces_the_natural_height() {
let run = make("a paragraph long enough to have an opinion about width\n\nand another");
let natural = run.natural_height();
let broken = run.set_max_width(run.natural_width() as f32, HAlign::Start) as f64;
assert!(
(broken - natural).abs() < 1.0,
"re-breaking at the natural width changed the height ({natural} → {broken})"
);
}
#[test]
fn natural_height_survives_a_narrow_re_break() {
let run = make("a paragraph long enough to wrap when the column gets narrow");
let natural = run.natural_height();
run.set_max_width(natural as f32 / 4.0, HAlign::Start);
assert!(
run.current_height() > natural,
"wrapping should make the run taller"
);
assert_eq!(
run.natural_height(),
natural,
"natural height must not move when the run re-breaks"
);
}
#[test]
fn base_style_font_features_reach_the_rich_shaper() {
let sheet = RichTextStyleSheet::new();
let plain = TextStyle::new(20.0);
let small_caps = plain.clone().features([crate::text::FontFeatureSetting {
tag: *b"smcp",
value: 1,
}]);
let width_of = |style: &TextStyle| {
RichTextRun::new(
"widths",
style,
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
)
.natural_width()
};
assert!(width_of(&plain) > 0.0);
assert!(width_of(&small_caps) > 0.0);
}
#[test]
fn list_markers_draw_in_the_gutter_left_of_the_body() {
let run = make("- alpha");
let blocks = run.blocks.borrow();
let bl = blocks
.iter()
.find(|b| b.marker.is_some())
.expect("the item body carries the marker");
let marker = bl.marker.as_ref().expect("marker");
let (x0, x1) = marker_x_range(bl, marker);
assert!(x1 <= bl.left_px, "marker must sit start-side of the text");
assert!(x0 < x1, "marker must have width");
}
#[test]
fn multi_digit_ordinals_share_a_right_edge() {
let run = make(
"1. one\n2. two\n3. three\n4. four\n5. five\n6. six\n7. seven\n8. eight\n9. nine\n10. ten",
);
let blocks = run.blocks.borrow();
let right_edges: Vec<f32> = blocks
.iter()
.filter_map(|bl| bl.marker.as_ref().map(|m| marker_x_range(bl, m).1))
.collect();
assert!(right_edges.len() >= 10, "expected ten markers");
let first = right_edges[0];
for e in &right_edges {
assert!(
(e - first).abs() < 0.01,
"markers should right-align, got {right_edges:?}"
);
}
}
#[test]
fn a_background_with_no_padding_still_blocks_margin_collapse() {
let mut sheet = RichTextStyleSheet::new();
sheet.set(
"barrier",
StyleDelta {
background: Some(crate::style_vocab::ThemeColor::Accent),
margin: Some(RichMargin::new(pt(20.0), pt(0.0), pt(20.0), pt(0.0))),
..StyleDelta::empty()
},
);
sheet.set(
"plain",
StyleDelta {
margin: Some(RichMargin::new(pt(20.0), pt(0.0), pt(20.0), pt(0.0))),
..StyleDelta::empty()
},
);
let make_with = |src: &str| {
RichTextRun::new(
src,
&base_style(),
Color::from_rgba8(0, 0, 0, 255),
&sheet,
&palette(),
96.0,
)
};
let collapsed = make_with(":::plain\na\n:::\n:::plain\nb\n:::").natural_height();
let separated = make_with(":::barrier\na\n:::\n:::plain\nb\n:::").natural_height();
assert!(
separated > collapsed + 15.0,
"a background must stop the collapse ({collapsed} → {separated})"
);
}