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