1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
//! Fold command helpers on [`App`].
//!
//! Folding state lives on the per-pane [`crate::editor::Editor`]; the
//! *foldable regions* are derived from the active document (tree-sitter
//! `folds.scm` when the language has one, indentation otherwise). These
//! helpers bridge the two: resolve the region under the cursor, mutate
//! the view's collapse state, and keep the cursor off hidden rows.
use std::collections::HashSet;
use crate::app::App;
/// Innermost foldable region containing `row` — the one with the
/// smallest span among those whose `header ..= end` covers it. `za`/`zo`/
/// `zc` act on this region.
fn innermost_fold_at(regions: &[(usize, usize)], row: usize) -> Option<(usize, usize)> {
regions
.iter()
.copied()
.filter(|&(h, e)| h <= row && row <= e)
.min_by_key(|&(h, e)| e - h)
}
impl App {
/// Foldable regions for the active document: syntax folds when the
/// language ships a `folds.scm`, indentation-based regions otherwise
/// (and for buffers with no highlighter at all). Sorted by header
/// row, one region per header.
pub(crate) fn fold_regions(&self) -> Vec<(usize, usize)> {
let tab_width = self.effective_editor().tab_width.max(1);
crate::editor::fold::buffer_fold_regions(self.active_doc(), tab_width)
}
/// Rows hidden by the active view's collapsed folds. Empty (and
/// cheap — no region scan) when nothing is collapsed. Used by
/// folding-aware vertical motion and the cursor-snap invariant.
pub(crate) fn hidden_fold_rows(&self) -> HashSet<usize> {
let folds = self.editor.folds();
if folds.is_empty() {
return HashSet::new();
}
let regions = self.fold_regions();
let mut hidden = HashSet::new();
for &(h, e) in ®ions {
if folds.is_collapsed(h) {
hidden.extend((h + 1)..=e);
}
}
hidden
}
/// Post-dispatch guard: pull the cursor off any hidden row after a
/// command runs. Cheap no-op when nothing is folded — the common
/// case — so it's safe to call after every `evaluate`. Catches
/// motions (`gg`/`G`/search/jumps) that don't go through the
/// folding-aware vertical-move path.
pub(crate) fn snap_cursor_after_motion(&mut self) {
if self.editor.folds().is_empty() {
return;
}
let regions = self.fold_regions();
self.snap_cursor_out_of_fold(®ions);
}
/// If the cursor sits on a hidden row (inside a collapsed fold), move
/// it up to the outermost collapsed header covering it. The single
/// invariant the rest of the fold code relies on: the cursor is never
/// on a row the renderer would skip. Pass the already-computed
/// `regions` to avoid recomputing them.
pub(crate) fn snap_cursor_out_of_fold(&mut self, regions: &[(usize, usize)]) {
let row = self.editor.cursor.row;
let folds = self.editor.folds();
// Outermost (smallest header) collapsed region whose hidden body
// `header + 1 ..= end` contains the cursor.
let mut target: Option<usize> = None;
for &(h, e) in regions {
if row > h && row <= e && folds.is_collapsed(h) {
target = Some(target.map_or(h, |t| t.min(h)));
}
}
if let Some(h) = target {
self.editor.cursor.row = h;
ed_op_ref!(self, clamp_col(false));
}
}
/// `za` — toggle the fold under the cursor.
pub(crate) fn fold_toggle_at_cursor(&mut self) {
let regions = self.fold_regions();
let row = self.editor.cursor.row;
let Some((header, _)) = innermost_fold_at(®ions, row) else {
return;
};
self.editor.folds_mut().toggle(header);
self.snap_cursor_out_of_fold(®ions);
}
/// `zo` — open the fold under the cursor.
pub(crate) fn fold_open_at_cursor(&mut self) {
let regions = self.fold_regions();
let row = self.editor.cursor.row;
if let Some((header, _)) = innermost_fold_at(®ions, row) {
self.editor.folds_mut().open(header);
}
}
/// `zc` — close the fold under the cursor.
pub(crate) fn fold_close_at_cursor(&mut self) {
let regions = self.fold_regions();
let row = self.editor.cursor.row;
if let Some((header, _)) = innermost_fold_at(®ions, row) {
self.editor.folds_mut().close(header);
self.snap_cursor_out_of_fold(®ions);
}
}
/// `zR` — open every fold in the buffer.
pub(crate) fn fold_open_all(&mut self) {
self.editor.folds_mut().clear();
}
/// `zM` — close every fold in the buffer.
pub(crate) fn fold_close_all(&mut self) {
let regions = self.fold_regions();
self.editor.folds_mut().close_all(®ions);
self.snap_cursor_out_of_fold(®ions);
}
}
#[cfg(test)]
mod tests {
use crate::app::App;
use crate::editor::Cursor;
/// Minimal `App` over a scratch buffer with `src` as its content.
/// The loader has no grammar dirs, so no highlighter attaches and
/// folding exercises the indentation-based fallback.
fn app_with_lines(src: &[&str]) -> App {
let config = crate::config::Config::load(None).expect("default config loads");
let loader =
crate::syntax::Loader::new(std::path::PathBuf::new(), std::path::PathBuf::new());
let (tx, _rx) = std::sync::mpsc::channel();
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let mut app = App::new(config, loader, tx, cwd);
app.active_doc_mut().lines = src.iter().map(|s| s.to_string()).collect();
app
}
#[test]
fn za_collapses_block_and_snaps_cursor_to_header() {
let mut app = app_with_lines(&["fn f() {", " a;", " b;", "}"]);
// Cursor inside the body; toggling should fold (0,2) and pull
// the cursor up to the header.
app.editor.cursor = Cursor { row: 2, col: 0 };
app.fold_toggle_at_cursor();
let hidden = app.hidden_fold_rows();
assert!(hidden.contains(&1) && hidden.contains(&2), "body hidden");
assert!(
!hidden.contains(&0) && !hidden.contains(&3),
"header/tail visible"
);
assert_eq!(app.editor.cursor.row, 0, "cursor snapped to header");
// Toggling again expands.
app.fold_toggle_at_cursor();
assert!(app.hidden_fold_rows().is_empty());
}
#[test]
fn close_all_then_open_all() {
let mut app = app_with_lines(&["fn f() {", " a;", "}", "fn g() {", " b;", "}"]);
app.fold_close_all();
assert!(!app.hidden_fold_rows().is_empty(), "zM collapses folds");
app.fold_open_all();
assert!(app.hidden_fold_rows().is_empty(), "zR opens all folds");
}
#[test]
fn move_down_skips_hidden_rows() {
let mut app = app_with_lines(&["fn f() {", " a;", " b;", "}", "after"]);
app.editor.cursor = Cursor { row: 0, col: 0 };
app.fold_close_at_cursor(); // fold (0,2)
let hidden = app.hidden_fold_rows();
let r = app.editor.doc.clone();
let doc = app.documents.get(&r).unwrap();
// From the header, j should jump over the hidden body to row 3.
app.editor.move_down_folding(doc, &hidden);
assert_eq!(app.editor.cursor.row, 3);
}
}