longcipher_leptos_components/components/editor/
folding.rs

1//! Code folding functionality
2//!
3//! Provides collapsible regions for markdown headings, code blocks, and more.
4
5use std::collections::HashMap;
6
7use serde::{Deserialize, Serialize};
8
9/// Type of foldable region.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub enum FoldKind {
12    /// Markdown heading (H1-H6)
13    Heading(u8),
14    /// Code block (fenced or indented)
15    CodeBlock,
16    /// List (ordered or unordered)
17    List,
18    /// Blockquote
19    Blockquote,
20    /// Indentation-based fold (for JSON, YAML, etc.)
21    Indentation,
22    /// Custom region (for explicit fold markers)
23    Custom,
24}
25
26/// A foldable region in the document.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct FoldRegion {
29    /// Unique identifier for this region
30    pub id: u64,
31    /// Start line (0-indexed)
32    pub start_line: usize,
33    /// End line (0-indexed, inclusive)
34    pub end_line: usize,
35    /// Kind of fold region
36    pub kind: FoldKind,
37    /// Preview text to show when folded
38    pub preview: Option<String>,
39    /// Whether this region is currently folded
40    pub is_folded: bool,
41}
42
43impl FoldRegion {
44    /// Create a new fold region.
45    #[must_use]
46    pub fn new(id: u64, start_line: usize, end_line: usize, kind: FoldKind) -> Self {
47        Self {
48            id,
49            start_line,
50            end_line,
51            kind,
52            preview: None,
53            is_folded: false,
54        }
55    }
56
57    /// Create a fold region with preview text.
58    #[must_use]
59    pub fn with_preview(
60        id: u64,
61        start_line: usize,
62        end_line: usize,
63        kind: FoldKind,
64        preview: impl Into<String>,
65    ) -> Self {
66        Self {
67            id,
68            start_line,
69            end_line,
70            kind,
71            preview: Some(preview.into()),
72            is_folded: false,
73        }
74    }
75
76    /// Get the number of lines in this region.
77    #[must_use]
78    pub fn line_count(&self) -> usize {
79        self.end_line.saturating_sub(self.start_line) + 1
80    }
81
82    /// Check if a line is within this region (excluding the start line).
83    #[must_use]
84    pub fn contains_line(&self, line: usize) -> bool {
85        line > self.start_line && line <= self.end_line
86    }
87
88    /// Toggle the folded state.
89    pub fn toggle(&mut self) {
90        self.is_folded = !self.is_folded;
91    }
92}
93
94/// State for managing fold regions in a document.
95#[derive(Debug, Clone, Default)]
96pub struct FoldState {
97    /// All fold regions, indexed by ID
98    regions: HashMap<u64, FoldRegion>,
99    /// Next available region ID
100    next_id: u64,
101    /// Whether the fold state is dirty (needs recalculation)
102    is_dirty: bool,
103}
104
105impl FoldState {
106    /// Create a new fold state.
107    #[must_use]
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    /// Add a new fold region.
113    pub fn add_region(&mut self, start_line: usize, end_line: usize, kind: FoldKind) -> u64 {
114        let id = self.next_id;
115        self.next_id += 1;
116
117        let region = FoldRegion::new(id, start_line, end_line, kind);
118        self.regions.insert(id, region);
119        id
120    }
121
122    /// Add a region with preview text.
123    pub fn add_region_with_preview(
124        &mut self,
125        start_line: usize,
126        end_line: usize,
127        kind: FoldKind,
128        preview: impl Into<String>,
129    ) -> u64 {
130        let id = self.next_id;
131        self.next_id += 1;
132
133        let region = FoldRegion::with_preview(id, start_line, end_line, kind, preview);
134        self.regions.insert(id, region);
135        id
136    }
137
138    /// Get a region by ID.
139    #[must_use]
140    pub fn get_region(&self, id: u64) -> Option<&FoldRegion> {
141        self.regions.get(&id)
142    }
143
144    /// Get a mutable reference to a region.
145    pub fn get_region_mut(&mut self, id: u64) -> Option<&mut FoldRegion> {
146        self.regions.get_mut(&id)
147    }
148
149    /// Get the region that starts at a specific line.
150    #[must_use]
151    pub fn region_at_line(&self, line: usize) -> Option<&FoldRegion> {
152        self.regions.values().find(|r| r.start_line == line)
153    }
154
155    /// Toggle fold at a specific line.
156    ///
157    /// Returns true if a fold was toggled.
158    pub fn toggle_at_line(&mut self, line: usize) -> bool {
159        if let Some(region) = self.regions.values_mut().find(|r| r.start_line == line) {
160            region.toggle();
161            true
162        } else {
163            false
164        }
165    }
166
167    /// Check if a line is hidden due to folding.
168    #[must_use]
169    pub fn is_line_hidden(&self, line: usize) -> bool {
170        self.regions
171            .values()
172            .any(|r| r.is_folded && r.contains_line(line))
173    }
174
175    /// Get all fold indicator positions (line, is_folded).
176    #[must_use]
177    pub fn fold_indicators(&self) -> Vec<(usize, bool)> {
178        let mut indicators: Vec<_> = self
179            .regions
180            .values()
181            .map(|r| (r.start_line, r.is_folded))
182            .collect();
183        indicators.sort_by_key(|(line, _)| *line);
184        indicators
185    }
186
187    /// Fold all regions.
188    pub fn fold_all(&mut self) {
189        for region in self.regions.values_mut() {
190            region.is_folded = true;
191        }
192    }
193
194    /// Unfold all regions.
195    pub fn unfold_all(&mut self) {
196        for region in self.regions.values_mut() {
197            region.is_folded = false;
198        }
199    }
200
201    /// Fold all regions of a specific kind.
202    pub fn fold_kind(&mut self, kind: FoldKind) {
203        for region in self.regions.values_mut() {
204            if region.kind == kind {
205                region.is_folded = true;
206            }
207        }
208    }
209
210    /// Unfold all regions of a specific kind.
211    pub fn unfold_kind(&mut self, kind: FoldKind) {
212        for region in self.regions.values_mut() {
213            if region.kind == kind {
214                region.is_folded = false;
215            }
216        }
217    }
218
219    /// Clear all fold regions.
220    pub fn clear(&mut self) {
221        self.regions.clear();
222    }
223
224    /// Get the next available ID.
225    #[must_use]
226    pub fn next_id(&mut self) -> u64 {
227        let id = self.next_id;
228        self.next_id += 1;
229        id
230    }
231
232    /// Mark the fold state as clean.
233    pub fn mark_clean(&mut self) {
234        self.is_dirty = false;
235    }
236
237    /// Mark the fold state as dirty (needs recalculation).
238    pub fn mark_dirty(&mut self) {
239        self.is_dirty = true;
240    }
241
242    /// Check if the fold state is dirty.
243    #[must_use]
244    pub fn is_dirty(&self) -> bool {
245        self.is_dirty
246    }
247
248    /// Get the number of fold regions.
249    #[must_use]
250    pub fn region_count(&self) -> usize {
251        self.regions.len()
252    }
253
254    /// Iterate over all regions.
255    pub fn iter(&self) -> impl Iterator<Item = &FoldRegion> {
256        self.regions.values()
257    }
258}
259
260/// Detect markdown heading level (1-6) from a line.
261#[must_use]
262pub fn detect_heading_level(line: &str) -> Option<u8> {
263    let trimmed = line.trim_start();
264    if !trimmed.starts_with('#') {
265        return None;
266    }
267
268    let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
269    if hash_count > 6 {
270        return None;
271    }
272
273    // Must have space after hashes or be just hashes
274    let after_hashes = &trimmed[hash_count..];
275    if after_hashes.is_empty() || after_hashes.starts_with(' ') {
276        return Some(hash_count as u8);
277    }
278
279    None
280}
281
282/// Detect fold regions in markdown content.
283#[must_use]
284pub fn detect_markdown_folds(content: &str) -> FoldState {
285    let mut state = FoldState::new();
286    let lines: Vec<&str> = content.lines().collect();
287
288    if lines.is_empty() {
289        return state;
290    }
291
292    // Track headings for fold region detection
293    let mut headings: Vec<(usize, u8, String)> = Vec::new();
294
295    // First pass: find all headings
296    for (line_num, line) in lines.iter().enumerate() {
297        if let Some(level) = detect_heading_level(line) {
298            let text = line
299                .trim_start_matches('#')
300                .trim()
301                .chars()
302                .take(50)
303                .collect::<String>();
304            headings.push((line_num, level, text));
305        }
306    }
307
308    // Second pass: create fold regions for headings
309    for (i, (start_line, level, preview_text)) in headings.iter().enumerate() {
310        // Find the end of this heading's content
311        let end_line = if i + 1 < headings.len() {
312            // Look for the next heading of same or higher level
313            let mut found_end = None;
314            for j in (i + 1)..headings.len() {
315                let (next_line, next_level, _) = &headings[j];
316                if *next_level <= *level {
317                    found_end = Some(next_line.saturating_sub(1));
318                    break;
319                }
320            }
321            found_end.unwrap_or_else(|| {
322                if i + 1 < headings.len() {
323                    headings[i + 1].0.saturating_sub(1)
324                } else {
325                    lines.len().saturating_sub(1)
326                }
327            })
328        } else {
329            lines.len().saturating_sub(1)
330        };
331
332        // Only create fold if there's content to fold
333        if end_line > *start_line {
334            // Skip trailing empty lines
335            let mut actual_end = end_line;
336            while actual_end > *start_line
337                && lines
338                    .get(actual_end)
339                    .map(|l| l.trim().is_empty())
340                    .unwrap_or(true)
341            {
342                actual_end -= 1;
343            }
344
345            if actual_end > *start_line {
346                state.add_region_with_preview(
347                    *start_line,
348                    actual_end,
349                    FoldKind::Heading(*level),
350                    preview_text.clone(),
351                );
352            }
353        }
354    }
355
356    // Detect code blocks
357    let mut in_code_block = false;
358    let mut code_block_start = 0;
359
360    for (line_num, line) in lines.iter().enumerate() {
361        let trimmed = line.trim();
362        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
363            if in_code_block {
364                // End of code block
365                if line_num > code_block_start {
366                    state.add_region(code_block_start, line_num, FoldKind::CodeBlock);
367                }
368                in_code_block = false;
369            } else {
370                // Start of code block
371                code_block_start = line_num;
372                in_code_block = true;
373            }
374        }
375    }
376
377    state.mark_clean();
378    state
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn test_detect_heading_level() {
387        assert_eq!(detect_heading_level("# Heading"), Some(1));
388        assert_eq!(detect_heading_level("## Heading"), Some(2));
389        assert_eq!(detect_heading_level("### Heading"), Some(3));
390        assert_eq!(detect_heading_level("Not a heading"), None);
391        assert_eq!(detect_heading_level("#NoSpace"), None);
392    }
393
394    #[test]
395    fn test_fold_region_contains_line() {
396        let region = FoldRegion::new(1, 5, 10, FoldKind::Heading(1));
397
398        assert!(!region.contains_line(5)); // Start line is not "contained"
399        assert!(region.contains_line(6));
400        assert!(region.contains_line(10));
401        assert!(!region.contains_line(11));
402    }
403
404    #[test]
405    fn test_detect_markdown_folds() {
406        let content = "# Title\n\nSome content\n\n## Section\n\nMore content";
407        let state = detect_markdown_folds(content);
408
409        assert!(state.region_count() > 0);
410    }
411}