Skip to main content

iced_code_editor/canvas_editor/
folding.rs

1//! Code folding logic for the text editor.
2//!
3//! This module detects foldable regions (collapsible blocks) in the buffer and
4//! computes which logical lines must be hidden when a block is collapsed.
5//!
6//! Detection is **indentation-based**: a line is a fold header when the next
7//! non-blank line is more deeply indented. This is language-agnostic and matches
8//! the fallback strategy used by editors such as VS Code. The collapsed state and
9//! the on/off toggle live on [`super::CodeEditor`]; this module is pure logic so
10//! it can be unit-tested in isolation.
11
12use std::collections::HashSet;
13
14use crate::text_buffer::TextBuffer;
15
16use super::TAB_WIDTH;
17
18/// A region of the buffer that can be collapsed into a single header line.
19///
20/// `start_line` is the header line, which always stays visible. When the region
21/// is collapsed, the lines `start_line + 1 ..= end_line` are hidden.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct FoldRegion {
24    /// Index of the header line (stays visible when collapsed).
25    pub start_line: usize,
26    /// Index of the last line belonging to the region (hidden when collapsed).
27    pub end_line: usize,
28}
29
30impl FoldRegion {
31    /// Creates a new fold region.
32    ///
33    /// # Arguments
34    ///
35    /// * `start_line` - Index of the header line
36    /// * `end_line` - Index of the last line in the region
37    pub fn new(start_line: usize, end_line: usize) -> Self {
38        Self { start_line, end_line }
39    }
40}
41
42/// Computes the visual indentation width of a line, expanding tabs.
43///
44/// Returns `None` for blank lines (empty or whitespace-only), which have no
45/// meaningful indentation and act as "transparent" lines during detection.
46///
47/// # Arguments
48///
49/// * `line` - The line content (without the trailing newline)
50fn indent_width(line: &str) -> Option<usize> {
51    let mut width = 0;
52    for c in line.chars() {
53        match c {
54            '\t' => width += TAB_WIDTH,
55            ' ' => width += 1,
56            _ if c.is_whitespace() => width += 1,
57            _ => return Some(width),
58        }
59    }
60    // Reached end of line without a non-whitespace character: blank line.
61    None
62}
63
64/// Detects all indentation-based foldable regions in the buffer.
65///
66/// A line `i` is a fold header when the next non-blank line is indented more
67/// deeply than `i`. The region extends over every following line that is either
68/// blank or more deeply indented than the header; trailing blank lines are
69/// trimmed so a collapsed block does not swallow the gap before the next block.
70///
71/// Nested blocks each yield their own region, so they can be folded
72/// independently.
73///
74/// # Arguments
75///
76/// * `buffer` - The text buffer to analyze
77///
78/// # Returns
79///
80/// Fold regions in ascending order of `start_line`. Only regions hiding at least
81/// one line are returned.
82pub fn compute_foldable_regions(buffer: &TextBuffer) -> Vec<FoldRegion> {
83    let line_count = buffer.line_count();
84    // Precompute indentation once: O(n) instead of re-scanning per header.
85    let indents: Vec<Option<usize>> =
86        (0..line_count).map(|i| indent_width(buffer.line(i))).collect();
87
88    let mut regions = Vec::new();
89
90    for i in 0..line_count {
91        let Some(header_indent) = indents[i] else {
92            continue; // Blank lines are never headers.
93        };
94
95        // Scan forward, tracking the last non-blank line more deeply indented
96        // than the header. Blank lines are skipped without ending the region.
97        let mut last_content = i;
98        let mut j = i + 1;
99        while j < line_count {
100            match indents[j] {
101                None => j += 1, // Blank line: tentatively inside the region.
102                Some(indent) if indent > header_indent => {
103                    last_content = j;
104                    j += 1;
105                }
106                Some(_) => break, // Sibling/outer line ends the region.
107            }
108        }
109
110        if last_content > i {
111            regions.push(FoldRegion::new(i, last_content));
112        }
113    }
114
115    regions
116}
117
118/// Returns whether `line` is the header of a foldable region.
119///
120/// # Arguments
121///
122/// * `regions` - Pre-computed fold regions
123/// * `line` - The logical line index to test
124pub fn is_fold_header(regions: &[FoldRegion], line: usize) -> bool {
125    regions.iter().any(|r| r.start_line == line)
126}
127
128/// Computes the set of logical lines hidden by the currently collapsed regions.
129///
130/// A region contributes its hidden lines (`start_line + 1 ..= end_line`) only
131/// when its header is present in `collapsed`. Overlapping (nested) regions are
132/// handled naturally because the result is a union.
133///
134/// # Arguments
135///
136/// * `regions` - Pre-computed fold regions
137/// * `collapsed` - Header line indices that are currently collapsed
138///
139/// # Returns
140///
141/// The set of logical line indices that must not be rendered.
142pub fn hidden_lines(
143    regions: &[FoldRegion],
144    collapsed: &HashSet<usize>,
145) -> HashSet<usize> {
146    let mut hidden = HashSet::new();
147    for region in regions {
148        if collapsed.contains(&region.start_line) {
149            hidden.extend((region.start_line + 1)..=region.end_line);
150        }
151    }
152    hidden
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_indent_width_blank_lines() {
161        assert_eq!(indent_width(""), None);
162        assert_eq!(indent_width("   "), None);
163        assert_eq!(indent_width("\t"), None);
164    }
165
166    #[test]
167    fn test_indent_width_expands_tabs() {
168        assert_eq!(indent_width("code"), Some(0));
169        assert_eq!(indent_width("  code"), Some(2));
170        assert_eq!(indent_width("\tcode"), Some(TAB_WIDTH));
171        assert_eq!(indent_width("\t  code"), Some(TAB_WIDTH + 2));
172    }
173
174    #[test]
175    fn test_no_regions_for_flat_text() {
176        let buffer = TextBuffer::new("a\nb\nc");
177        assert!(compute_foldable_regions(&buffer).is_empty());
178    }
179
180    #[test]
181    fn test_simple_block() {
182        // `fn main` header at line 0, body at lines 1-2, closing brace dedented.
183        let buffer =
184            TextBuffer::new("fn main() {\n    let x = 1;\n    let y = 2;\n}");
185        let regions = compute_foldable_regions(&buffer);
186        assert_eq!(regions, vec![FoldRegion::new(0, 2)]);
187    }
188
189    #[test]
190    fn test_nested_blocks() {
191        // Two nesting levels produce two independent regions.
192        let buffer = TextBuffer::new(
193            "outer:\n    inner:\n        deep\n        deeper\n    after_inner",
194        );
195        let regions = compute_foldable_regions(&buffer);
196        assert_eq!(regions, vec![FoldRegion::new(0, 4), FoldRegion::new(1, 3)]);
197    }
198
199    #[test]
200    fn test_blank_lines_inside_and_trailing() {
201        // Blank line (idx 2) stays inside the block; trailing blank (idx 4)
202        // before a dedented line is trimmed.
203        let buffer =
204            TextBuffer::new("def f():\n    a = 1\n\n    b = 2\n\ng = 3");
205        let regions = compute_foldable_regions(&buffer);
206        // Region covers lines 1..=3 (the blank at 2 is absorbed), but not the
207        // trailing blank at line 4.
208        assert_eq!(regions, vec![FoldRegion::new(0, 3)]);
209    }
210
211    #[test]
212    fn test_hidden_lines_single_collapsed() {
213        let regions = vec![FoldRegion::new(0, 2)];
214        let collapsed: HashSet<usize> = [0].into_iter().collect();
215        let hidden = hidden_lines(&regions, &collapsed);
216        assert_eq!(hidden, [1, 2].into_iter().collect());
217    }
218
219    #[test]
220    fn test_hidden_lines_nested_union() {
221        let regions = vec![FoldRegion::new(0, 4), FoldRegion::new(1, 3)];
222        // Only the inner region is collapsed.
223        let collapsed: HashSet<usize> = [1].into_iter().collect();
224        assert_eq!(
225            hidden_lines(&regions, &collapsed),
226            [2, 3].into_iter().collect()
227        );
228
229        // Both collapsed: union covers 1..=4.
230        let collapsed: HashSet<usize> = [0, 1].into_iter().collect();
231        assert_eq!(
232            hidden_lines(&regions, &collapsed),
233            [1, 2, 3, 4].into_iter().collect()
234        );
235    }
236
237    #[test]
238    fn test_hidden_lines_ignores_unknown_collapsed() {
239        // A stale collapsed entry without a matching region contributes nothing.
240        let regions = vec![FoldRegion::new(0, 2)];
241        let collapsed: HashSet<usize> = [99].into_iter().collect();
242        assert!(hidden_lines(&regions, &collapsed).is_empty());
243    }
244
245    #[test]
246    fn test_is_fold_header() {
247        let regions = vec![FoldRegion::new(0, 2), FoldRegion::new(5, 7)];
248        assert!(is_fold_header(&regions, 0));
249        assert!(is_fold_header(&regions, 5));
250        assert!(!is_fold_header(&regions, 1));
251        assert!(!is_fold_header(&regions, 3));
252    }
253}