use crate::model::{FontSpec, Paragraph, StyledRun};
use crate::shape::LineShaper;
use super::paragraph::layout_paragraph;
#[derive(Debug, Clone, PartialEq)]
pub struct GlyphLayout {
pub cluster: String,
pub run_index: usize,
pub line_index: usize,
pub x: f64,
pub y: f64,
pub advance: f64,
pub width: f64,
pub font: FontSpec,
pub color: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LineBox {
pub line_index: usize,
pub y_top: f64,
pub baseline_y: f64,
pub height: f64,
pub content_width: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PlacedInlineBox {
pub id: u64,
pub line_index: usize,
pub x: f64,
pub y_top: f64,
pub width: f64,
pub height: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecorationKind {
Underline,
Strikethrough,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DecorationSpan {
pub run_index: usize,
pub line_index: usize,
pub kind: DecorationKind,
pub x_start: f64,
pub x_end: f64,
pub y: f64,
pub thickness: f64,
pub color: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ParagraphLayout {
pub glyphs: Vec<GlyphLayout>,
pub lines: Vec<LineBox>,
pub boxes: Vec<PlacedInlineBox>,
pub decorations: Vec<DecorationSpan>,
pub width: f64,
pub height: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Align {
#[default]
Left,
Center,
Right,
}
pub fn layout_text(text: &str, font: &FontSpec, max_width: f64, shaper: &dyn LineShaper) -> ParagraphLayout {
let runs = [StyledRun::new(text, *font)];
let paragraph = Paragraph::new(&runs, max_width);
layout_paragraph(¶graph, shaper)
}
pub fn align_lines(layout: &mut ParagraphLayout, box_width: f64, align: Align) {
if align == Align::Left || !box_width.is_finite() {
return;
}
for line in &layout.lines {
let shift = match align {
Align::Left => 0.0,
Align::Center => (box_width - line.content_width) / 2.0,
Align::Right => box_width - line.content_width,
};
if shift == 0.0 {
continue;
}
for glyph in layout.glyphs.iter_mut().filter(|g| g.line_index == line.line_index) {
glyph.x += shift;
}
for b in layout.boxes.iter_mut().filter(|b| b.line_index == line.line_index) {
b.x += shift;
}
for d in layout.decorations.iter_mut().filter(|d| d.line_index == line.line_index) {
d.x_start += shift;
d.x_end += shift;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shape::CosmicShaper;
use uzor::fonts::FontFamily;
const LONG_SENTENCE: &str = "The quick brown fox jumps over the lazy dog \
and then keeps running further down the road without stopping for a \
very long time indeed";
#[test]
fn layout_text_unwrapped_matches_measure_glyphs() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let text = "Hello, uzor-text!";
let layout = layout_text(text, &font, f64::MAX, &shaper);
assert_eq!(layout.lines.len(), 1, "expected exactly one line at f64::MAX width");
let glyphs = uzor::shaper::measure_glyphs(text, &font.to_css_font());
let expected_extent = glyphs.iter().map(|g| g.x_offset + g.advance).fold(0.0_f64, f64::max);
assert!((layout.width - expected_extent).abs() < 0.01);
assert_eq!(layout.glyphs.len(), glyphs.len());
for (a, b) in layout.glyphs.iter().zip(glyphs.iter()) {
assert_eq!(a.cluster, b.cluster);
assert!((a.x - b.x_offset).abs() < 0.01);
assert!((a.advance - b.advance).abs() < 0.01);
assert!((a.width - b.width).abs() < 0.01);
}
}
#[test]
fn wrap_produces_multiple_lines_within_width_with_consistent_spacing() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let max_width = 150.0;
let layout = layout_text(LONG_SENTENCE, &font, max_width, &shaper);
assert!(layout.lines.len() > 1, "expected wrap into multiple lines, got {}", layout.lines.len());
let mut prev_baseline = f64::MIN;
for line in &layout.lines {
assert!(
line.content_width <= max_width + 1.0,
"line {} width {} exceeds max_width {max_width}",
line.line_index,
line.content_width
);
assert!(line.baseline_y > prev_baseline, "baselines must strictly increase");
prev_baseline = line.baseline_y;
}
let deltas: Vec<f64> = layout
.lines
.windows(2)
.map(|w| w[1].baseline_y - w[0].baseline_y)
.collect();
for d in &deltas {
assert!((d - deltas[0]).abs() < 0.5, "line spacing must be consistent, got deltas {deltas:?}");
}
}
#[test]
fn empty_text_produces_empty_layout() {
let font = FontSpec::default();
let shaper = CosmicShaper::headless();
let layout = layout_text("", &font, 100.0, &shaper);
assert!(layout.glyphs.is_empty());
assert!(layout.lines.is_empty());
assert_eq!(layout.width, 0.0);
assert_eq!(layout.height, 0.0);
}
#[test]
fn whitespace_only_text_does_not_panic() {
let font = FontSpec::default();
let shaper = CosmicShaper::headless();
let layout = layout_text(" ", &font, 100.0, &shaper);
assert!(layout.lines.len() <= 1);
}
#[test]
fn align_center_shifts_each_line_by_half_the_remaining_width() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let max_width = 220.0;
let text = "one two three four five six seven eight nine ten eleven twelve";
let base = layout_text(text, &font, max_width, &shaper);
assert!(base.lines.len() > 1, "fixture must wrap to multiple lines");
let mut centered = base.clone();
align_lines(&mut centered, max_width, Align::Center);
for line in &base.lines {
let expected_shift = (max_width - line.content_width) / 2.0;
let base_xs: Vec<f64> =
base.glyphs.iter().filter(|g| g.line_index == line.line_index).map(|g| g.x).collect();
let centered_xs: Vec<f64> =
centered.glyphs.iter().filter(|g| g.line_index == line.line_index).map(|g| g.x).collect();
assert_eq!(base_xs.len(), centered_xs.len());
for (b, c) in base_xs.iter().zip(centered_xs.iter()) {
assert!((c - (b + expected_shift)).abs() < 1e-6);
}
}
}
#[test]
fn align_right_shifts_each_line_to_the_far_edge() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let max_width = 220.0;
let text = "one two three four five six seven eight nine ten eleven twelve";
let base = layout_text(text, &font, max_width, &shaper);
assert!(base.lines.len() > 1, "fixture must wrap to multiple lines");
let mut right = base.clone();
align_lines(&mut right, max_width, Align::Right);
for line in &right.lines {
let expected_shift = max_width - line.content_width;
let last_glyph = right
.glyphs
.iter()
.filter(|g| g.line_index == line.line_index)
.last();
if let Some(g) = last_glyph {
assert!(
(g.x + g.advance - max_width).abs() < 0.5,
"right-aligned line {} should reach max_width {max_width}, got {}",
line.line_index,
g.x + g.advance
);
}
assert!(expected_shift.is_finite());
}
}
#[test]
fn align_lines_shifts_decoration_spans_by_the_same_amount_as_glyphs() {
use crate::model::TextDecoration;
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let max_width = 220.0;
let runs = [StyledRun::new("short", font).with_decoration(TextDecoration::underline())];
let paragraph = Paragraph::new(&runs, max_width);
let shaper = CosmicShaper::headless();
let base = layout_paragraph(¶graph, &shaper);
assert_eq!(base.decorations.len(), 1);
let mut centered = base.clone();
align_lines(&mut centered, max_width, Align::Center);
let expected_shift = (max_width - base.lines[0].content_width) / 2.0;
assert!((centered.decorations[0].x_start - (base.decorations[0].x_start + expected_shift)).abs() < 1e-6);
assert!((centered.decorations[0].x_end - (base.decorations[0].x_end + expected_shift)).abs() < 1e-6);
assert_eq!(centered.decorations[0].y, base.decorations[0].y);
assert_eq!(centered.decorations[0].thickness, base.decorations[0].thickness);
}
}