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            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}