Skip to main content

mos_layout/
list.rs

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    /// Lay out a [`NodeKind::List`] and its [`NodeKind::ListItem`]
9    /// children with hanging indent.
10    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}