Skip to main content

lightweight_pdf_layout/
list.rs

1//! `List` layout (Phase 6): sugar over `Row`/`Column` — a fixed-width
2//! marker column beside each item's content, stacked vertically. Reuses
3//! Row/Column's existing measure/layout entirely rather than duplicating
4//! that logic; see `lightweight_pdf_core::list` for why `List` is still its own
5//! element (builder ergonomics) despite delegating like this.
6
7use crate::geometry::{Constraints, Rect, Size};
8use crate::layoutable::{coerce_to_fit_and_warn, LayoutCtx, LayoutResult, Layoutable};
9use crate::warnings::LayoutWarning;
10use lightweight_pdf_core::{Align, Column, List, Marker, Row, Text};
11
12fn marker_text(marker: &Marker) -> String {
13    match marker {
14        Marker::Bullet => "\u{2022}".to_string(),
15        Marker::Number(n) => format!("{n}."),
16    }
17}
18
19/// Translates a `List` into an equivalent `Column` of `Row`s (marker +
20/// content). Item content that has no explicit width gets `flex(1.0)` so
21/// it fills the remaining row width instead of shrink-wrapping to its own
22/// natural size (`Element::common_mut`, added for exactly this).
23fn to_column(list: &List) -> Column {
24    let mut col = Column::new().gap(list.gap);
25    for item in &list.items {
26        let mut content = item.content.clone();
27        if let Some(common) = content.common_mut() {
28            if common.width.is_none() && common.flex.is_none() {
29                common.flex = Some(1.0);
30            }
31        }
32        let row = Row::new()
33            .gap(list.gap)
34            .child(Text::new(marker_text(&item.marker)).width(list.marker_width).align(Align::End))
35            .child(content);
36        col = col.child(row);
37    }
38    col.common = list.common;
39    col
40}
41
42impl Layoutable for List {
43    fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
44        to_column(self).measure(ctx, constraints)
45    }
46
47    fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
48        // Lists don't paginate in V1 (same "atomic, no Split" contract as
49        // Row) — if the translated Column would need to split, keep what
50        // fits and clip+warn on the rest instead of silently dropping it.
51        let result = to_column(self).layout(ctx, area, warnings, page);
52        LayoutResult::Fit(coerce_to_fit_and_warn(
53            result,
54            warnings,
55            page,
56            "List content exceeds available space",
57        ))
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use crate::font_resolver::{FontMetrics, FontResolver};
65    use crate::render_node::RenderNode;
66    use lightweight_pdf_core::{FontKey, Text as TextEl};
67
68    struct FixedMetrics;
69    impl FontMetrics for FixedMetrics {
70        fn advance(&self, ch: char) -> f32 {
71            if ch == ' ' {
72                300.0
73            } else {
74                600.0
75            }
76        }
77        fn ascent(&self) -> f32 {
78            800.0
79        }
80        fn descent(&self) -> f32 {
81            -200.0
82        }
83    }
84    struct FixedResolver;
85    impl FontResolver for FixedResolver {
86        fn metrics(&self, _key: FontKey) -> &dyn FontMetrics {
87            &FixedMetrics
88        }
89    }
90    fn ctx() -> LayoutCtx<'static> {
91        LayoutCtx { resolver: &FixedResolver }
92    }
93
94    #[test]
95    fn bullet_and_numbered_items_get_distinct_markers() {
96        let list = List::new()
97            .bullet(TextEl::new("Erstens"))
98            .numbered(TextEl::new("Zweitens"))
99            .numbered(TextEl::new("Drittens"));
100        let c = ctx();
101        let mut warnings = Vec::new();
102        let area = Rect {
103            x: 0.0,
104            y: 0.0,
105            width: 300.0,
106            height: 300.0,
107        };
108        let LayoutResult::Fit(RenderNode::Group { children: rows, .. }) = list.layout(&c, area, &mut warnings, 1) else {
109            panic!("expected Fit");
110        };
111        assert_eq!(rows.len(), 3);
112
113        fn marker_of(row: &RenderNode) -> String {
114            let RenderNode::Group { children: cells, .. } = row else {
115                panic!("expected row group")
116            };
117            let RenderNode::Group { children: wrap, .. } = &cells[0] else {
118                panic!("expected clip wrapper")
119            };
120            let RenderNode::TextLines { lines, .. } = &wrap[0] else {
121                panic!("expected TextLines")
122            };
123            lines.join("")
124        }
125        assert_eq!(marker_of(&rows[0]), "\u{2022}");
126        assert_eq!(marker_of(&rows[1]), "1.");
127        assert_eq!(marker_of(&rows[2]), "2.");
128    }
129
130    #[test]
131    fn item_content_fills_remaining_row_width() {
132        let list = List::new().bullet(TextEl::new("x"));
133        let c = ctx();
134        let mut warnings = Vec::new();
135        let area = Rect {
136            x: 0.0,
137            y: 0.0,
138            width: 200.0,
139            height: 100.0,
140        };
141        let LayoutResult::Fit(RenderNode::Group { children: rows, .. }) = list.layout(&c, area, &mut warnings, 1) else {
142            panic!("expected Fit");
143        };
144        let RenderNode::Group { children: cells, .. } = &rows[0] else {
145            panic!("expected row group")
146        };
147        let RenderNode::Group { area: content_area, .. } = &cells[1] else {
148            panic!("expected clip wrapper for content cell")
149        };
150        // marker_width (16) + row gap (6) = 22; content should claim the
151        // rest of the 200pt row, not shrink to "x"'s own tiny width.
152        assert!(
153            content_area.width > 150.0,
154            "expected content to fill remaining width, got {}",
155            content_area.width
156        );
157    }
158}