Skip to main content

forme/layout/
page_break.rs

1//! # Page Break Decisions
2//!
3//! Logic for deciding when and how to break content across pages.
4//! This module encodes the rules that make Forme's page breaks
5//! feel natural rather than mechanical.
6
7/// Decide what to do when a node doesn't fit on the current page.
8#[derive(Debug, Clone, PartialEq)]
9pub enum BreakDecision {
10    /// Place the entire node on the current page (it fits).
11    Place,
12    /// Move the entire node to the next page (unbreakable, or better aesthetics).
13    MoveToNextPage,
14    /// Split the node: place some content here, continue on the next page.
15    Split {
16        /// How many child items / lines fit on the current page.
17        items_on_current_page: usize,
18    },
19}
20
21/// Given the remaining space on a page and a list of child heights,
22/// decide how to break.
23pub fn decide_break(
24    remaining_height: f64,
25    child_heights: &[f64],
26    is_breakable: bool,
27    min_orphan_lines: usize,
28    min_widow_lines: usize,
29) -> BreakDecision {
30    // Total height of all children
31    let total: f64 = child_heights.iter().sum();
32
33    // Easy case: everything fits
34    if total <= remaining_height {
35        return BreakDecision::Place;
36    }
37
38    // Unbreakable: force to next page
39    if !is_breakable {
40        return BreakDecision::MoveToNextPage;
41    }
42
43    // Find how many children fit
44    let mut running = 0.0;
45    let mut fit_count = 0;
46    for &h in child_heights {
47        if running + h > remaining_height {
48            break;
49        }
50        running += h;
51        fit_count += 1;
52    }
53
54    // Widow/orphan control
55    let total_items = child_heights.len();
56
57    // Would we leave too few items on the current page? (orphan)
58    if fit_count < min_orphan_lines && fit_count < total_items {
59        return BreakDecision::MoveToNextPage;
60    }
61
62    // Would we leave too few items on the next page? (widow)
63    let remaining_items = total_items - fit_count;
64    if remaining_items < min_widow_lines && remaining_items > 0 {
65        // Pull some items back to avoid widows
66        let adjusted = fit_count.saturating_sub(min_widow_lines - remaining_items);
67        if adjusted == 0 {
68            return BreakDecision::MoveToNextPage;
69        }
70        return BreakDecision::Split {
71            items_on_current_page: adjusted,
72        };
73    }
74
75    if fit_count == 0 {
76        return BreakDecision::MoveToNextPage;
77    }
78
79    BreakDecision::Split {
80        items_on_current_page: fit_count,
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn everything_fits() {
90        let decision = decide_break(100.0, &[20.0, 30.0, 40.0], true, 2, 2);
91        assert_eq!(decision, BreakDecision::Place);
92    }
93
94    #[test]
95    fn unbreakable_moves() {
96        let decision = decide_break(50.0, &[20.0, 30.0, 40.0], false, 2, 2);
97        assert_eq!(decision, BreakDecision::MoveToNextPage);
98    }
99
100    #[test]
101    fn split_at_right_point() {
102        let decision = decide_break(55.0, &[20.0, 30.0, 40.0], true, 1, 1);
103        assert_eq!(
104            decision,
105            BreakDecision::Split {
106                items_on_current_page: 2,
107            }
108        );
109    }
110
111    #[test]
112    fn orphan_control() {
113        // Only 1 item would fit, but min_orphan is 2 → move everything
114        let decision = decide_break(25.0, &[20.0, 30.0, 40.0], true, 2, 2);
115        assert_eq!(decision, BreakDecision::MoveToNextPage);
116    }
117
118    #[test]
119    fn widow_control() {
120        // 3 of 4 fit, leaving 1 widow (min=2) → pull one back
121        let decision = decide_break(70.0, &[20.0, 20.0, 20.0, 20.0], true, 2, 2);
122        assert_eq!(
123            decision,
124            BreakDecision::Split {
125                items_on_current_page: 2,
126            }
127        );
128    }
129}