Lists: the first line after a bullet or number wraps at the column edge

In a hanging indent (a negative first-line indent), the tab after a
bullet or number went to the next default tab stop, past the left
indent: the first line's text started late and pushed the line past the
column edge.

The left indent is now a left tab stop of its own. A tab before it
reaches it unless an explicit stop comes first, so the first line's
text lines up with the lines below.
This commit is contained in:
aiongg
2026-10-10 04:40:17 +00:00
parent 53a83139d9
commit a33e597399
2 changed files with 69 additions and 8 deletions
+13 -8
View File
@@ -615,7 +615,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let breaks = if pp.composer == Composer::SingleLine || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 {
let tab = |j: usize, x: f64, i: usize| {
let ind = pp.left_indent + if j == 0 { pp.first_line_indent } else { 0.0 };
tab_advance(&pp.tabs, ind + x, gl.get(i + 1..).unwrap_or_default()).0
tab_advance(&pp.tabs, pp.left_indent, ind + x, gl.get(i + 1..).unwrap_or_default()).0
};
breaker::greedy(&gl, &hy, &spacing, &width, &tab)
} else if pp.balance_ragged && !spacing.justify {
@@ -753,7 +753,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let tab = |j: usize, x: f64, i: usize| {
let x0 = slots.get(j).map_or(col.x0, |s| s.0);
let ind = pp.left_indent + if line_no + j == 0 { pp.first_line_indent } else { 0.0 };
tab_advance(&pp.tabs, x0 + ind + x - col.x0, rest.get(i + 1..).unwrap_or_default()).0
tab_advance(&pp.tabs, pp.left_indent, x0 + ind + x - col.x0, rest.get(i + 1..).unwrap_or_default()).0
};
breaker::greedy(rest, rest_h, &spacing, &width, &tab)
} else if pp.balance_ragged && !spacing.justify {
@@ -1541,13 +1541,18 @@ pub fn hj_severity(ratio: f64, min: f64, max: f64) -> u8 {
}
}
/// Advance of a tab that starts `abs` from the tab origin and is followed by `after`, and the tab
/// stop it reaches (none: the next default stop, left aligned). Right, centre and character
/// alignment look at the text up to the next tab or forced break.
fn tab_advance<'a>(tabs: &'a [designcraft_doc::TabStop], abs: f64, after: &[Glyph]) -> (f64, Option<&'a designcraft_doc::TabStop>) {
let stop = tabs.iter().find(|t| t.position > abs + 0.01);
/// Advance of a tab that starts `abs` from the tab origin and is followed by `after`, and the
/// explicit tab stop it reaches (none: the left indent or the next default stop, left aligned).
/// Right, centre and character alignment look at the text up to the next tab or forced break.
///
/// The left indent is a left stop of its own: in a hanging indent, the tab after a bullet or
/// number reaches it unless an explicit stop comes first.
fn tab_advance<'a>(tabs: &'a [designcraft_doc::TabStop], left_indent: f64, abs: f64, after: &[Glyph]) -> (f64, Option<&'a designcraft_doc::TabStop>) {
let indent_ahead = left_indent > abs + 0.01;
let stop = tabs.iter().find(|t| t.position > abs + 0.01).filter(|t| !indent_ahead || t.position <= left_indent);
let (pos, align) = match stop {
Some(t) => (t.position, t.align),
None if indent_ahead => (left_indent, TabAlign::Left),
None => (((abs / DEFAULT_TAB).floor() + 1.0) * DEFAULT_TAB, TabAlign::Left),
};
let seg = after.iter().take_while(|g| g.ch != '\t' && !breaker::is_forced(g.ch));
@@ -1603,7 +1608,7 @@ fn layout_line(
let mut leaders: Vec<(usize, String)> = Vec::new();
while i < line.len() {
if line[i].ch == '\t' {
let (w, stop) = tab_advance(&pp.tabs, x0 + x - tab_origin, line.get(i + 1..).unwrap_or_default());
let (w, stop) = tab_advance(&pp.tabs, pp.left_indent, x0 + x - tab_origin, line.get(i + 1..).unwrap_or_default());
if let Some(t) = stop
&& !t.leader.is_empty()
{
+56
View File
@@ -1917,3 +1917,59 @@ fn vertical_lines_fit_the_em_box() {
assert!((l.baseline - col.y0 - ascent).abs() < 1e-9, "{family}: {} {}", l.baseline, col.y0);
}
}
#[test]
fn list_first_line_wraps_at_the_column_edge_after_its_label() {
use designcraft_doc::{ListType, TabStop};
// A hanging indent (left 18, first line −18): the label sits at the column start, a tab
// after it reaches the left indent (an implicit stop when no explicit stop comes first), and
// the first line wraps at the same right edge as the others.
let right_edge = |l: &Line, source: &str| {
let space = |g: &&PlacedGlyph| g.len > 0 && source.get(g.byte..).and_then(|s| s.chars().next()).is_some_and(char::is_whitespace);
l.glyphs.iter().filter(|g| g.visible && !space(g)).map(|g| g.x + g.adv).fold(0.0, f64::max)
};
let text_start = |l: &Line| l.glyphs.iter().find(|g| g.len > 0 && g.visible).map_or(f64::NAN, |g| g.x);
let stop = |position: f64| TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: String::new() };
let text = format!("{LOREM}\n{LOREM}");
// (list, separator, explicit tab stops, where line 0's text starts past the column start:
// None = right after the label, wherever that is).
let cases = [
(ListType::Bullets, "\t", None, Some(18.0)),
(ListType::Numbers, "\t", None, Some(18.0)),
(ListType::Numbers, "\t", Some(vec![stop(12.0)]), Some(12.0)),
(ListType::Numbers, "\t", Some(vec![stop(30.0)]), Some(18.0)),
(ListType::Bullets, " ", None, None),
(ListType::Numbers, "\u{2003}", None, None),
];
for (list, sep, tabs, first_at) in cases {
let para = ParaAttrs {
list_type: Some(list),
list_separator: Some(sep.into()),
left_indent: Some(18.0),
first_line_indent: Some(-18.0),
tabs: tabs.clone(),
..Default::default()
};
let (d, sid, _) = doc_with(&text, Rect::new(36.0, 36.0, 300.0, 400.0), para);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let col = cs.frames[0].columns[0];
let lines = &cs.frames[0].lines;
let what = format!("{list:?} {sep:?} {tabs:?}");
assert!(lines.len() >= 6, "{what}: {} lines", lines.len());
for (i, l) in lines.iter().enumerate() {
let right = right_edge(l, &text);
assert!(right <= col.x1 + 0.01, "{what}: line {i} ends at {right}, past the column's {}", col.x1);
let start = text_start(l) - col.x0;
if !l.first_in_para {
assert!((start - 18.0).abs() < 0.01, "{what}: line {i} text starts at {start}, not the left indent");
} else if let Some(at) = first_at {
assert!((start - at).abs() < 0.01, "{what}: line {i} text starts at {start}, not {at}");
} else {
assert!(start > 0.0, "{what}: line {i} text starts at {start}");
}
if l.first_in_para {
assert!(l.glyphs.first().is_some_and(|g| g.len == 0 && (g.x - col.x0).abs() < 0.01), "{what}: the label leads line {i}");
}
}
}
}