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