longcipher_leptos_components/components/editor/
folding.rs1use std::collections::HashMap;
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub enum FoldKind {
12 Heading(u8),
14 CodeBlock,
16 List,
18 Blockquote,
20 Indentation,
22 Custom,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct FoldRegion {
29 pub id: u64,
31 pub start_line: usize,
33 pub end_line: usize,
35 pub kind: FoldKind,
37 pub preview: Option<String>,
39 pub is_folded: bool,
41}
42
43impl FoldRegion {
44 #[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 #[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 #[must_use]
78 pub fn line_count(&self) -> usize {
79 self.end_line.saturating_sub(self.start_line) + 1
80 }
81
82 #[must_use]
84 pub fn contains_line(&self, line: usize) -> bool {
85 line > self.start_line && line <= self.end_line
86 }
87
88 pub fn toggle(&mut self) {
90 self.is_folded = !self.is_folded;
91 }
92}
93
94#[derive(Debug, Clone, Default)]
96pub struct FoldState {
97 regions: HashMap<u64, FoldRegion>,
99 next_id: u64,
101 is_dirty: bool,
103}
104
105impl FoldState {
106 #[must_use]
108 pub fn new() -> Self {
109 Self::default()
110 }
111
112 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 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 #[must_use]
140 pub fn get_region(&self, id: u64) -> Option<&FoldRegion> {
141 self.regions.get(&id)
142 }
143
144 pub fn get_region_mut(&mut self, id: u64) -> Option<&mut FoldRegion> {
146 self.regions.get_mut(&id)
147 }
148
149 #[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 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 #[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 #[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 pub fn fold_all(&mut self) {
189 for region in self.regions.values_mut() {
190 region.is_folded = true;
191 }
192 }
193
194 pub fn unfold_all(&mut self) {
196 for region in self.regions.values_mut() {
197 region.is_folded = false;
198 }
199 }
200
201 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 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 pub fn clear(&mut self) {
221 self.regions.clear();
222 }
223
224 #[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 pub fn mark_clean(&mut self) {
234 self.is_dirty = false;
235 }
236
237 pub fn mark_dirty(&mut self) {
239 self.is_dirty = true;
240 }
241
242 #[must_use]
244 pub fn is_dirty(&self) -> bool {
245 self.is_dirty
246 }
247
248 #[must_use]
250 pub fn region_count(&self) -> usize {
251 self.regions.len()
252 }
253
254 pub fn iter(&self) -> impl Iterator<Item = &FoldRegion> {
256 self.regions.values()
257 }
258}
259
260#[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 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#[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 let mut headings: Vec<(usize, u8, String)> = Vec::new();
294
295 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 for (i, (start_line, level, preview_text)) in headings.iter().enumerate() {
310 let end_line = if i + 1 < headings.len() {
312 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 if end_line > *start_line {
334 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 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 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 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)); 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}