Skip to main content

twrite_core/
folding.rs

1//! Collapsible fold ranges and their collapsed state.
2//!
3//! A [`FoldRange`] names a hidden-able row span whose first row (the header)
4//! always stays visible; rows after it hide while collapsed. Range
5//! computation is producer-specific (Markdown headings and lists live in the
6//! battery); this module only tracks which starts are collapsed and answers
7//! visibility queries against caller-supplied ranges sorted by start row.
8
9use std::collections::BTreeSet;
10
11/// A collapsible row span with inclusive bounds. The start row is the header
12/// and is never hidden itself; hiding applies to `start_row + 1..=end_row`.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct FoldRange {
15    /// First row of the fold; always visible.
16    pub start_row: usize,
17    /// Last hidden row while collapsed (inclusive).
18    pub end_row: usize,
19}
20
21/// Which fold starts are collapsed. Owned by the editor view; row ranges come
22/// from the highlighter per document version.
23#[derive(Debug, Clone, Default)]
24pub struct FoldState {
25    collapsed: BTreeSet<usize>,
26}
27
28impl FoldState {
29    /// Creates an empty fold state with nothing collapsed.
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    /// Toggles the fold starting at `start_row`, returning true when the fold
35    /// ends up collapsed. Toggling a row that starts no range still flips
36    /// membership so a later recompute revealing a fold there picks it up.
37    pub fn toggle(&mut self, start_row: usize) -> bool {
38        if self.collapsed.remove(&start_row) {
39            false
40        } else {
41            self.collapsed.insert(start_row);
42            true
43        }
44    }
45
46    /// Reports whether the fold starting at `start_row` is collapsed.
47    pub fn is_collapsed(&self, start_row: usize) -> bool {
48        self.collapsed.contains(&start_row)
49    }
50
51    /// Drops collapsed starts absent from `ranges`, keeping the set aligned
52    /// with structural edits that move or delete fold headers.
53    pub fn retain(&mut self, ranges: &[FoldRange]) {
54        self.collapsed
55            .retain(|start| ranges.iter().any(|range| range.start_row == *start));
56    }
57
58    /// Returns the collapsed range hiding `row`, if any. Ranges must be
59    /// sorted by start row; headers themselves are never hidden.
60    pub fn hidden_range_at<'a>(
61        &self,
62        ranges: &'a [FoldRange],
63        row: usize,
64    ) -> Option<&'a FoldRange> {
65        self.collapsed.iter().find_map(|start| {
66            ranges
67                .iter()
68                .find(|range| range.start_row == *start && *start < row && row <= range.end_row)
69        })
70    }
71
72    /// Reports whether `row` hides inside a collapsed range.
73    pub fn is_row_hidden(&self, ranges: &[FoldRange], row: usize) -> bool {
74        self.hidden_range_at(ranges, row).is_some()
75    }
76
77    /// Steps one visible row from `row`, skipping collapsed spans. Stays put
78    /// when no visible row exists in that direction.
79    pub fn step_visible_row(
80        &self,
81        ranges: &[FoldRange],
82        row: usize,
83        total_lines: usize,
84        down: bool,
85    ) -> usize {
86        if total_lines == 0 {
87            return 0;
88        }
89        let last = total_lines - 1;
90        if down {
91            let mut next = row.saturating_add(1);
92            while next <= last {
93                match self.hidden_range_at(ranges, next) {
94                    Some(range) => next = range.end_row.saturating_add(1),
95                    None => return next,
96                }
97            }
98            row
99        } else {
100            if row == 0 {
101                return 0;
102            }
103            let next = row - 1;
104            match self.hidden_range_at(ranges, next) {
105                // Headers stay visible, so their start row is the target.
106                Some(range) => range.start_row,
107                None => next,
108            }
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    fn ranges() -> Vec<FoldRange> {
118        vec![
119            FoldRange {
120                start_row: 0,
121                end_row: 5,
122            },
123            FoldRange {
124                start_row: 1,
125                end_row: 3,
126            },
127            FoldRange {
128                start_row: 7,
129                end_row: 9,
130            },
131        ]
132    }
133
134    #[test]
135    fn toggle_flips_membership() {
136        let mut state = FoldState::new();
137        assert!(state.toggle(0));
138        assert!(state.is_collapsed(0));
139        assert!(!state.toggle(0));
140        assert!(!state.is_collapsed(0));
141    }
142
143    #[test]
144    fn headers_stay_visible() {
145        let mut state = FoldState::new();
146        state.toggle(0);
147        assert!(!state.is_row_hidden(&ranges(), 0));
148        assert!(state.is_row_hidden(&ranges(), 1));
149        assert!(state.is_row_hidden(&ranges(), 5));
150        assert!(!state.is_row_hidden(&ranges(), 6));
151    }
152
153    #[test]
154    fn step_down_skips_collapsed_spans() {
155        let mut state = FoldState::new();
156        state.toggle(0);
157        // From the header, Down jumps past the whole hidden span.
158        assert_eq!(state.step_visible_row(&ranges(), 0, 10, true), 6);
159        // From inside (possible after structural edits), same landing.
160        assert_eq!(state.step_visible_row(&ranges(), 2, 10, true), 6);
161        // Nothing visible below the last fold: stay put. Row 7 stays a
162        // visible header, so Down from 6 still lands on it.
163        state.toggle(7);
164        assert_eq!(state.step_visible_row(&ranges(), 8, 10, true), 8);
165        assert_eq!(state.step_visible_row(&ranges(), 6, 10, true), 7);
166    }
167
168    #[test]
169    fn step_up_lands_on_headers() {
170        let mut state = FoldState::new();
171        state.toggle(0);
172        // Up from below the fold lands on its header.
173        assert_eq!(state.step_visible_row(&ranges(), 6, 10, false), 0);
174        // Up from inside lands on the header too.
175        assert_eq!(state.step_visible_row(&ranges(), 4, 10, false), 0);
176        assert_eq!(state.step_visible_row(&ranges(), 0, 10, false), 0);
177        // Non-zero headers work the same way.
178        state.toggle(7);
179        assert_eq!(state.step_visible_row(&ranges(), 9, 10, false), 7);
180        assert_eq!(state.step_visible_row(&ranges(), 8, 10, true), 8);
181        // No folds: plain stepping.
182        let open = FoldState::new();
183        assert_eq!(open.step_visible_row(&ranges(), 6, 10, false), 5);
184        assert_eq!(open.step_visible_row(&ranges(), 6, 10, true), 7);
185    }
186
187    #[test]
188    fn retain_drops_vanished_starts() {
189        let mut state = FoldState::new();
190        state.toggle(0);
191        state.toggle(7);
192        state.retain(&[FoldRange {
193            start_row: 7,
194            end_row: 9,
195        }]);
196        assert!(!state.is_collapsed(0));
197        assert!(state.is_collapsed(7));
198    }
199
200    #[test]
201    fn empty_state_hides_nothing() {
202        let state = FoldState::new();
203        for row in 0..10 {
204            assert!(!state.is_row_hidden(&ranges(), row));
205        }
206    }
207}