1use mos_core::{AttrValue, Document, Node, NodeKind};
2use mos_fonts::{Font, shape_with_fallback, text_width};
3
4use crate::word::Word;
5use crate::{LIST_MARKER_GUTTER_PT, LayoutState, PARA_SPACE_AFTER_PT, PendingMarker};
6
7impl LayoutState {
8 pub(super) fn layout_list(&mut self, document: &Document, list_node: &Node) {
11 let ordered = matches!(
12 list_node.attributes.get("ordered"),
13 Some(AttrValue::Bool(true))
14 );
15 let regular = self.text.family.regular;
16 let size = self.text.size_pt;
17 let leading = self.text.leading;
18 let saved_left = self.current_left_pt;
19 let widest_marker_pt = if ordered {
20 list_node
21 .children
22 .iter()
23 .filter_map(|id| document.get(*id))
24 .filter(|n| n.kind == NodeKind::ListItem)
25 .enumerate()
26 .map(|(idx, _)| {
27 shape_with_fallback(
28 regular,
29 self.text.family.fallbacks,
30 size,
31 &format!("{}.", idx + 1),
32 )
33 .iter()
34 .map(|s| s.advance_pt)
35 .sum()
36 })
37 .fold(0.0_f32, f32::max)
38 } else {
39 shape_with_fallback(regular, self.text.family.fallbacks, size, "\u{2022}")
40 .iter()
41 .map(|s| s.advance_pt)
42 .sum()
43 };
44 let marker_gap_pt = text_width(regular, size, " ");
45 let gutter = (widest_marker_pt + marker_gap_pt).max(LIST_MARKER_GUTTER_PT);
46 let item_left = saved_left + gutter;
47
48 let mut item_idx = 0_usize;
49 for item_id in &list_node.children {
50 let Some(item) = document.get(*item_id) else {
51 continue;
52 };
53 if item.kind != NodeKind::ListItem {
54 continue;
55 }
56 item_idx += 1;
57
58 let marker_text = if ordered {
59 format!("{item_idx}.")
60 } else {
61 "\u{2022}".to_owned()
62 };
63 let subruns =
64 shape_with_fallback(regular, self.text.family.fallbacks, size, &marker_text);
65 let width_pt: f32 = subruns.iter().map(|s| s.advance_pt).sum();
66 let marker_word = Word {
67 text: marker_text,
68 actual_text: None,
69 space_before_pt: 0.0,
70 font: regular,
71 size_pt: size,
72 width_pt,
73 subruns,
74 shy_break_offsets: Vec::new(),
75 };
76 let marker_x = item_left - marker_gap_pt - marker_word.width_pt;
77
78 self.current_left_pt = item_left;
79 self.pending_marker = Some(PendingMarker {
80 x_pt: marker_x,
81 word: marker_word,
82 });
83
84 let has_block_children = item
85 .children
86 .iter()
87 .filter_map(|id| document.get(*id))
88 .any(|child| matches!(child.kind, NodeKind::Paragraph));
89 if has_block_children {
90 self.layout_list_item_blocks(document, item, regular, size, leading);
91 } else {
92 let words = self.collect_words(document, item, regular, size);
93 if words.is_empty() {
94 self.flush_line(&[], leading);
95 } else {
96 self.flow_words(&words, leading);
97 self.flush_marker_only_line_if_pending(leading);
98 }
99
100 for child_id in &item.children {
101 let Some(child) = document.get(*child_id) else {
102 continue;
103 };
104 if child.kind == NodeKind::List {
105 self.layout_list(document, child);
106 }
107 }
108 }
109 }
110
111 self.current_left_pt = saved_left;
112 if (saved_left - self.page.margin).abs() < f32::EPSILON {
113 self.cursor_y += PARA_SPACE_AFTER_PT;
114 }
115 }
116
117 fn layout_list_item_blocks(
118 &mut self,
119 document: &Document,
120 item: &Node,
121 regular: Font,
122 size: f32,
123 leading: f32,
124 ) {
125 let mut saw_block = false;
126 for child_id in &item.children {
127 let Some(child) = document.get(*child_id) else {
128 continue;
129 };
130 match child.kind {
131 NodeKind::Paragraph => {
132 let words = self.collect_words(document, child, regular, size);
133 if words.is_empty() {
134 if self.pending_marker.is_some() {
135 self.flush_line(&[], leading);
136 }
137 } else {
138 self.flow_words(&words, leading);
139 self.flush_marker_only_line_if_pending(leading);
140 }
141 saw_block = true;
142 }
143 NodeKind::List => {
144 if self.pending_marker.is_some() {
145 self.flush_line(&[], leading);
146 }
147 self.layout_list(document, child);
148 saw_block = true;
149 }
150 _ => {}
151 }
152 }
153 if !saw_block && self.pending_marker.is_some() {
154 self.flush_line(&[], leading);
155 }
156 }
157
158 fn flush_marker_only_line_if_pending(&mut self, leading: f32) {
159 if self.pending_marker.is_some() {
160 self.flush_line(&[], leading);
161 }
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 #![allow(
168 clippy::unwrap_used,
169 clippy::expect_used,
170 reason = "tests panic loudly on setup failure; matches crate-wide test-module convention"
171 )]
172
173 use std::fmt::Write as _;
174 use std::path::PathBuf;
175
176 use mos_core::{AttrMap, NodeId, NodeSpec, SourceSpan};
177
178 use crate::{A4_WIDTH_PT, LayoutEngine, MARGIN_PT, TextRun};
179
180 use super::*;
181
182 fn alloc_inline(doc: &mut Document, parent: NodeId, kind: NodeKind, text: &str) {
183 let mut attrs = AttrMap::new();
184 attrs.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
185 doc.alloc_child(parent, node(kind, attrs));
186 }
187
188 fn node(kind: NodeKind, attributes: AttrMap) -> NodeSpec {
189 NodeSpec::new(kind, SourceSpan::placeholder(PathBuf::from("test.mos")))
190 .with_attributes(attributes)
191 }
192
193 fn pin_helvetica(doc: &mut Document) {
194 let mut attrs = AttrMap::new();
195 attrs.insert("set".to_owned(), AttrValue::Str("text".to_owned()));
196 attrs.insert(
197 "set.arg.font".to_owned(),
198 AttrValue::Str("Helvetica".to_owned()),
199 );
200 doc.alloc_child(doc.root, node(NodeKind::Raw, attrs));
201 }
202
203 fn alloc_list(doc: &mut Document, parent: NodeId, ordered: bool) -> NodeId {
204 let mut attrs = AttrMap::new();
205 attrs.insert("ordered".to_owned(), AttrValue::Bool(ordered));
206 doc.alloc_child(parent, node(NodeKind::List, attrs))
207 }
208
209 fn alloc_list_item(doc: &mut Document, parent: NodeId, text: &str) -> NodeId {
210 let id = doc.alloc_child(parent, node(NodeKind::ListItem, AttrMap::new()));
211 alloc_inline(doc, id, NodeKind::Text, text);
212 id
213 }
214
215 fn alloc_list_item_paragraph(doc: &mut Document, parent: NodeId, text: &str) -> NodeId {
216 let item = doc.alloc_child(parent, node(NodeKind::ListItem, AttrMap::new()));
217 let paragraph = doc.alloc_child(item, node(NodeKind::Paragraph, AttrMap::new()));
218 alloc_inline(doc, paragraph, NodeKind::Text, text);
219 item
220 }
221
222 fn alloc_paragraph(doc: &mut Document, parent: NodeId, text: &str) -> NodeId {
223 let paragraph = doc.alloc_child(parent, node(NodeKind::Paragraph, AttrMap::new()));
224 alloc_inline(doc, paragraph, NodeKind::Text, text);
225 paragraph
226 }
227
228 fn alloc_hard_break(doc: &mut Document, parent: NodeId) {
229 doc.alloc_child(parent, node(NodeKind::HardBreak, AttrMap::new()));
230 }
231
232 #[test]
233 fn unordered_list_emits_bullet_markers() {
234 let mut doc = Document::new(PathBuf::from("test.mos"));
235 pin_helvetica(&mut doc);
236 let root = doc.root;
237 let list = alloc_list(&mut doc, root, false);
238 alloc_list_item(&mut doc, list, "alpha");
239 alloc_list_item(&mut doc, list, "beta");
240
241 let result = LayoutEngine::new().layout(&doc);
242
243 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
244 let runs = &result.graph.pages[0].runs;
245 let bullets: Vec<&TextRun> = runs.iter().filter(|r| r.text == "\u{2022}").collect();
246 assert_eq!(bullets.len(), 2, "expected 2 bullets, got runs {runs:?}");
247 for bullet in &bullets {
248 let bullet_w = text_width(bullet.font, bullet.size_pt, &bullet.text);
249 assert!(bullet.x_pt >= MARGIN_PT - 0.5);
250 assert!(bullet.x_pt + bullet_w <= MARGIN_PT + LIST_MARKER_GUTTER_PT + 0.5);
251 }
252 let alpha = runs.iter().find(|r| r.text == "alpha").expect("alpha run");
253 assert!(alpha.x_pt > bullets[0].x_pt);
254 }
255
256 #[test]
257 fn ordered_list_numbers_from_one() {
258 let mut doc = Document::new(PathBuf::from("test.mos"));
259 pin_helvetica(&mut doc);
260 let root = doc.root;
261 let list = alloc_list(&mut doc, root, true);
262 alloc_list_item(&mut doc, list, "first");
263 alloc_list_item(&mut doc, list, "second");
264 alloc_list_item(&mut doc, list, "third");
265
266 let result = LayoutEngine::new().layout(&doc);
267
268 let markers: Vec<&str> = result.graph.pages[0]
269 .runs
270 .iter()
271 .filter(|r| {
272 r.text.ends_with('.') && r.text.chars().next().is_some_and(|c| c.is_ascii_digit())
273 })
274 .map(|r| r.text.as_str())
275 .collect();
276 assert_eq!(markers, vec!["1.", "2.", "3."]);
277 }
278
279 #[test]
280 fn list_item_text_indented_past_marker_gutter() {
281 let mut doc = Document::new(PathBuf::from("test.mos"));
282 pin_helvetica(&mut doc);
283 let root = doc.root;
284 let list = alloc_list(&mut doc, root, false);
285 alloc_list_item(&mut doc, list, "hello");
286
287 let result = LayoutEngine::new().layout(&doc);
288
289 let hello = result.graph.pages[0]
290 .runs
291 .iter()
292 .find(|r| r.text == "hello")
293 .expect("hello run");
294 let expected = MARGIN_PT + LIST_MARKER_GUTTER_PT;
295 assert!((hello.x_pt - expected).abs() < 0.5);
296 }
297
298 #[test]
299 fn nested_list_indents_one_more_level() {
300 let mut doc = Document::new(PathBuf::from("test.mos"));
301 pin_helvetica(&mut doc);
302 let root = doc.root;
303 let outer = alloc_list(&mut doc, root, false);
304 let outer_item = alloc_list_item(&mut doc, outer, "outer");
305 let inner = alloc_list(&mut doc, outer_item, false);
306 alloc_list_item(&mut doc, inner, "inner");
307
308 let result = LayoutEngine::new().layout(&doc);
309
310 let runs = &result.graph.pages[0].runs;
311 let bullets: Vec<&TextRun> = runs.iter().filter(|r| r.text == "\u{2022}").collect();
312 assert_eq!(bullets.len(), 2);
313 let inner = runs.iter().find(|r| r.text == "inner").unwrap();
314 assert!((inner.x_pt - 2.0f32.mul_add(LIST_MARKER_GUTTER_PT, MARGIN_PT)).abs() < 0.5);
315 assert!(bullets[1].x_pt > bullets[0].x_pt);
316 }
317
318 #[test]
319 fn long_ordered_list_widens_gutter_so_markers_dont_overlap_text() {
320 let mut doc = Document::new(PathBuf::from("test.mos"));
321 pin_helvetica(&mut doc);
322 let root = doc.root;
323 let list = alloc_list(&mut doc, root, true);
324 for i in 0..100 {
325 alloc_list_item(&mut doc, list, &format!("item{i}"));
326 }
327
328 let result = LayoutEngine::new().layout(&doc);
329
330 let all_runs: Vec<&TextRun> = result
331 .graph
332 .pages
333 .iter()
334 .flat_map(|p| p.runs.iter())
335 .collect();
336 let marker_100 = all_runs
337 .iter()
338 .find(|r| r.text == "100.")
339 .expect("`100.` marker emitted");
340 let text_99 = all_runs
341 .iter()
342 .find(|r| r.text == "item99")
343 .expect("`item99` text emitted");
344 let marker_right =
345 marker_100.x_pt + text_width(marker_100.font, marker_100.size_pt, &marker_100.text);
346 assert!(marker_right <= text_99.x_pt + 0.01);
347 }
348
349 #[test]
350 fn marker_only_item_still_emits_marker() {
351 let mut doc = Document::new(PathBuf::from("test.mos"));
352 pin_helvetica(&mut doc);
353 let root = doc.root;
354 let list = alloc_list(&mut doc, root, false);
355 doc.alloc_child(list, node(NodeKind::ListItem, AttrMap::new()));
356 alloc_list_item(&mut doc, list, "second");
357
358 let result = LayoutEngine::new().layout(&doc);
359
360 let bullets: Vec<&TextRun> = result.graph.pages[0]
361 .runs
362 .iter()
363 .filter(|r| r.text == "\u{2022}")
364 .collect();
365 assert_eq!(bullets.len(), 2);
366 assert!(bullets[0].baseline_from_top_pt < bullets[1].baseline_from_top_pt);
367 }
368
369 #[test]
370 fn hard_break_only_list_paragraph_does_not_leak_or_drop_marker() {
371 let mut doc = Document::new(PathBuf::from("test.mos"));
372 pin_helvetica(&mut doc);
373 let root = doc.root;
374 let list = alloc_list(&mut doc, root, false);
375
376 let first_item = doc.alloc_child(list, node(NodeKind::ListItem, AttrMap::new()));
377 let first_blank = doc.alloc_child(first_item, node(NodeKind::Paragraph, AttrMap::new()));
378 alloc_hard_break(&mut doc, first_blank);
379 alloc_hard_break(&mut doc, first_blank);
380 let first_body = doc.alloc_child(first_item, node(NodeKind::Paragraph, AttrMap::new()));
381 alloc_inline(&mut doc, first_body, NodeKind::Text, "body");
382
383 let second_item = doc.alloc_child(list, node(NodeKind::ListItem, AttrMap::new()));
384 let second_blank = doc.alloc_child(second_item, node(NodeKind::Paragraph, AttrMap::new()));
385 alloc_hard_break(&mut doc, second_blank);
386
387 let result = LayoutEngine::new().layout(&doc);
388
389 let runs = &result.graph.pages[0].runs;
390 let bullets: Vec<&TextRun> = runs.iter().filter(|r| r.text == "\u{2022}").collect();
391 assert_eq!(bullets.len(), 2, "got {runs:?}");
392 let body = runs.iter().find(|r| r.text == "body").expect("body run");
393 assert!(bullets[0].baseline_from_top_pt < body.baseline_from_top_pt);
394 assert!(bullets[1].baseline_from_top_pt > body.baseline_from_top_pt);
395 for bullet in bullets {
396 assert!(
397 (bullet.baseline_from_top_pt - body.baseline_from_top_pt).abs() > 0.01,
398 "marker leaked onto body line: {runs:?}"
399 );
400 }
401 }
402
403 #[test]
404 fn item_with_only_nested_child_keeps_its_marker() {
405 let mut doc = Document::new(PathBuf::from("test.mos"));
406 pin_helvetica(&mut doc);
407 let root = doc.root;
408 let outer = alloc_list(&mut doc, root, false);
409 let outer_item = doc.alloc_child(outer, node(NodeKind::ListItem, AttrMap::new()));
410 let inner = alloc_list(&mut doc, outer_item, false);
411 alloc_list_item(&mut doc, inner, "deep");
412
413 let result = LayoutEngine::new().layout(&doc);
414
415 let bullets: Vec<&TextRun> = result.graph.pages[0]
416 .runs
417 .iter()
418 .filter(|r| r.text == "\u{2022}")
419 .collect();
420 assert_eq!(bullets.len(), 2);
421 assert!(bullets[1].x_pt > bullets[0].x_pt);
422 assert!(bullets[1].baseline_from_top_pt > bullets[0].baseline_from_top_pt);
423 }
424
425 #[test]
426 fn list_marker_baseline_matches_first_line_of_item() {
427 let mut doc = Document::new(PathBuf::from("test.mos"));
428 pin_helvetica(&mut doc);
429 let root = doc.root;
430 let list = alloc_list(&mut doc, root, false);
431 alloc_list_item(&mut doc, list, "one");
432
433 let result = LayoutEngine::new().layout(&doc);
434
435 let runs = &result.graph.pages[0].runs;
436 let bullet = runs.iter().find(|r| r.text == "\u{2022}").unwrap();
437 let one = runs.iter().find(|r| r.text == "one").unwrap();
438 assert!((bullet.baseline_from_top_pt - one.baseline_from_top_pt).abs() < 1e-3);
439 }
440
441 #[test]
442 fn list_text_wraps_within_indented_column() {
443 let mut doc = Document::new(PathBuf::from("test.mos"));
444 pin_helvetica(&mut doc);
445 let root = doc.root;
446 let list = alloc_list(&mut doc, root, false);
447 let long = (0..40).fold(String::new(), |mut long, i| {
448 let _ = write!(long, "word{i} ");
449 long
450 });
451 alloc_list_item(&mut doc, list, long.trim());
452
453 let result = LayoutEngine::new().layout(&doc);
454
455 let text_left = MARGIN_PT + LIST_MARKER_GUTTER_PT;
456 let text_right = A4_WIDTH_PT - MARGIN_PT;
457 for run in result.graph.pages[0]
458 .runs
459 .iter()
460 .filter(|r| r.text != "\u{2022}")
461 {
462 assert!(run.x_pt >= text_left - 0.5);
463 let end = run.x_pt + text_width(run.font, run.size_pt, &run.text);
464 assert!(end <= text_right + 1e-3);
465 }
466 }
467
468 #[test]
469 fn list_continuation_text_uses_soft_whitespace() {
470 let mut doc = Document::new(PathBuf::from("test.mos"));
471 pin_helvetica(&mut doc);
472 let root = doc.root;
473 let list = alloc_list(&mut doc, root, false);
474 alloc_list_item_paragraph(&mut doc, list, "first\n second");
475
476 let result = LayoutEngine::new().layout(&doc);
477
478 let runs = &result.graph.pages[0].runs;
479 let first = runs.iter().find(|r| r.text == "first").expect("first run");
480 let second = runs
481 .iter()
482 .find(|r| r.text == "second")
483 .expect("second run");
484 assert!((first.baseline_from_top_pt - second.baseline_from_top_pt).abs() < 1e-3);
485 assert!(second.x_pt > first.x_pt);
486 }
487
488 #[test]
489 fn direct_and_block_paragraph_list_items_have_same_layout() {
490 fn positions(block_paragraph: bool) -> (f32, f32, f32, f32) {
491 let mut doc = Document::new(PathBuf::from("test.mos"));
492 pin_helvetica(&mut doc);
493 let root = doc.root;
494 let list = alloc_list(&mut doc, root, false);
495 if block_paragraph {
496 alloc_list_item_paragraph(&mut doc, list, "alpha");
497 } else {
498 alloc_list_item(&mut doc, list, "alpha");
499 }
500
501 let result = LayoutEngine::new().layout(&doc);
502 let runs = &result.graph.pages[0].runs;
503 let bullet = runs.iter().find(|r| r.text == "\u{2022}").expect("bullet");
504 let text = runs.iter().find(|r| r.text == "alpha").expect("alpha");
505 (
506 bullet.x_pt,
507 bullet.baseline_from_top_pt,
508 text.x_pt,
509 text.baseline_from_top_pt,
510 )
511 }
512
513 let direct = positions(false);
514 let block = positions(true);
515 assert!((direct.0 - block.0).abs() < 0.01);
516 assert!((direct.1 - block.1).abs() < 0.01);
517 assert!((direct.2 - block.2).abs() < 0.01);
518 assert!((direct.3 - block.3).abs() < 0.01);
519 }
520
521 #[test]
522 fn list_hard_break_hangs_under_item_text() {
523 let mut doc = Document::new(PathBuf::from("test.mos"));
524 pin_helvetica(&mut doc);
525 let root = doc.root;
526 let list = alloc_list(&mut doc, root, false);
527 let item = doc.alloc_child(list, node(NodeKind::ListItem, AttrMap::new()));
528 let paragraph = doc.alloc_child(item, node(NodeKind::Paragraph, AttrMap::new()));
529 alloc_inline(&mut doc, paragraph, NodeKind::Text, "first");
530 alloc_hard_break(&mut doc, paragraph);
531 alloc_inline(&mut doc, paragraph, NodeKind::Text, "second");
532
533 let result = LayoutEngine::new().layout(&doc);
534
535 let runs = &result.graph.pages[0].runs;
536 let first = runs.iter().find(|r| r.text == "first").expect("first run");
537 let second = runs
538 .iter()
539 .find(|r| r.text == "second")
540 .expect("second run");
541 let expected = MARGIN_PT + LIST_MARKER_GUTTER_PT;
542 assert!((first.x_pt - expected).abs() < 0.5);
543 assert!((second.x_pt - expected).abs() < 0.5);
544 assert!(second.baseline_from_top_pt > first.baseline_from_top_pt);
545 }
546
547 #[test]
548 fn parent_tail_after_nested_list_returns_to_parent_column() {
549 let mut doc = Document::new(PathBuf::from("test.mos"));
550 pin_helvetica(&mut doc);
551 let root = doc.root;
552 let outer = alloc_list(&mut doc, root, false);
553 let item = doc.alloc_child(outer, node(NodeKind::ListItem, AttrMap::new()));
554 alloc_paragraph(&mut doc, item, "parent");
555 let inner = alloc_list(&mut doc, item, false);
556 alloc_list_item_paragraph(&mut doc, inner, "child");
557 alloc_paragraph(&mut doc, item, "tail");
558
559 let result = LayoutEngine::new().layout(&doc);
560
561 let runs = &result.graph.pages[0].runs;
562 let parent = runs
563 .iter()
564 .find(|r| r.text == "parent")
565 .expect("parent run");
566 let child = runs.iter().find(|r| r.text == "child").expect("child run");
567 let tail = runs.iter().find(|r| r.text == "tail").expect("tail run");
568 let parent_left = MARGIN_PT + LIST_MARKER_GUTTER_PT;
569 let child_left = 2.0f32.mul_add(LIST_MARKER_GUTTER_PT, MARGIN_PT);
570 assert!((parent.x_pt - parent_left).abs() < 0.5);
571 assert!((tail.x_pt - parent_left).abs() < 0.5);
572 assert!((child.x_pt - child_left).abs() < 0.5);
573 assert!(tail.baseline_from_top_pt > child.baseline_from_top_pt);
574 }
575}