use crate::html_css::css::Color;
#[derive(Debug, Clone)]
pub enum InlineItem {
Text {
text: String,
char_width_px: f32,
line_height_px: f32,
color: Color,
font_size_px: f32,
},
Atom {
width: f32,
height: f32,
baseline: Option<f32>,
},
HardBreak,
}
#[derive(Debug, Clone)]
pub enum LineFragment {
GlyphRun {
text: String,
x: f32,
baseline_y: f32,
char_width_px: f32,
color: Color,
font_size_px: f32,
},
Atom {
x: f32,
y: f32,
width: f32,
height: f32,
},
}
#[derive(Debug, Clone)]
pub struct LineBox {
pub fragments: Vec<LineFragment>,
pub content_width: f32,
pub height: f32,
pub baseline_y: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TextAlign {
#[default]
Left,
Right,
Center,
Justify,
Start,
End,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WhiteSpace {
#[default]
Normal,
Nowrap,
Pre,
PreWrap,
PreLine,
}
pub fn layout_paragraph(
items: &[InlineItem],
available_width_px: f32,
align: TextAlign,
white_space: WhiteSpace,
) -> Vec<LineBox> {
let normalized = normalize_whitespace(items, white_space);
let lines = break_into_lines(&normalized, available_width_px, white_space);
lines
.into_iter()
.map(|raw| align_line(raw, available_width_px, align))
.collect()
}
fn normalize_whitespace(items: &[InlineItem], ws: WhiteSpace) -> Vec<InlineItem> {
items
.iter()
.map(|it| match it {
InlineItem::Text {
text,
char_width_px,
line_height_px,
color,
font_size_px,
} => {
let processed = match ws {
WhiteSpace::Normal | WhiteSpace::Nowrap => collapse_whitespace(text, false),
WhiteSpace::PreLine => collapse_whitespace(text, true),
WhiteSpace::Pre | WhiteSpace::PreWrap => text.clone(),
};
InlineItem::Text {
text: processed,
char_width_px: *char_width_px,
line_height_px: *line_height_px,
color: *color,
font_size_px: *font_size_px,
}
},
other => other.clone(),
})
.collect()
}
fn collapse_whitespace(s: &str, keep_newlines: bool) -> String {
let mut out = String::with_capacity(s.len());
let mut last_was_ws = false;
for c in s.chars() {
if c == '\n' && keep_newlines {
out.push('\n');
last_was_ws = false;
continue;
}
if c.is_whitespace() {
if !last_was_ws {
out.push(' ');
}
last_was_ws = true;
} else {
out.push(c);
last_was_ws = false;
}
}
out
}
#[derive(Debug, Clone, Default)]
struct RawLine {
fragments: Vec<LineFragment>,
consumed_width: f32,
line_height: f32,
baseline_y: f32,
}
fn break_into_lines(items: &[InlineItem], available_width_px: f32, ws: WhiteSpace) -> Vec<RawLine> {
let allow_wrap = !matches!(ws, WhiteSpace::Pre | WhiteSpace::Nowrap);
let mut lines: Vec<RawLine> = vec![RawLine::default()];
for item in items {
match item {
InlineItem::HardBreak => {
lines.push(RawLine::default());
},
InlineItem::Atom {
width,
height,
baseline,
} => {
let baseline = baseline.unwrap_or(*height);
let cur = lines.last_mut().unwrap();
let fits = cur.consumed_width + *width <= available_width_px;
if !fits && allow_wrap && cur.consumed_width > 0.0 {
lines.push(RawLine::default());
}
let cur = lines.last_mut().unwrap();
cur.fragments.push(LineFragment::Atom {
x: cur.consumed_width,
y: 0.0,
width: *width,
height: *height,
});
cur.consumed_width += *width;
cur.line_height = cur.line_height.max(*height);
cur.baseline_y = cur.baseline_y.max(baseline);
},
InlineItem::Text {
text,
char_width_px,
line_height_px,
color,
font_size_px,
} => {
if text.is_empty() {
continue;
}
let segments: Vec<&str> =
if matches!(ws, WhiteSpace::Pre | WhiteSpace::PreWrap | WhiteSpace::PreLine) {
text.split_inclusive('\n').collect()
} else {
vec![text.as_str()]
};
for (seg_idx, seg) in segments.iter().enumerate() {
let mandatory_break_after = seg.ends_with('\n');
let seg_text = if mandatory_break_after {
&seg[..seg.len() - 1]
} else {
seg
};
if seg_text.is_empty() && mandatory_break_after {
lines.push(RawLine::default());
continue;
}
if !allow_wrap {
let cur = lines.last_mut().unwrap();
let w = seg_text.chars().count() as f32 * char_width_px;
cur.fragments.push(LineFragment::GlyphRun {
text: seg_text.to_string(),
x: cur.consumed_width,
baseline_y: 0.0,
char_width_px: *char_width_px,
color: *color,
font_size_px: *font_size_px,
});
cur.consumed_width += w;
cur.line_height = cur.line_height.max(*line_height_px);
cur.baseline_y = cur.baseline_y.max(*line_height_px * 0.8);
} else {
emit_wrapped_text(
seg_text,
*char_width_px,
*line_height_px,
*color,
*font_size_px,
available_width_px,
&mut lines,
);
}
if mandatory_break_after && seg_idx < segments.len() {
lines.push(RawLine::default());
}
}
},
}
}
while lines
.last()
.map(|l| l.fragments.is_empty())
.unwrap_or(false)
&& lines.len() > 1
{
lines.pop();
}
lines
}
fn emit_wrapped_text(
text: &str,
char_width_px: f32,
line_height_px: f32,
color: Color,
font_size_px: f32,
available_width_px: f32,
lines: &mut Vec<RawLine>,
) {
use unicode_linebreak::{linebreaks, BreakOpportunity};
let breaks: Vec<(usize, BreakOpportunity)> = linebreaks(text).collect();
let measure = |slice: &str| slice.chars().count() as f32 * char_width_px;
let mut pending_start: usize = 0;
let mut pending_end: usize = 0;
let mut pending_width: f32 = 0.0;
for &(pos, op) in &breaks {
if pos <= pending_end {
continue;
}
let candidate = &text[pending_end..pos];
let candidate_width = measure(candidate);
let cur_room = {
let cur = lines.last().unwrap();
available_width_px - cur.consumed_width
};
if pending_width + candidate_width <= cur_room {
pending_end = pos;
pending_width += candidate_width;
if matches!(op, BreakOpportunity::Mandatory) && pos < text.len() {
push_run(
lines,
&text[pending_start..pending_end],
char_width_px,
line_height_px,
color,
font_size_px,
);
lines.push(RawLine::default());
pending_start = pending_end;
pending_width = 0.0;
}
} else {
if pending_width > 0.0 {
push_run(
lines,
&text[pending_start..pending_end],
char_width_px,
line_height_px,
color,
font_size_px,
);
}
lines.push(RawLine::default());
pending_start = pending_end;
pending_end = pos;
pending_width = candidate_width;
if matches!(op, BreakOpportunity::Mandatory) && pos < text.len() {
push_run(
lines,
&text[pending_start..pending_end],
char_width_px,
line_height_px,
color,
font_size_px,
);
lines.push(RawLine::default());
pending_start = pending_end;
pending_width = 0.0;
}
}
}
if pending_width > 0.0 {
push_run(
lines,
&text[pending_start..pending_end],
char_width_px,
line_height_px,
color,
font_size_px,
);
}
}
fn push_run(
lines: &mut [RawLine],
text: &str,
char_width_px: f32,
line_height_px: f32,
color: Color,
font_size_px: f32,
) {
if text.is_empty() {
return;
}
let cur = lines.last_mut().unwrap();
let w = text.chars().count() as f32 * char_width_px;
cur.fragments.push(LineFragment::GlyphRun {
text: text.to_string(),
x: cur.consumed_width,
baseline_y: 0.0,
char_width_px,
color,
font_size_px,
});
cur.consumed_width += w;
cur.line_height = cur.line_height.max(line_height_px);
cur.baseline_y = cur.baseline_y.max(line_height_px * 0.8);
}
fn align_line(mut raw: RawLine, available_width_px: f32, align: TextAlign) -> LineBox {
let extra = (available_width_px - raw.consumed_width).max(0.0);
let shift = match align {
TextAlign::Left | TextAlign::Start => 0.0,
TextAlign::Right | TextAlign::End => extra,
TextAlign::Center => extra / 2.0,
TextAlign::Justify => 0.0, };
if shift > 0.0 {
for f in &mut raw.fragments {
shift_fragment(f, shift);
}
}
if matches!(align, TextAlign::Justify) {
let space_count: usize = raw
.fragments
.iter()
.filter_map(|f| match f {
LineFragment::GlyphRun { text, .. } => {
Some(text.chars().filter(|c| *c == ' ').count())
},
_ => None,
})
.sum();
if space_count > 0 && extra > 0.0 {
let per_space = extra / space_count as f32;
let mut x_acc = 0.0_f32;
for f in &mut raw.fragments {
match f {
LineFragment::GlyphRun {
text,
x,
char_width_px,
..
} => {
*x = x_acc;
let mut local = 0.0_f32;
for c in text.chars() {
if c == ' ' {
local += *char_width_px + per_space;
} else {
local += *char_width_px;
}
}
x_acc += local;
},
LineFragment::Atom { x, width, .. } => {
*x = x_acc;
x_acc += *width;
},
}
}
raw.consumed_width = x_acc;
}
} else {
raw.consumed_width += shift;
}
LineBox {
fragments: raw.fragments,
content_width: raw.consumed_width,
height: raw.line_height.max(1.0),
baseline_y: raw.baseline_y,
}
}
fn shift_fragment(f: &mut LineFragment, dx: f32) {
match f {
LineFragment::GlyphRun { x, .. } => *x += dx,
LineFragment::Atom { x, .. } => *x += dx,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::html_css::css::Color;
fn text(s: &str) -> InlineItem {
InlineItem::Text {
text: s.to_string(),
char_width_px: 10.0,
line_height_px: 16.0,
color: Color::BLACK,
font_size_px: 12.0,
}
}
fn count_glyph_runs(line: &LineBox) -> usize {
line.fragments
.iter()
.filter(|f| matches!(f, LineFragment::GlyphRun { .. }))
.count()
}
fn line_text(line: &LineBox) -> String {
line.fragments
.iter()
.filter_map(|f| match f {
LineFragment::GlyphRun { text, .. } => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("")
}
#[test]
fn single_line_fits() {
let lines =
layout_paragraph(&[text("hello world")], 300.0, TextAlign::Left, WhiteSpace::Normal);
assert_eq!(lines.len(), 1);
assert!(line_text(&lines[0]).contains("hello"));
}
#[test]
fn wraps_to_multiple_lines() {
let lines = layout_paragraph(
&[text("the quick brown fox jumps over the lazy dog")],
100.0, TextAlign::Left,
WhiteSpace::Normal,
);
assert!(lines.len() > 1);
}
#[test]
fn whitespace_collapsed_in_normal_mode() {
let lines = layout_paragraph(
&[text(" hello\t\nworld ")],
300.0,
TextAlign::Left,
WhiteSpace::Normal,
);
let s = line_text(&lines[0]);
assert!(s.contains("hello world"));
}
#[test]
fn pre_preserves_newlines() {
let lines = layout_paragraph(
&[text("line one\nline two")],
300.0,
TextAlign::Left,
WhiteSpace::Pre,
);
assert!(lines.len() >= 2);
}
#[test]
fn nowrap_keeps_one_line() {
let lines = layout_paragraph(
&[text("one two three four five six seven eight")],
50.0, TextAlign::Left,
WhiteSpace::Nowrap,
);
assert_eq!(lines.len(), 1);
}
#[test]
fn hard_break_starts_new_line() {
let lines = layout_paragraph(
&[text("before"), InlineItem::HardBreak, text("after")],
300.0,
TextAlign::Left,
WhiteSpace::Normal,
);
assert!(lines.len() >= 2);
assert!(line_text(&lines[0]).contains("before"));
assert!(line_text(&lines[1]).contains("after"));
}
#[test]
fn align_right_shifts_fragments() {
let lines = layout_paragraph(&[text("hi")], 300.0, TextAlign::Right, WhiteSpace::Normal);
let f = &lines[0].fragments[0];
if let LineFragment::GlyphRun { x, .. } = f {
assert!(*x > 250.0, "expected right-aligned x near 280, got {x}");
} else {
panic!()
}
}
#[test]
fn align_center_shifts_to_middle() {
let lines = layout_paragraph(&[text("hi")], 300.0, TextAlign::Center, WhiteSpace::Normal);
let f = &lines[0].fragments[0];
if let LineFragment::GlyphRun { x, .. } = f {
assert!((x - 140.0).abs() < 1.0, "got {x}");
} else {
panic!()
}
}
#[test]
fn justify_distributes_extra_across_spaces() {
let lines =
layout_paragraph(&[text("a b c d")], 300.0, TextAlign::Justify, WhiteSpace::Normal);
assert!((lines[0].content_width - 300.0).abs() < 1.0);
}
#[test]
fn atom_inline_block_in_text_run() {
let items = vec![
text("before "),
InlineItem::Atom {
width: 50.0,
height: 50.0,
baseline: Some(45.0),
},
text(" after"),
];
let lines = layout_paragraph(&items, 400.0, TextAlign::Left, WhiteSpace::Normal);
assert_eq!(lines.len(), 1);
let atoms: Vec<_> = lines[0]
.fragments
.iter()
.filter(|f| matches!(f, LineFragment::Atom { .. }))
.collect();
assert_eq!(atoms.len(), 1);
}
#[test]
fn line_height_takes_max_of_items() {
let mut big = text("BIG");
if let InlineItem::Text { line_height_px, .. } = &mut big {
*line_height_px = 32.0;
}
let lines =
layout_paragraph(&[big, text("small")], 300.0, TextAlign::Left, WhiteSpace::Normal);
assert_eq!(lines[0].height, 32.0);
}
}