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(®ion.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(®ions, &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(®ions, &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(®ions, &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(®ions, &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(®ions, 0));
249 assert!(is_fold_header(®ions, 5));
250 assert!(!is_fold_header(®ions, 1));
251 assert!(!is_fold_header(®ions, 3));
252 }
253}