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. Blank lines remain transparent to folding.
85    let indents: Vec<Option<usize>> =
86        (0..line_count).map(|i| indent_width(buffer.line(i))).collect();
87
88    let mut regions: Vec<FoldRegion> = Vec::new();
89    // Stack entries are `(indent, region_index)`. A region is opened when the
90    // next non-blank line is deeper, and closed by the first non-blank line at
91    // the same or a shallower indentation. This keeps detection O(n), including
92    // deeply nested or very large uniformly-indented files.
93    let mut open_regions: Vec<(usize, usize)> = Vec::new();
94    let mut non_blank = indents
95        .iter()
96        .enumerate()
97        .filter_map(|(line, indent)| indent.map(|width| (line, width)))
98        .peekable();
99
100    let mut previous_non_blank = None;
101    while let Some((line, indent)) = non_blank.next() {
102        while open_regions
103            .last()
104            .is_some_and(|(header_indent, _)| *header_indent >= indent)
105        {
106            if let Some((_, region_index)) = open_regions.pop()
107                && let Some(end_line) = previous_non_blank
108                && let Some(region) = regions.get_mut(region_index)
109            {
110                region.end_line = end_line;
111            }
112        }
113
114        if non_blank
115            .peek()
116            .is_some_and(|(_, next_indent)| *next_indent > indent)
117        {
118            let region_index = regions.len();
119            regions.push(FoldRegion::new(line, line));
120            open_regions.push((indent, region_index));
121        }
122
123        previous_non_blank = Some(line);
124    }
125
126    if let Some(end_line) = previous_non_blank {
127        for (_, region_index) in open_regions {
128            if let Some(region) = regions.get_mut(region_index) {
129                region.end_line = end_line;
130            }
131        }
132    }
133
134    regions
135}
136
137/// Returns whether `line` is the header of a foldable region.
138///
139/// # Arguments
140///
141/// * `regions` - Pre-computed fold regions
142/// * `line` - The logical line index to test
143pub fn is_fold_header(regions: &[FoldRegion], line: usize) -> bool {
144    regions.binary_search_by_key(&line, |region| region.start_line).is_ok()
145}
146
147/// Checks whether one logical line is a fold header without scanning the whole
148/// buffer or building every fold region.
149///
150/// Rendering and hover hit-testing only need this local yes/no answer. Full
151/// region discovery remains deferred until the user actually folds a block.
152pub fn is_line_fold_header(buffer: &TextBuffer, line: usize) -> bool {
153    let Some(header_indent) = indent_width(buffer.line(line)) else {
154        return false;
155    };
156
157    (line.saturating_add(1)..buffer.line_count())
158        .find_map(|next_line| indent_width(buffer.line(next_line)))
159        .is_some_and(|next_indent| next_indent > header_indent)
160}
161
162/// Computes the set of logical lines hidden by the currently collapsed regions.
163///
164/// A region contributes its hidden lines (`start_line + 1 ..= end_line`) only
165/// when its header is present in `collapsed`. Overlapping (nested) regions are
166/// handled naturally because the result is a union.
167///
168/// # Arguments
169///
170/// * `regions` - Pre-computed fold regions
171/// * `collapsed` - Header line indices that are currently collapsed
172///
173/// # Returns
174///
175/// The set of logical line indices that must not be rendered.
176pub fn hidden_lines(
177    regions: &[FoldRegion],
178    collapsed: &HashSet<usize>,
179) -> HashSet<usize> {
180    let mut hidden = HashSet::new();
181    for region in regions {
182        if collapsed.contains(&region.start_line) {
183            hidden.extend((region.start_line + 1)..=region.end_line);
184        }
185    }
186    hidden
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_indent_width_blank_lines() {
195        assert_eq!(indent_width(""), None);
196        assert_eq!(indent_width("   "), None);
197        assert_eq!(indent_width("\t"), None);
198    }
199
200    #[test]
201    fn test_indent_width_expands_tabs() {
202        assert_eq!(indent_width("code"), Some(0));
203        assert_eq!(indent_width("  code"), Some(2));
204        assert_eq!(indent_width("\tcode"), Some(TAB_WIDTH));
205        assert_eq!(indent_width("\t  code"), Some(TAB_WIDTH + 2));
206    }
207
208    #[test]
209    fn test_no_regions_for_flat_text() {
210        let buffer = TextBuffer::new("a\nb\nc");
211        assert!(compute_foldable_regions(&buffer).is_empty());
212    }
213
214    #[test]
215    fn test_simple_block() {
216        // `fn main` header at line 0, body at lines 1-2, closing brace dedented.
217        let buffer =
218            TextBuffer::new("fn main() {\n    let x = 1;\n    let y = 2;\n}");
219        let regions = compute_foldable_regions(&buffer);
220        assert_eq!(regions, vec![FoldRegion::new(0, 2)]);
221    }
222
223    #[test]
224    fn test_nested_blocks() {
225        // Two nesting levels produce two independent regions.
226        let buffer = TextBuffer::new(
227            "outer:\n    inner:\n        deep\n        deeper\n    after_inner",
228        );
229        let regions = compute_foldable_regions(&buffer);
230        assert_eq!(regions, vec![FoldRegion::new(0, 4), FoldRegion::new(1, 3)]);
231    }
232
233    #[test]
234    fn test_blank_lines_inside_and_trailing() {
235        // Blank line (idx 2) stays inside the block; trailing blank (idx 4)
236        // before a dedented line is trimmed.
237        let buffer =
238            TextBuffer::new("def f():\n    a = 1\n\n    b = 2\n\ng = 3");
239        let regions = compute_foldable_regions(&buffer);
240        // Region covers lines 1..=3 (the blank at 2 is absorbed), but not the
241        // trailing blank at line 4.
242        assert_eq!(regions, vec![FoldRegion::new(0, 3)]);
243    }
244
245    #[test]
246    fn test_hidden_lines_single_collapsed() {
247        let regions = vec![FoldRegion::new(0, 2)];
248        let collapsed: HashSet<usize> = [0].into_iter().collect();
249        let hidden = hidden_lines(&regions, &collapsed);
250        assert_eq!(hidden, [1, 2].into_iter().collect());
251    }
252
253    #[test]
254    fn test_hidden_lines_nested_union() {
255        let regions = vec![FoldRegion::new(0, 4), FoldRegion::new(1, 3)];
256        // Only the inner region is collapsed.
257        let collapsed: HashSet<usize> = [1].into_iter().collect();
258        assert_eq!(
259            hidden_lines(&regions, &collapsed),
260            [2, 3].into_iter().collect()
261        );
262
263        // Both collapsed: union covers 1..=4.
264        let collapsed: HashSet<usize> = [0, 1].into_iter().collect();
265        assert_eq!(
266            hidden_lines(&regions, &collapsed),
267            [1, 2, 3, 4].into_iter().collect()
268        );
269    }
270
271    #[test]
272    fn test_hidden_lines_ignores_unknown_collapsed() {
273        // A stale collapsed entry without a matching region contributes nothing.
274        let regions = vec![FoldRegion::new(0, 2)];
275        let collapsed: HashSet<usize> = [99].into_iter().collect();
276        assert!(hidden_lines(&regions, &collapsed).is_empty());
277    }
278
279    #[test]
280    fn test_is_fold_header() {
281        let regions = vec![FoldRegion::new(0, 2), FoldRegion::new(5, 7)];
282        assert!(is_fold_header(&regions, 0));
283        assert!(is_fold_header(&regions, 5));
284        assert!(!is_fold_header(&regions, 1));
285        assert!(!is_fold_header(&regions, 3));
286    }
287
288    #[test]
289    fn test_is_line_fold_header_skips_blank_lines() {
290        let buffer = TextBuffer::new("header\n\n    body\nsibling");
291        assert!(is_line_fold_header(&buffer, 0));
292        assert!(!is_line_fold_header(&buffer, 1));
293        assert!(!is_line_fold_header(&buffer, 2));
294    }
295}