Skip to content

Commit b916d2e

Browse files
committed
Merge folding: heading folding (roadmap feature #4)
2 parents d842f9d + 39d6dcd commit b916d2e

8 files changed

Lines changed: 842 additions & 18 deletions

File tree

src/core.rs

Lines changed: 363 additions & 1 deletion
Large diffs are not rendered by default.

src/doc_layout.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -965,6 +965,10 @@ impl DocLayout {
965965
// whole document (the headless/first-open path).
966966
anchor_line: usize,
967967
viewport_h: f32,
968+
// Sorted, disjoint line ranges to collapse to zero height (heading folds). Hidden
969+
// lines are skipped: never materialized, `heights[i] = 0`, so the prefix-sum `tops`
970+
// ties across them and the whole virtualization stack excludes them for free.
971+
folds: &[Range<usize>],
968972
) -> Self {
969973
let LayoutParams {
970974
content_x0,
@@ -1036,6 +1040,7 @@ impl DocLayout {
10361040
let mut past_band = false; // true once the band is tall enough; tail is estimated
10371041
let mut measured_start = n; // first materialized line (n = none materialized yet)
10381042
let mut measured_count = n; // first estimated tail line (n = materialized to end)
1043+
let mut fold_idx = 0usize; // moving cursor into the sorted `folds` ranges
10391044
let mut preedit_caret: Option<(usize, usize)> = None;
10401045
// Bucket inline styles per line once (O(n + styles)) instead of the O(n²)
10411046
// per-line `styles_in_range` scan — the dominant per-keystroke cost on large
@@ -1082,6 +1087,25 @@ impl DocLayout {
10821087
// `i` indexes several parallel per-line inputs (styles, markers, diff).
10831088
#[allow(clippy::needless_range_loop)]
10841089
for i in 0..n {
1090+
// Folded line: zero height, no materialization, no band accounting. Placed
1091+
// before the estimate/band logic so a fold never perturbs the measured band.
1092+
while fold_idx < folds.len() && folds[fold_idx].end <= i {
1093+
fold_idx += 1;
1094+
}
1095+
if fold_idx < folds.len() && folds[fold_idx].start <= i {
1096+
heights.push(0.0);
1097+
layouts.push(empty_layout.clone());
1098+
renders.push(empty_render.clone());
1099+
line_ranges.push(snapshot.line_byte_range(i));
1100+
line_diffs.push(LineDiff::default());
1101+
ghosts.push(Vec::new());
1102+
ghost_height.push(0.0);
1103+
quote_bars.push(Vec::new());
1104+
image_blocks.push(None);
1105+
inline_draws.push(Vec::new());
1106+
table_lines.push(None);
1107+
continue;
1108+
}
10851109
// Estimate the head (above the band) and the tail (once the band has covered
10861110
// the viewport + overscan); materialize only the band in between.
10871111
if i >= measure_from_line && !past_band && measured_start == n {
@@ -1403,6 +1427,23 @@ impl DocLayout {
14031427
.min(n - 1)
14041428
}
14051429

1430+
/// Screen-space top y (device px) of line `i`'s real text, or `None` if out of
1431+
/// range. Used to place fold chevrons in the gutter beside heading lines.
1432+
pub fn line_top_screen(&self, line: usize) -> Option<f32> {
1433+
(line < self.layouts.len()).then(|| self.real_top(line) - self.scroll_y)
1434+
}
1435+
1436+
/// Height (device px) of line `i`'s laid-out text block, for vertically centering
1437+
/// gutter affordances against it. Zero for estimated/hidden lines.
1438+
pub fn line_text_height(&self, line: usize) -> f32 {
1439+
self.layouts.get(line).map(|l| l.height()).unwrap_or(0.0)
1440+
}
1441+
1442+
/// Left draw origin (device px) of the document body — the gutter ends here.
1443+
pub fn body_left(&self) -> f32 {
1444+
self.pad_x
1445+
}
1446+
14061447
/// Top y (device px, before scroll) of the *real* text of line `i` — i.e. below
14071448
/// any ghost (deleted) rows stacked above it.
14081449
fn real_top(&self, line: usize) -> f32 {
@@ -2277,6 +2318,7 @@ mod tests {
22772318
None,
22782319
0,
22792320
f32::INFINITY,
2321+
&[],
22802322
);
22812323
// Several buffer offsets (incl. second line) should map to a caret rect
22822324
// whose center hit-tests back to the same offset.
@@ -2289,6 +2331,55 @@ mod tests {
22892331
}
22902332
}
22912333

2334+
/// Folding integration: hidden lines get zero height, so `tops` ties across the
2335+
/// folded run, `content_height` drops by exactly the folded lines' heights, and a
2336+
/// click just below the fold resolves to the first visible line after it. This is
2337+
/// the load-bearing virtualization invariant the plan flags as riskiest.
2338+
#[test]
2339+
fn folded_lines_are_zero_height() {
2340+
use crate::buffer::Buffer;
2341+
let mut engine = TextEngine::new();
2342+
let theme = EditorTheme::dracula();
2343+
let mut buffer: Buffer = "line0\nline1\nline2\nline3\nline4\n".parse().unwrap();
2344+
let snapshot = buffer.render_snapshot();
2345+
let params = test_params(&theme, 1200.0);
2346+
let build = |engine: &mut TextEngine, folds: &[Range<usize>]| {
2347+
DocLayout::build(
2348+
engine,
2349+
&mut LineCache::new(),
2350+
&mut RenderCache::new(),
2351+
&mut HeightCache::new(),
2352+
&mut TableCache::new(),
2353+
0,
2354+
&snapshot,
2355+
&theme,
2356+
None,
2357+
None,
2358+
&ImageCache::new(),
2359+
0,
2360+
&params,
2361+
None,
2362+
0,
2363+
f32::INFINITY,
2364+
folds,
2365+
)
2366+
};
2367+
let full = build(&mut engine, &[]);
2368+
let folded = build(&mut engine, &[1..3]); // hide lines 1 and 2
2369+
2370+
// Lines 1 and 2 collapse: their tops tie, and content height drops by exactly
2371+
// the height those two lines occupied in the unfolded layout.
2372+
assert_eq!(folded.tops[1], folded.tops[2]);
2373+
assert_eq!(folded.tops[2], folded.tops[3]);
2374+
let hidden_h = full.tops[3] - full.tops[1];
2375+
assert!((full.content_height() - folded.content_height() - hidden_h).abs() < 1e-3);
2376+
2377+
// A click at the y where line 3 now sits resolves to line 3 (not a hidden line).
2378+
let y3 = folded.line_top_screen(3).expect("line 3 visible") + 1.0;
2379+
let hit = folded.hit_test(folded.pad_x + 1.0, y3).expect("hit");
2380+
assert_eq!(folded.line_of(hit), 3);
2381+
}
2382+
22922383
/// The outline-panel inset: shrinking the horizontal content region to
22932384
/// `content_w = W - OUTLINE_WIDTH` narrows the doc's draw origin / body width and
22942385
/// caps `self.width` at the region edge (not the window edge), so full-width diff
@@ -2333,6 +2424,7 @@ mod tests {
23332424
None,
23342425
0,
23352426
f32::INFINITY,
2427+
&[],
23362428
)
23372429
};
23382430
let doc = build(&params);
@@ -2394,6 +2486,7 @@ mod tests {
23942486
None,
23952487
0,
23962488
f32::INFINITY,
2489+
&[],
23972490
);
23982491

23992492
// A mid-document line: its top pins to the viewport top exactly.
@@ -2444,6 +2537,7 @@ mod tests {
24442537
None,
24452538
0,
24462539
f32::INFINITY,
2540+
&[],
24472541
);
24482542
// Line 0's changed version has a deleted ghost stacked above it.
24492543
assert!(doc.ghost_height[0] > 0.0, "expected a ghost above line 0");
@@ -2490,6 +2584,7 @@ mod tests {
24902584
None,
24912585
0,
24922586
f32::INFINITY,
2587+
&[],
24932588
);
24942589
assert!(
24952590
!doc.trailing_ghosts.is_empty(),
@@ -2535,6 +2630,7 @@ mod tests {
25352630
None,
25362631
0,
25372632
f32::INFINITY,
2633+
&[],
25382634
)
25392635
};
25402636
let t0 = Instant::now();
@@ -2589,6 +2685,7 @@ mod tests {
25892685
None,
25902686
0,
25912687
f32::INFINITY,
2688+
&[],
25922689
)
25932690
};
25942691

@@ -2641,6 +2738,7 @@ mod tests {
26412738
None,
26422739
0, // anchor at the top
26432740
viewport_h,
2741+
&[],
26442742
);
26452743
let n = doc.line_count();
26462744
assert!(n >= 3000);
@@ -2706,6 +2804,7 @@ mod tests {
27062804
None,
27072805
anchor,
27082806
viewport_h,
2807+
&[],
27092808
);
27102809
let n = doc.line_count();
27112810
// Head virtualized: the band starts near the anchor, not at line 0.
@@ -2897,6 +2996,7 @@ mod tests {
28972996
None,
28982997
0,
28992998
f32::INFINITY,
2999+
&[],
29003000
);
29013001
// Table lines are the header/delimiter/body rows.
29023002
for (line, want) in [
@@ -2960,6 +3060,7 @@ mod tests {
29603060
None,
29613061
0,
29623062
f32::INFINITY,
3063+
&[],
29633064
)
29643065
};
29653066
// Cursor off the table (past the end): grid mode, header row hidden + flagged.
@@ -3006,6 +3107,7 @@ mod tests {
30063107
None,
30073108
0,
30083109
f32::INFINITY,
3110+
&[],
30093111
)
30103112
}
30113113

src/fold.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
//! Heading-based folding (v1): pure geometry over the document's ATX headings.
2+
//!
3+
//! Folding never mutates the buffer — it is session UI state living on the `Editor`
4+
//! as a set of folded heading *byte offsets*. This module derives, from that set and
5+
//! the current headings, which lines the layout should collapse to zero height. Byte
6+
//! offsets (not line numbers) anchor a fold because a single-splice edit remaps them
7+
//! exactly; the derivation here re-reads live headings each frame, so a fold's reach
8+
//! tracks edits automatically. List-indent folding is a deliberate follow-up — this
9+
//! model generalizes (a fold is any block anchored at a start offset); only the
10+
//! extent derivation below is heading-specific.
11+
12+
use std::collections::HashSet;
13+
use std::ops::Range;
14+
15+
use crate::marker::HeadingInfo;
16+
17+
/// Lines hidden when `headings[idx]` folds: from the line after the heading down to
18+
/// (but not including) the next heading of the same-or-higher level, else EOF.
19+
/// Half-open `[start, end)`; `start == end` when the heading has no body.
20+
pub fn heading_extent(headings: &[HeadingInfo], idx: usize, line_count: usize) -> Range<usize> {
21+
let h = &headings[idx];
22+
let start = h.line + 1;
23+
let mut end = line_count;
24+
for next in &headings[idx + 1..] {
25+
if next.level <= h.level {
26+
end = next.line;
27+
break;
28+
}
29+
}
30+
start..end.max(start)
31+
}
32+
33+
/// Whether folding this heading would hide at least one line (drives chevron display).
34+
pub fn heading_is_foldable(headings: &[HeadingInfo], idx: usize, line_count: usize) -> bool {
35+
let r = heading_extent(headings, idx, line_count);
36+
r.end > r.start
37+
}
38+
39+
/// Merged, sorted hidden-line ranges for every folded heading. Nested folds produce
40+
/// subset ranges, so the coalesce below collapses them into the enclosing range.
41+
pub fn hidden_line_ranges(
42+
headings: &[HeadingInfo],
43+
folded: &HashSet<usize>,
44+
line_count: usize,
45+
) -> Vec<Range<usize>> {
46+
if folded.is_empty() {
47+
return Vec::new();
48+
}
49+
let mut ranges: Vec<Range<usize>> = headings
50+
.iter()
51+
.enumerate()
52+
.filter(|(_, h)| folded.contains(&h.byte_offset))
53+
.map(|(i, _)| heading_extent(headings, i, line_count))
54+
.filter(|r| r.end > r.start)
55+
.collect();
56+
ranges.sort_by_key(|r| r.start);
57+
let mut merged: Vec<Range<usize>> = Vec::with_capacity(ranges.len());
58+
for r in ranges {
59+
match merged.last_mut() {
60+
Some(last) if r.start <= last.end => last.end = last.end.max(r.end),
61+
_ => merged.push(r),
62+
}
63+
}
64+
merged
65+
}
66+
67+
/// Index of the heading whose section contains `line`: the nearest heading at or
68+
/// before `line`. `None` when `line` precedes the first heading.
69+
pub fn section_heading(headings: &[HeadingInfo], line: usize) -> Option<usize> {
70+
headings.iter().rposition(|h| h.line <= line)
71+
}
72+
73+
#[cfg(test)]
74+
mod tests {
75+
use super::*;
76+
77+
fn h(level: u8, line: usize) -> HeadingInfo {
78+
HeadingInfo {
79+
level,
80+
text: String::new(),
81+
line,
82+
// Unique, monotonic offsets so tests can fold by offset unambiguously.
83+
byte_offset: line * 100,
84+
}
85+
}
86+
87+
#[test]
88+
fn extent_stops_at_same_level_sibling() {
89+
// # A(0) body(1..2) # B(3)
90+
let hs = [h(1, 0), h(1, 3)];
91+
assert_eq!(heading_extent(&hs, 0, 10), 1..3);
92+
assert_eq!(heading_extent(&hs, 1, 10), 4..10); // last heading → EOF
93+
}
94+
95+
#[test]
96+
fn extent_swallows_deeper_subheadings() {
97+
// # A(0) ## B(2) ### C(4) # D(6)
98+
let hs = [h(1, 0), h(2, 2), h(3, 4), h(1, 6)];
99+
assert_eq!(heading_extent(&hs, 0, 10), 1..6); // A swallows B and C
100+
assert_eq!(heading_extent(&hs, 1, 10), 3..6); // B swallows C, stops at D
101+
assert_eq!(heading_extent(&hs, 3, 10), 7..10);
102+
}
103+
104+
#[test]
105+
fn extent_empty_when_no_body() {
106+
// # A(0) immediately followed by # B(1)
107+
let hs = [h(1, 0), h(1, 1)];
108+
assert_eq!(heading_extent(&hs, 0, 5), 1..1);
109+
assert!(!heading_is_foldable(&hs, 0, 5));
110+
assert!(heading_is_foldable(&hs, 1, 5));
111+
}
112+
113+
#[test]
114+
fn nested_folds_coalesce() {
115+
let hs = [h(1, 0), h(2, 2), h(3, 4), h(1, 6)];
116+
// Folding both A and its child B: A's range 1..6 subsumes B's 3..6.
117+
let folded: HashSet<usize> = [hs[0].byte_offset, hs[1].byte_offset].into();
118+
assert_eq!(hidden_line_ranges(&hs, &folded, 10), vec![1..6]);
119+
}
120+
121+
#[test]
122+
fn disjoint_folds_stay_separate() {
123+
let hs = [h(1, 0), h(1, 3), h(1, 6)];
124+
let folded: HashSet<usize> = [hs[0].byte_offset, hs[2].byte_offset].into();
125+
assert_eq!(hidden_line_ranges(&hs, &folded, 10), vec![1..3, 7..10]);
126+
}
127+
128+
#[test]
129+
fn section_heading_finds_enclosing() {
130+
let hs = [h(1, 0), h(2, 4), h(1, 8)];
131+
assert_eq!(section_heading(&hs, 0), Some(0));
132+
assert_eq!(section_heading(&hs, 5), Some(1));
133+
assert_eq!(section_heading(&hs, 9), Some(2));
134+
}
135+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pub mod cursor;
3434
pub mod diff;
3535
pub mod doc_layout;
3636
pub mod editor;
37+
pub mod fold;
3738
#[cfg(feature = "git")]
3839
pub mod git;
3940
#[cfg(feature = "github")]

src/markdown_view.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ impl MarkdownView {
130130
None,
131131
0,
132132
f32::INFINITY,
133+
&[],
133134
);
134135
self.doc = Some(doc);
135136
self.laid_out = Some((width, scale));

0 commit comments

Comments
 (0)