Skip to main content

mos_layout/
list.rs

1use mos_core::{AttrValue, Document, Node, NodeKind};
2use mos_fonts::{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                font: regular,
70                size_pt: size,
71                width_pt,
72                subruns,
73                shy_break_offsets: Vec::new(),
74            };
75            let marker_x = item_left - marker_gap_pt - marker_word.width_pt;
76
77            self.current_left_pt = item_left;
78            self.pending_marker = Some(PendingMarker {
79                x_pt: marker_x,
80                word: marker_word,
81            });
82
83            let words = self.collect_words(document, item, regular, size);
84            if words.is_empty() {
85                self.flush_line(&[], leading);
86            } else {
87                self.flow_words(&words, leading);
88            }
89
90            for child_id in &item.children {
91                let Some(child) = document.get(*child_id) else {
92                    continue;
93                };
94                if child.kind == NodeKind::List {
95                    self.layout_list(document, child);
96                }
97            }
98        }
99
100        self.current_left_pt = saved_left;
101        if (saved_left - self.page.margin_pt).abs() < f32::EPSILON {
102            self.cursor_y += PARA_SPACE_AFTER_PT;
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    #![allow(
110        clippy::unwrap_used,
111        clippy::expect_used,
112        reason = "tests panic loudly on setup failure; matches crate-wide test-module convention"
113    )]
114
115    use std::path::PathBuf;
116
117    use mos_core::{AttrMap, NodeId, NodeSpec, SourceSpan};
118
119    use crate::{A4_WIDTH_PT, LayoutEngine, MARGIN_PT, TextRun};
120
121    use super::*;
122
123    fn alloc_inline(doc: &mut Document, parent: NodeId, kind: NodeKind, text: &str) {
124        let mut attrs = AttrMap::new();
125        attrs.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
126        doc.alloc_child(parent, node(kind, attrs));
127    }
128
129    fn node(kind: NodeKind, attributes: AttrMap) -> NodeSpec {
130        NodeSpec::new(kind, SourceSpan::placeholder(PathBuf::from("test.mos")))
131            .with_attributes(attributes)
132    }
133
134    fn pin_helvetica(doc: &mut Document) {
135        let mut attrs = AttrMap::new();
136        attrs.insert("set".to_owned(), AttrValue::Str("text".to_owned()));
137        attrs.insert(
138            "set.arg.font".to_owned(),
139            AttrValue::Str("Helvetica".to_owned()),
140        );
141        doc.alloc_child(doc.root, node(NodeKind::Raw, attrs));
142    }
143
144    fn alloc_list(doc: &mut Document, parent: NodeId, ordered: bool) -> NodeId {
145        let mut attrs = AttrMap::new();
146        attrs.insert("ordered".to_owned(), AttrValue::Bool(ordered));
147        doc.alloc_child(parent, node(NodeKind::List, attrs))
148    }
149
150    fn alloc_list_item(doc: &mut Document, parent: NodeId, text: &str) -> NodeId {
151        let id = doc.alloc_child(parent, node(NodeKind::ListItem, AttrMap::new()));
152        alloc_inline(doc, id, NodeKind::Text, text);
153        id
154    }
155
156    #[test]
157    fn unordered_list_emits_bullet_markers() {
158        let mut doc = Document::new(PathBuf::from("test.mos"));
159        pin_helvetica(&mut doc);
160        let root = doc.root;
161        let list = alloc_list(&mut doc, root, false);
162        alloc_list_item(&mut doc, list, "alpha");
163        alloc_list_item(&mut doc, list, "beta");
164
165        let result = LayoutEngine::new().layout(&doc);
166
167        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
168        let runs = &result.graph.pages[0].runs;
169        let bullets: Vec<&TextRun> = runs.iter().filter(|r| r.text == "\u{2022}").collect();
170        assert_eq!(bullets.len(), 2, "expected 2 bullets, got runs {runs:?}");
171        for bullet in &bullets {
172            let bullet_w = text_width(bullet.font, bullet.size_pt, &bullet.text);
173            assert!(bullet.x_pt >= MARGIN_PT - 0.5);
174            assert!(bullet.x_pt + bullet_w <= MARGIN_PT + LIST_MARKER_GUTTER_PT + 0.5);
175        }
176        let alpha = runs.iter().find(|r| r.text == "alpha").expect("alpha run");
177        assert!(alpha.x_pt > bullets[0].x_pt);
178    }
179
180    #[test]
181    fn ordered_list_numbers_from_one() {
182        let mut doc = Document::new(PathBuf::from("test.mos"));
183        pin_helvetica(&mut doc);
184        let root = doc.root;
185        let list = alloc_list(&mut doc, root, true);
186        alloc_list_item(&mut doc, list, "first");
187        alloc_list_item(&mut doc, list, "second");
188        alloc_list_item(&mut doc, list, "third");
189
190        let result = LayoutEngine::new().layout(&doc);
191
192        let markers: Vec<&str> = result.graph.pages[0]
193            .runs
194            .iter()
195            .filter(|r| {
196                r.text.ends_with('.') && r.text.chars().next().is_some_and(|c| c.is_ascii_digit())
197            })
198            .map(|r| r.text.as_str())
199            .collect();
200        assert_eq!(markers, vec!["1.", "2.", "3."]);
201    }
202
203    #[test]
204    fn list_item_text_indented_past_marker_gutter() {
205        let mut doc = Document::new(PathBuf::from("test.mos"));
206        pin_helvetica(&mut doc);
207        let root = doc.root;
208        let list = alloc_list(&mut doc, root, false);
209        alloc_list_item(&mut doc, list, "hello");
210
211        let result = LayoutEngine::new().layout(&doc);
212
213        let hello = result.graph.pages[0]
214            .runs
215            .iter()
216            .find(|r| r.text == "hello")
217            .expect("hello run");
218        let expected = MARGIN_PT + LIST_MARKER_GUTTER_PT;
219        assert!((hello.x_pt - expected).abs() < 0.5);
220    }
221
222    #[test]
223    fn nested_list_indents_one_more_level() {
224        let mut doc = Document::new(PathBuf::from("test.mos"));
225        pin_helvetica(&mut doc);
226        let root = doc.root;
227        let outer = alloc_list(&mut doc, root, false);
228        let outer_item = alloc_list_item(&mut doc, outer, "outer");
229        let inner = alloc_list(&mut doc, outer_item, false);
230        alloc_list_item(&mut doc, inner, "inner");
231
232        let result = LayoutEngine::new().layout(&doc);
233
234        let runs = &result.graph.pages[0].runs;
235        let bullets: Vec<&TextRun> = runs.iter().filter(|r| r.text == "\u{2022}").collect();
236        assert_eq!(bullets.len(), 2);
237        let inner = runs.iter().find(|r| r.text == "inner").unwrap();
238        assert!((inner.x_pt - (MARGIN_PT + 2.0 * LIST_MARKER_GUTTER_PT)).abs() < 0.5);
239        assert!(bullets[1].x_pt > bullets[0].x_pt);
240    }
241
242    #[test]
243    fn long_ordered_list_widens_gutter_so_markers_dont_overlap_text() {
244        let mut doc = Document::new(PathBuf::from("test.mos"));
245        pin_helvetica(&mut doc);
246        let root = doc.root;
247        let list = alloc_list(&mut doc, root, true);
248        for i in 0..100 {
249            alloc_list_item(&mut doc, list, &format!("item{i}"));
250        }
251
252        let result = LayoutEngine::new().layout(&doc);
253
254        let all_runs: Vec<&TextRun> = result
255            .graph
256            .pages
257            .iter()
258            .flat_map(|p| p.runs.iter())
259            .collect();
260        let marker_100 = all_runs
261            .iter()
262            .find(|r| r.text == "100.")
263            .expect("`100.` marker emitted");
264        let text_99 = all_runs
265            .iter()
266            .find(|r| r.text == "item99")
267            .expect("`item99` text emitted");
268        let marker_right =
269            marker_100.x_pt + text_width(marker_100.font, marker_100.size_pt, &marker_100.text);
270        assert!(marker_right <= text_99.x_pt + 0.01);
271    }
272
273    #[test]
274    fn marker_only_item_still_emits_marker() {
275        let mut doc = Document::new(PathBuf::from("test.mos"));
276        pin_helvetica(&mut doc);
277        let root = doc.root;
278        let list = alloc_list(&mut doc, root, false);
279        doc.alloc_child(list, node(NodeKind::ListItem, AttrMap::new()));
280        alloc_list_item(&mut doc, list, "second");
281
282        let result = LayoutEngine::new().layout(&doc);
283
284        let bullets: Vec<&TextRun> = result.graph.pages[0]
285            .runs
286            .iter()
287            .filter(|r| r.text == "\u{2022}")
288            .collect();
289        assert_eq!(bullets.len(), 2);
290        assert!(bullets[0].baseline_from_top_pt < bullets[1].baseline_from_top_pt);
291    }
292
293    #[test]
294    fn item_with_only_nested_child_keeps_its_marker() {
295        let mut doc = Document::new(PathBuf::from("test.mos"));
296        pin_helvetica(&mut doc);
297        let root = doc.root;
298        let outer = alloc_list(&mut doc, root, false);
299        let outer_item = doc.alloc_child(outer, node(NodeKind::ListItem, AttrMap::new()));
300        let inner = alloc_list(&mut doc, outer_item, false);
301        alloc_list_item(&mut doc, inner, "deep");
302
303        let result = LayoutEngine::new().layout(&doc);
304
305        let bullets: Vec<&TextRun> = result.graph.pages[0]
306            .runs
307            .iter()
308            .filter(|r| r.text == "\u{2022}")
309            .collect();
310        assert_eq!(bullets.len(), 2);
311        assert!(bullets[1].x_pt > bullets[0].x_pt);
312        assert!(bullets[1].baseline_from_top_pt > bullets[0].baseline_from_top_pt);
313    }
314
315    #[test]
316    fn list_marker_baseline_matches_first_line_of_item() {
317        let mut doc = Document::new(PathBuf::from("test.mos"));
318        pin_helvetica(&mut doc);
319        let root = doc.root;
320        let list = alloc_list(&mut doc, root, false);
321        alloc_list_item(&mut doc, list, "one");
322
323        let result = LayoutEngine::new().layout(&doc);
324
325        let runs = &result.graph.pages[0].runs;
326        let bullet = runs.iter().find(|r| r.text == "\u{2022}").unwrap();
327        let one = runs.iter().find(|r| r.text == "one").unwrap();
328        assert!((bullet.baseline_from_top_pt - one.baseline_from_top_pt).abs() < 1e-3);
329    }
330
331    #[test]
332    fn list_text_wraps_within_indented_column() {
333        let mut doc = Document::new(PathBuf::from("test.mos"));
334        pin_helvetica(&mut doc);
335        let root = doc.root;
336        let list = alloc_list(&mut doc, root, false);
337        let long: String = (0..40).map(|i| format!("word{i} ")).collect();
338        alloc_list_item(&mut doc, list, long.trim());
339
340        let result = LayoutEngine::new().layout(&doc);
341
342        let text_left = MARGIN_PT + LIST_MARKER_GUTTER_PT;
343        let text_right = A4_WIDTH_PT - MARGIN_PT;
344        for run in result.graph.pages[0]
345            .runs
346            .iter()
347            .filter(|r| r.text != "\u{2022}")
348        {
349            assert!(run.x_pt >= text_left - 0.5);
350            let end = run.x_pt + text_width(run.font, run.size_pt, &run.text);
351            assert!(end <= text_right + 1e-3);
352        }
353    }
354}