467 lines
18 KiB
Rust
467 lines
18 KiB
Rust
// fold_view.rs --- The visible-line map (Arc 6, Stage 2).
|
||
|
||
//! The source-line ↔ display-row projection that folding introduces.
|
||
//!
|
||
//! Before folding, every grid consumer assumed `display_row =
|
||
//! source_line − view_top` — an identity map baked into the text walk,
|
||
//! the gutter, every overlay, the caret, both selection painters, the
|
||
//! mode-line indicator, and the click/scroll/motion inverses.
|
||
//! [`DisplayCoord`](crate::view::DisplayCoord) anticipated a
|
||
//! non-identity map "once virtual lines, wrapping, and inline
|
||
//! expansions appear"; **folding is the first**.
|
||
//!
|
||
//! This module is that map — **one derivation/query primitive**
|
||
//! (`docs/folding-stage2-framing.md` Q#FD12), derived from
|
||
//! [`crate::fold::FoldRegistry::folds`] plus the buffer's line offsets
|
||
//! and **never stored**: the byte-range store in [`crate::fold`] stays
|
||
//! the single source of truth. Instances are short-lived and built
|
||
//! **per rendered window** and **per command/event operation**, never
|
||
//! once per frame — a frame paints several windows that may show
|
||
//! different buffers, so a per-frame singleton would leak one pane's
|
||
//! folds into another's (framing round-2 F2).
|
||
//!
|
||
//! # Hidden components, not folds
|
||
//!
|
||
//! The unit here is not a fold. [`crate::fold::FoldStore::insert`]
|
||
//! accepts any normalized range, so folds may nest, share a head line,
|
||
//! or **cross**: with fold `A` hiding lines 1–3 and fold `B` headed on
|
||
//! line 2 hiding lines 3–5, a point on line 5 is directly inside only
|
||
//! `B` — yet `B`'s own head is hidden by `A`, so projecting to `B`'s
|
||
//! `range.start` would land on another *hidden* position (round-3 F2).
|
||
//!
|
||
//! The derivation therefore unions overlapping **or adjacent** hidden
|
||
//! line intervals into sorted, non-overlapping **hidden components**.
|
||
//! Adjacent intervals merge because the later fold's head is hidden by
|
||
//! the earlier one, so it can never render. Each component keeps the one
|
||
//! visible line immediately before it (`head_line`) and that line's exact
|
||
//! end-of-content byte (`head_position` — the fold `range.start` Stage 1
|
||
//! already moves point to). Resolving through the component is
|
||
//! equivalent to repeatedly projecting a hidden fold head until it is
|
||
//! visible, and so covers nesting, shared heads, and crossing overlap
|
||
//! alike.
|
||
|
||
use pmacs_protocol::ByteRange;
|
||
|
||
use crate::fold::FoldRegistry;
|
||
use crate::rope::Position;
|
||
use crate::window::Window;
|
||
|
||
/// The visible-line map for one **window's** buffer, or `None` when that
|
||
/// buffer has no folds.
|
||
///
|
||
/// The single construction rule shared by the render path and
|
||
/// `EditorCore` (Q#FD12): keyed on *this* window's `buffer_id` and its
|
||
/// own [`TextView`](crate::text_view::TextView) line offsets — never the
|
||
/// active buffer's — so a split showing two buffers gets two independent
|
||
/// maps and neither leaks into the other (round-2 F2). Returning `None`
|
||
/// rather than an empty map keeps the unfolded path byte-identical.
|
||
#[must_use]
|
||
pub fn map_for_window(registry: &FoldRegistry, window: &Window) -> Option<VisibleLineMap> {
|
||
let folds = registry.folds(window.buffer_id);
|
||
if folds.is_empty() {
|
||
return None;
|
||
}
|
||
Some(VisibleLineMap::build(&folds, |off| {
|
||
window.text_view.line_at_offset(off)
|
||
}))
|
||
}
|
||
|
||
/// A maximal run of consecutive hidden source lines, plus the one
|
||
/// visible line that heads it.
|
||
///
|
||
/// `first_hidden >= 1` always: a component's `head_line` is
|
||
/// `first_hidden - 1`, and a fold's head line is the line *above* its
|
||
/// first hidden line, so line 0 can never be hidden.
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
struct HiddenComponent {
|
||
/// First hidden source line (inclusive).
|
||
first_hidden: usize,
|
||
/// Last hidden source line (inclusive).
|
||
last_hidden: usize,
|
||
/// End-of-content byte of `head_line` — the `ByteRange::start` of
|
||
/// the earliest fold participating in this component, which is
|
||
/// exactly where Stage 1 moves point on a fold-at-cursor.
|
||
head_position: Position,
|
||
}
|
||
|
||
impl HiddenComponent {
|
||
/// The one visible line immediately above this component.
|
||
const fn head_line(&self) -> usize {
|
||
self.first_hidden - 1
|
||
}
|
||
}
|
||
|
||
/// A buffer's collapsed regions, projected into line space.
|
||
///
|
||
/// Derived from a fold list and a byte→line lookup; cheap enough to
|
||
/// rebuild per window per frame (**Bet B4**: `O(folds)` with one binary
|
||
/// search into the caller's existing line-offset table per fold, and
|
||
/// folds are `O(top-level blocks)`).
|
||
///
|
||
/// An empty map (`is_identity`) means "no folds" — callers pass `None`
|
||
/// rather than an empty map so the unfolded path stays byte-identical.
|
||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||
pub struct VisibleLineMap {
|
||
/// Sorted by `first_hidden`, non-overlapping, and separated by at
|
||
/// least one visible line (adjacency is merged away at build time).
|
||
components: Vec<HiddenComponent>,
|
||
}
|
||
|
||
impl VisibleLineMap {
|
||
/// Derive the map from a buffer's folds.
|
||
///
|
||
/// `line_at_offset` is the caller's own line-offset lookup (the
|
||
/// rendering window's [`TextView`](crate::text_view::TextView), the
|
||
/// only table guaranteed to agree with the rows being painted). A
|
||
/// fold's stored range is `[end of head line, end of last hidden
|
||
/// line]`, so `head_line = line_at_offset(start)` and `last_hidden =
|
||
/// line_at_offset(end)`; a fold that no longer spans a whole line
|
||
/// (mid-edit drift) contributes nothing.
|
||
#[must_use]
|
||
pub fn build<F>(folds: &[ByteRange], line_at_offset: F) -> Self
|
||
where
|
||
F: Fn(Position) -> usize,
|
||
{
|
||
let mut raw: Vec<HiddenComponent> = folds
|
||
.iter()
|
||
.filter_map(|f| {
|
||
let head_line = line_at_offset(f.start);
|
||
let last_hidden = line_at_offset(f.end);
|
||
(last_hidden > head_line).then_some(HiddenComponent {
|
||
first_hidden: head_line + 1,
|
||
last_hidden,
|
||
head_position: f.start,
|
||
})
|
||
})
|
||
.collect();
|
||
raw.sort_by(|a, b| {
|
||
a.first_hidden
|
||
.cmp(&b.first_hidden)
|
||
.then(a.last_hidden.cmp(&b.last_hidden))
|
||
});
|
||
let mut components: Vec<HiddenComponent> = Vec::with_capacity(raw.len());
|
||
for c in raw {
|
||
match components.last_mut() {
|
||
// Overlapping OR adjacent: `c`'s head line is itself
|
||
// hidden by `prev`, so it can never render — the merged
|
||
// component keeps `prev`'s (visible) head.
|
||
Some(prev) if c.first_hidden <= prev.last_hidden + 1 => {
|
||
prev.last_hidden = prev.last_hidden.max(c.last_hidden);
|
||
}
|
||
_ => components.push(c),
|
||
}
|
||
}
|
||
Self { components }
|
||
}
|
||
|
||
/// Whether this map hides nothing — the identity projection.
|
||
#[must_use]
|
||
pub fn is_identity(&self) -> bool {
|
||
self.components.is_empty()
|
||
}
|
||
|
||
/// The component hiding `line`, if any.
|
||
fn component_of(&self, line: usize) -> Option<&HiddenComponent> {
|
||
let after = self.components.partition_point(|c| c.first_hidden <= line);
|
||
let c = self.components.get(after.checked_sub(1)?)?;
|
||
(line <= c.last_hidden).then_some(c)
|
||
}
|
||
|
||
/// Whether `line` is collapsed away and renders no row.
|
||
#[must_use]
|
||
pub fn is_hidden(&self, line: usize) -> bool {
|
||
self.component_of(line).is_some()
|
||
}
|
||
|
||
/// Whether `line` is the visible head of a collapsed region — the
|
||
/// row that carries the ellipsis and the gutter fold glyph.
|
||
#[must_use]
|
||
pub fn is_head(&self, line: usize) -> bool {
|
||
self.components
|
||
.binary_search_by(|c| c.first_hidden.cmp(&(line + 1)))
|
||
.is_ok()
|
||
}
|
||
|
||
/// The **outermost visible head** of `line`: for a hidden line, its
|
||
/// component's head line; for a visible line, itself.
|
||
///
|
||
/// The **row-only** clamp — diagnostic signs, the relative-number
|
||
/// cursor anchor, and the backward `view_top` clamp. Positions that
|
||
/// carry a column use [`Self::visible_position`] instead.
|
||
#[must_use]
|
||
pub fn visible_head_of(&self, line: usize) -> usize {
|
||
self.component_of(line)
|
||
.map_or(line, HiddenComponent::head_line)
|
||
}
|
||
|
||
/// The **position** projection of a byte on `line`: for a hidden
|
||
/// line, its component's `head_position` (the head line's
|
||
/// end-of-content byte); for a visible line, `pos` unchanged.
|
||
///
|
||
/// Used wherever a clamp carries a column — the local caret, peer
|
||
/// cursors, and selection endpoints — so a hidden point lands at the
|
||
/// head's end of content rather than at an arbitrary column on the
|
||
/// head (round-2 F3) or at a still-hidden crossing fold's start
|
||
/// (round-3 F2).
|
||
#[must_use]
|
||
pub fn visible_position(&self, line: usize, pos: Position) -> Position {
|
||
self.component_of(line).map_or(pos, |c| c.head_position)
|
||
}
|
||
|
||
/// Clamp a candidate `view_top` **backward** to a visible line, so a
|
||
/// fold at the top of the viewport shows its head rather than being
|
||
/// skipped past (framing acceptance 8).
|
||
#[must_use]
|
||
pub fn clamp_view_top(&self, line: usize) -> usize {
|
||
self.visible_head_of(line)
|
||
}
|
||
|
||
/// The next visible line strictly after `line`, skipping whole
|
||
/// collapsed regions. May exceed the buffer's line count; callers
|
||
/// bound it themselves.
|
||
#[must_use]
|
||
pub fn next_visible(&self, line: usize) -> usize {
|
||
let next = line + 1;
|
||
self.component_of(next)
|
||
.map_or(next, |c| c.last_hidden.saturating_add(1))
|
||
}
|
||
|
||
/// The previous visible line strictly before `line`, or `0` when
|
||
/// `line` is already the first line.
|
||
#[must_use]
|
||
pub fn prev_visible(&self, line: usize) -> usize {
|
||
match line.checked_sub(1) {
|
||
Some(prev) => self.visible_head_of(prev),
|
||
None => 0,
|
||
}
|
||
}
|
||
|
||
/// Number of visible lines in the half-open range `[from, to)`;
|
||
/// `0` when `to <= from`.
|
||
///
|
||
/// This is the framing's `visible_between` — exposed unsigned and
|
||
/// half-open (plus the symmetric [`Self::visible_distance`]) because
|
||
/// no consumer reads the sign: row offsets always measure forward
|
||
/// from `view_top`, and relative line numbers want a magnitude.
|
||
#[must_use]
|
||
pub fn visible_rows_between(&self, from: usize, to: usize) -> usize {
|
||
if to <= from {
|
||
return 0;
|
||
}
|
||
(to - from) - self.hidden_in(from, to)
|
||
}
|
||
|
||
/// Visible-line distance between `a` and `b`, either order — the
|
||
/// relative/hybrid gutter number measured across collapses.
|
||
#[must_use]
|
||
pub fn visible_distance(&self, a: usize, b: usize) -> usize {
|
||
if a <= b {
|
||
self.visible_rows_between(a, b)
|
||
} else {
|
||
self.visible_rows_between(b, a)
|
||
}
|
||
}
|
||
|
||
/// Hidden lines within the half-open range `[from, to)`.
|
||
fn hidden_in(&self, from: usize, to: usize) -> usize {
|
||
self.components
|
||
.iter()
|
||
.filter(|c| c.first_hidden < to && c.last_hidden >= from)
|
||
.map(|c| {
|
||
// `lo <= hi` holds under the filter, so this cannot
|
||
// underflow.
|
||
let lo = c.first_hidden.max(from);
|
||
let hi = c.last_hidden.min(to - 1);
|
||
hi + 1 - lo
|
||
})
|
||
.sum()
|
||
}
|
||
|
||
/// Total visible lines in a buffer of `total_lines` source lines —
|
||
/// the denominator the mode-line scroll indicator reckons in.
|
||
#[must_use]
|
||
pub fn visible_line_count(&self, total_lines: usize) -> usize {
|
||
total_lines - self.hidden_in(0, total_lines).min(total_lines)
|
||
}
|
||
|
||
/// The line `n` visible steps forward from `from` (which is first
|
||
/// normalized to its visible head). `n == 0` yields that head.
|
||
#[must_use]
|
||
pub fn nth_visible_from(&self, from: usize, n: usize) -> usize {
|
||
let mut line = self.visible_head_of(from);
|
||
for _ in 0..n {
|
||
line = self.next_visible(line);
|
||
}
|
||
line
|
||
}
|
||
|
||
/// The line `n` visible steps back from `from` (first normalized to
|
||
/// its visible head), saturating at line 0.
|
||
#[must_use]
|
||
pub fn nth_visible_back(&self, from: usize, n: usize) -> usize {
|
||
let mut line = self.visible_head_of(from);
|
||
for _ in 0..n {
|
||
if line == 0 {
|
||
break;
|
||
}
|
||
line = self.prev_visible(line);
|
||
}
|
||
line
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// A 40-line buffer of `"L<n>\n"`-ish rows, 8 bytes each, so line
|
||
/// `n` starts at `8n` and its content ends at `8n + 7`.
|
||
fn line_of(offset: Position) -> usize {
|
||
(offset / 8) as usize
|
||
}
|
||
|
||
/// The fold that hides lines `first..=last` in that fixture.
|
||
fn fold(head: usize, last_hidden: usize) -> ByteRange {
|
||
ByteRange {
|
||
start: (head as u64) * 8 + 7,
|
||
end: (last_hidden as u64) * 8 + 7,
|
||
}
|
||
}
|
||
|
||
fn map(folds: &[ByteRange]) -> VisibleLineMap {
|
||
VisibleLineMap::build(folds, line_of)
|
||
}
|
||
|
||
#[test]
|
||
fn empty_map_is_identity() {
|
||
let m = map(&[]);
|
||
assert!(m.is_identity());
|
||
assert!(!m.is_hidden(5));
|
||
assert_eq!(m.visible_head_of(5), 5);
|
||
assert_eq!(m.next_visible(5), 6);
|
||
assert_eq!(m.visible_rows_between(0, 10), 10);
|
||
}
|
||
|
||
#[test]
|
||
fn single_fold_hides_its_interior_only() {
|
||
// head 2, hidden 3..=6.
|
||
let m = map(&[fold(2, 6)]);
|
||
assert!(!m.is_hidden(2));
|
||
assert!(m.is_head(2));
|
||
for line in 3..=6 {
|
||
assert!(m.is_hidden(line), "line {line} should be hidden");
|
||
assert_eq!(m.visible_head_of(line), 2);
|
||
}
|
||
assert!(!m.is_hidden(7));
|
||
assert_eq!(m.next_visible(2), 7);
|
||
assert_eq!(m.prev_visible(7), 2);
|
||
// 0,1,2,7,8,9 visible in [0,10).
|
||
assert_eq!(m.visible_rows_between(0, 10), 6);
|
||
assert_eq!(m.visible_line_count(10), 6);
|
||
}
|
||
|
||
#[test]
|
||
fn nested_folds_resolve_to_the_outermost_visible_head() {
|
||
// Outer: head 0, hidden 1..=9. Inner: head 3, hidden 4..=6.
|
||
let m = map(&[fold(0, 9), fold(3, 6)]);
|
||
assert_eq!(m.visible_head_of(5), 0, "inner head 3 is itself hidden");
|
||
assert_eq!(m.visible_head_of(3), 0);
|
||
assert!(m.is_head(0));
|
||
assert!(!m.is_head(3), "a hidden head renders no row");
|
||
assert_eq!(m.next_visible(0), 10);
|
||
}
|
||
|
||
#[test]
|
||
fn shared_head_folds_merge_to_the_longer_reach() {
|
||
// Two folds on head 4: one hides 5..=6, the other 5..=9.
|
||
let m = map(&[fold(4, 6), fold(4, 9)]);
|
||
assert_eq!(m.visible_head_of(9), 4);
|
||
assert_eq!(m.next_visible(4), 10);
|
||
assert_eq!(m.visible_position(9, 9 * 8 + 3), fold(4, 6).start);
|
||
}
|
||
|
||
#[test]
|
||
fn crossing_folds_project_to_the_first_visible_head() {
|
||
// Round-3 F2: A hides 1..=3 (head 0); B is headed on line 2 and
|
||
// hides 3..=5. A point on line 5 is directly inside only B, but
|
||
// B's head is hidden by A — it must resolve to A's head.
|
||
let m = map(&[fold(0, 3), fold(2, 5)]);
|
||
assert!(m.is_hidden(5));
|
||
assert_eq!(m.visible_head_of(5), 0);
|
||
assert_eq!(
|
||
m.visible_position(5, 5 * 8 + 4),
|
||
fold(0, 3).start,
|
||
"never B's still-hidden range.start"
|
||
);
|
||
assert_eq!(m.next_visible(0), 6);
|
||
assert!(!m.is_head(2), "B's head is hidden, so it heads nothing");
|
||
}
|
||
|
||
#[test]
|
||
fn adjacent_folds_merge_because_the_later_head_is_hidden() {
|
||
// A hides 1..=3 (head 0); B is headed on line 3 (hidden by A)
|
||
// and hides 4..=5. Lines 1..=5 collapse under head 0.
|
||
let m = map(&[fold(0, 3), fold(3, 5)]);
|
||
for line in 1..=5 {
|
||
assert_eq!(m.visible_head_of(line), 0, "line {line}");
|
||
}
|
||
assert_eq!(m.next_visible(0), 6);
|
||
}
|
||
|
||
#[test]
|
||
fn a_visible_line_between_two_folds_keeps_them_separate() {
|
||
// A hides 1..=3 (head 0); B hides 5..=6 (head 4). Line 4 stays
|
||
// visible, so the components do not merge.
|
||
let m = map(&[fold(0, 3), fold(4, 6)]);
|
||
assert!(!m.is_hidden(4));
|
||
assert_eq!(m.visible_head_of(3), 0);
|
||
assert_eq!(m.visible_head_of(6), 4);
|
||
assert_eq!(m.next_visible(0), 4);
|
||
assert_eq!(m.next_visible(4), 7);
|
||
}
|
||
|
||
#[test]
|
||
fn visible_position_leaves_a_visible_byte_alone() {
|
||
let m = map(&[fold(2, 6)]);
|
||
assert_eq!(m.visible_position(7, 7 * 8 + 2), 7 * 8 + 2);
|
||
}
|
||
|
||
#[test]
|
||
fn clamp_view_top_goes_backward_to_the_head() {
|
||
let m = map(&[fold(2, 6)]);
|
||
assert_eq!(m.clamp_view_top(5), 2);
|
||
assert_eq!(m.clamp_view_top(2), 2);
|
||
assert_eq!(m.clamp_view_top(7), 7);
|
||
}
|
||
|
||
#[test]
|
||
fn visible_distance_is_symmetric_and_skips_folds() {
|
||
let m = map(&[fold(2, 6)]);
|
||
// Visible order: 0,1,2,7,8 — line 8 is 4 visible steps from 0.
|
||
assert_eq!(m.visible_distance(0, 8), 4);
|
||
assert_eq!(m.visible_distance(8, 0), 4);
|
||
assert_eq!(m.visible_distance(2, 7), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn nth_visible_walks_forward_and_back_over_folds() {
|
||
let m = map(&[fold(2, 6)]);
|
||
assert_eq!(m.nth_visible_from(0, 3), 7);
|
||
assert_eq!(m.nth_visible_back(8, 4), 0);
|
||
// A hidden origin normalizes to its head first.
|
||
assert_eq!(m.nth_visible_from(5, 1), 7);
|
||
assert_eq!(m.nth_visible_back(5, 1), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn build_drops_a_fold_that_no_longer_spans_a_line() {
|
||
// start and end inside one line: nothing to hide.
|
||
let degenerate = ByteRange { start: 10, end: 12 };
|
||
assert!(map(&[degenerate]).is_identity());
|
||
}
|
||
}
|