feat(fold): Stage 2 — grid (daemon-rendered) collapse
Implements docs/folding-stage2-framing.md rev 4. The daemon grid renderer now consults the fold store: hidden lines are omitted, rows below shift up, and every consumer that assumed `display_row = source_line - view_top` routes through one shared projection. No wire schema change and no protocol bump (Bet B6) — the collapse is entirely daemon-side; the GPU path is Stage 3. The spine (Q#FD12) is `src/fold_view.rs`: a `VisibleLineMap` derived from `FoldRegistry::folds` plus a window's line offsets and never stored. Its unit is a merged **hidden component** — overlapping OR adjacent hidden intervals unioned, each keeping the one visible `head_line` and that line's exact `head_position`. Adjacent intervals merge because the later fold's head is itself hidden, which is what makes nesting, shared heads, and crossing overlap all resolve to a head that can actually render (round-3 F2). Instances are short-lived and built **per rendered window** and **per command/event operation**, never once per frame: `paint_frame` renders several windows that may show different buffers, so a singleton would leak one pane's folds into another (round-2 F2). The render instance rides on a lifetime-bearing `Viewport<'a>` as `Option<&'a VisibleLineMap>` — a shared ref is `Copy`, so `Viewport` stays `Copy` (Bet B7). Rendering: - `TextView::render` walks visible lines; the head line gets a trailing content-area ellipsis (Q#FD13). - The gutter walks visible lines too: Absolute keeps the raw `line+1`, Relative/Hybrid measure VISIBLE distance anchored on the cursor's visible head (Q#FD14). The fold glyph takes the col-0 sign cell only when a gutter exists — line numbers default to Off, so with no gutter the ellipsis is the sole marker (Q#FD20, round-1 F3). A diagnostic clamped onto the head wins that cell by paint order. - A diagnostic on a hidden line clamps its SIGN to the outermost visible head (most-severe merge); the squiggle needs a real row, so only the sign clamps (Q#FD15). - Caret, local selection endpoints, and peer cursors project via `visible_position_of` — the head row AND the head's end-of-content column, never an arbitrary column (round-2 F3). Peer presence derives the RECIPIENT window's map. - Style/search/completion overlays route through `Viewport::row_offset_of`; the mode-line indicator reckons in visible-line space. Command/event time is scoped per frontend (Q#FD21): a `fold_projection` flag on `FrontendView`, set at attach from the negotiated `semantic_render` bit (grid ⇒ true, semantic ⇒ false until Stage 3, LOCAL ⇒ true) and never inferred from a `FrontendId` (Bet B8). Without it, shared `EditorCore` motion would make a simultaneous unfolded GPU session's cursor skip lines it still displays. The map's two axes stay separate (round-3 F1): the acting frontend supplies the policy, the operation's TARGET window supplies the buffer — a wheel event names a pane without activating it. Motion (Q#FD17, ruled: include), paging, wheel, the click inverse, and the auto-scroll clamp all step by visible lines under that gate; motion from a hidden logical cursor normalizes to the visible head first. `view_top` stays a source-line index (Bet B5), set only via `clamp_view_top` so it never rests hidden. Unfold widening (Q#FD19): the pre-edit unfold moves to the top of `apply_active_edit` — one funnel that subsumes the six primitives' calls and covers yank + query-replace, both of which place point at the edit site first. Interactive Lua mutators hook the common `run_buffer_edit`, above the managed/bypass split, gated on `InteractiveCommandOrigin` AND the edit targeting that frontend's active-window buffer. The remote/optimistic-CRDT path stays excluded (Stage 3); undo/redo unfold stays deferred. Acceptance: `tests/folding_stage2_acceptance.rs`, 35 tests asserting on the real `paint_frame` cell grid, covering framing items 1–14 including crossing folds, a nested deeply-hidden cursor, a split of two different buffers with an inactive-pane wheel, and simultaneous grid+semantic motion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
This commit is contained in:
parent
4222ffae6e
commit
313b1ff77a
|
|
@ -684,7 +684,7 @@ struct PopupRect {
|
|||
/// the viewport or nothing fits.
|
||||
fn resolve_popup_rect(
|
||||
buf: &Buffer,
|
||||
viewport: Viewport,
|
||||
viewport: Viewport<'_>,
|
||||
anchor: Position,
|
||||
rows: &[PopupCandidate],
|
||||
) -> Option<PopupRect> {
|
||||
|
|
@ -700,10 +700,12 @@ fn resolve_popup_rect(
|
|||
let line_offsets = crate::diag::compute_line_offsets(&source);
|
||||
let start_line = crate::diag::line_at_offset(&line_offsets, viewport.buffer_start as u32);
|
||||
let anchor_line = crate::diag::line_at_offset(&line_offsets, anchor);
|
||||
if anchor_line < start_line {
|
||||
return None; // anchor scrolled above the viewport
|
||||
}
|
||||
let anchor_row = anchor_line - start_line;
|
||||
// Arc 6 Stage 2: the popup anchors on the anchor byte's VISIBLE row,
|
||||
// so a completion below a collapsed region lands on the right row;
|
||||
// an anchor inside a collapse has no row and paints nothing.
|
||||
let Some(anchor_row) = viewport.row_offset_of(start_line as usize, anchor_line as usize) else {
|
||||
return None; // anchor scrolled above the viewport, or collapsed
|
||||
};
|
||||
let max_rows = viewport.cell_size.rows;
|
||||
let max_cols = viewport.cell_size.cols;
|
||||
if anchor_row >= max_rows || max_cols == 0 {
|
||||
|
|
@ -820,7 +822,7 @@ impl View for CompletionView {
|
|||
"completion-popup"
|
||||
}
|
||||
|
||||
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||
fn render(&mut self, buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
|
||||
// Snapshot under the lock, then drop it before touching the rope.
|
||||
let (anchor, rows_data, selected_in_window): (Position, Vec<PopupCandidate>, usize) = {
|
||||
let guard = self.popup.lock().expect("completion popup poisoned");
|
||||
|
|
|
|||
|
|
@ -1545,7 +1545,16 @@ fn handle_session_established(
|
|||
// Register the frontend's view (M10.8 Day 3: fresh scratch
|
||||
// buffer view; future milestones may clone LOCAL's view or
|
||||
// take an explicit initial-buffer argument).
|
||||
let scratch_view = build_fresh_frontend_view(editor);
|
||||
//
|
||||
// Arc 6 Stage 2 (Q#FD21): the projection is decided in this same
|
||||
// attach transaction and from the same bit that selects a grid
|
||||
// `RenderState` vs a `SemanticRenderState` below — a grid session
|
||||
// collapses folds, a semantic one keeps raw-line reckoning until
|
||||
// Stage 3.
|
||||
let scratch_view = build_fresh_frontend_view(
|
||||
editor,
|
||||
!session_state.negotiated_capabilities.semantic_render,
|
||||
);
|
||||
editor
|
||||
.core
|
||||
.borrow_mut()
|
||||
|
|
@ -2622,7 +2631,13 @@ fn align_semantic_window_to_buffer(
|
|||
}
|
||||
}
|
||||
|
||||
fn build_fresh_frontend_view(editor: &mut EditorState) -> crate::window::FrontendView {
|
||||
fn build_fresh_frontend_view(
|
||||
editor: &mut EditorState,
|
||||
// Arc 6 Stage 2 (Q#FD21, Bet B8): whether this session's display
|
||||
// collapses folds. Passed explicitly from the negotiated
|
||||
// selected-render bit at the call site — never inferred here.
|
||||
fold_projection: bool,
|
||||
) -> crate::window::FrontendView {
|
||||
use crate::text_view::TextView;
|
||||
use crate::window::{FrontendView, Layout, Window, WindowId};
|
||||
let mut core = editor.core.borrow_mut();
|
||||
|
|
@ -2651,6 +2666,7 @@ fn build_fresh_frontend_view(editor: &mut EditorState) -> crate::window::Fronten
|
|||
FrontendView {
|
||||
layout: Layout::single(id),
|
||||
active: id,
|
||||
fold_projection,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3442,6 +3458,7 @@ mod tests {
|
|||
FrontendView {
|
||||
layout: Layout::single(wid),
|
||||
active: wid,
|
||||
fold_projection: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -435,6 +435,8 @@ pub fn restore_into(
|
|||
FrontendView {
|
||||
layout: Layout { root },
|
||||
active,
|
||||
// Desktop restore rebuilds LOCAL's grid view (Q#FD21).
|
||||
fold_projection: true,
|
||||
},
|
||||
);
|
||||
active
|
||||
|
|
|
|||
38
src/diag.rs
38
src/diag.rs
|
|
@ -493,7 +493,7 @@ impl View for DiagnosticView {
|
|||
"diagnostic"
|
||||
}
|
||||
|
||||
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||
fn render(&mut self, buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
|
||||
// Snapshot the diagnostics under the lock and drop it
|
||||
// immediately so we don't hold the lock through rendering
|
||||
// (rendering touches the rope, which could in principle
|
||||
|
|
@ -557,20 +557,23 @@ impl View for DiagnosticView {
|
|||
if line >= total_lines {
|
||||
break;
|
||||
}
|
||||
if line < start_line_buf {
|
||||
let row_offset = viewport.row_offset_of(start_line_buf as usize, line as usize);
|
||||
let Some(sign_row) = sign_row_for(viewport, start_line_buf, line) else {
|
||||
continue;
|
||||
}
|
||||
let row_offset = line - start_line_buf;
|
||||
if row_offset >= max_rows {
|
||||
};
|
||||
if sign_row >= max_rows {
|
||||
break;
|
||||
}
|
||||
// Record the line marker before any byte-range work:
|
||||
// zero-width ranges (`byte_end <= byte_start` below)
|
||||
// skip the underline but still mark the line.
|
||||
line_markers
|
||||
.entry(row_offset)
|
||||
.entry(sign_row)
|
||||
.and_modify(|s| *s = (*s).min(diag.severity))
|
||||
.or_insert(diag.severity);
|
||||
let Some(row_offset) = row_offset else {
|
||||
continue;
|
||||
};
|
||||
let line_start = line_offsets[line as usize];
|
||||
let line_end = line_offsets
|
||||
.get(line as usize + 1)
|
||||
|
|
@ -625,6 +628,25 @@ impl View for DiagnosticView {
|
|||
}
|
||||
}
|
||||
|
||||
/// The grid row a diagnostic on source `line` marks (Arc 6 Stage 2,
|
||||
/// Q#FD15).
|
||||
///
|
||||
/// Its own row normally; when the line is collapsed away, the fold's
|
||||
/// **outermost visible head** row — a clamp, not a drop, so "there is a
|
||||
/// problem inside this collapsed region" survives the collapse (the
|
||||
/// most-severe-per-row merge then makes the head show the worst severity
|
||||
/// among itself and every line its fold hides). `None` when neither has
|
||||
/// a row in this viewport. Only the *sign* clamps: a squiggle needs a
|
||||
/// real row, so a hidden line contributes no underline.
|
||||
fn sign_row_for(viewport: Viewport<'_>, start_line: u32, line: u32) -> Option<u32> {
|
||||
viewport
|
||||
.row_offset_of(start_line as usize, line as usize)
|
||||
.or_else(|| {
|
||||
let map = viewport.folds?;
|
||||
viewport.row_offset_of(start_line as usize, map.visible_head_of(line as usize))
|
||||
})
|
||||
}
|
||||
|
||||
/// Paint one severity marker per diagnostic line (UX gutter sub-arc 2).
|
||||
///
|
||||
/// When the window reserves a gutter (`gutter_w > 0`), draw the severity
|
||||
|
|
@ -1046,6 +1068,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(1, 10),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
},
|
||||
&mut grid,
|
||||
);
|
||||
|
|
@ -1110,6 +1133,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(3, 10),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
},
|
||||
&mut grid,
|
||||
);
|
||||
|
|
@ -1185,6 +1209,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 2),
|
||||
cell_size: CellSize::new(3, 8),
|
||||
gutter_w: 2,
|
||||
folds: None,
|
||||
},
|
||||
&mut grid,
|
||||
);
|
||||
|
|
@ -1253,6 +1278,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(2, 10),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
},
|
||||
&mut grid,
|
||||
);
|
||||
|
|
|
|||
260
src/editor.rs
260
src/editor.rs
|
|
@ -41,7 +41,7 @@ use crate::protocol::{
|
|||
use crate::terminal::TerminalSnapshot;
|
||||
use crate::terminal::view::TerminalViewKey;
|
||||
use crate::view::{View, Viewport};
|
||||
use crate::window::{Rect, WindowId};
|
||||
use crate::window::{LineNumberMode, Rect, WindowId};
|
||||
|
||||
/// Ephemeral authenticated origin for one interactive command invocation.
|
||||
///
|
||||
|
|
@ -2230,8 +2230,20 @@ impl EditorState {
|
|||
core.set_active_window_id(win_id);
|
||||
let view_top = core.windows[&win_id].view_top;
|
||||
let buffer_id = core.windows[&win_id].buffer_id;
|
||||
let display_row = view_top.saturating_add(local_row as usize);
|
||||
let target = crate::view::DisplayCoord::new(display_row as u32, local_col);
|
||||
// Arc 6 Stage 2 (Q#FD16/FD21): grid row `k` in this window shows
|
||||
// its `k`-th VISIBLE line, so the inverse must walk the same way
|
||||
// — a click can then never land on a collapsed line. The map is
|
||||
// the CLICKED window's (round-3 F1), not the previously active
|
||||
// one's.
|
||||
let folds = core.fold_map_for_window(win_id);
|
||||
let display_row = match folds.as_ref() {
|
||||
Some(map) => map.nth_visible_from(view_top, local_row as usize),
|
||||
None => view_top.saturating_add(local_row as usize),
|
||||
};
|
||||
let Ok(display_row) = u32::try_from(display_row) else {
|
||||
return;
|
||||
};
|
||||
let target = crate::view::DisplayCoord::new(display_row, local_col);
|
||||
let pos = {
|
||||
let registry = core.registry.clone();
|
||||
let reg = registry.borrow();
|
||||
|
|
@ -2263,23 +2275,33 @@ impl EditorState {
|
|||
/// `mouse-wheel-mode` and every modern editor's wheel behaviour.
|
||||
fn scroll_window(&mut self, win_id: WindowId, delta: i32) {
|
||||
let mut core = self.core.borrow_mut();
|
||||
// Arc 6 Stage 2 (Q#FD18/FD21, round-3 F1): a wheel event names
|
||||
// the pane under the pointer and does NOT activate it, so the map
|
||||
// must come from `win_id` — deriving the active window's would
|
||||
// project a folded buffer onto an unfolded neighbour. The
|
||||
// projection *policy* still comes from the acting frontend.
|
||||
let folds = core.fold_map_for_window(win_id);
|
||||
let line_count = core.windows[&win_id].text_view.line_count();
|
||||
let max_top = line_count.saturating_sub(1);
|
||||
let old_top = core.windows[&win_id].view_top;
|
||||
let scroll_up = delta < 0;
|
||||
let magnitude = delta.unsigned_abs() as usize;
|
||||
let new_top = if scroll_up {
|
||||
old_top.saturating_sub(magnitude)
|
||||
} else {
|
||||
old_top.saturating_add(magnitude).min(max_top)
|
||||
let new_top = match folds.as_ref() {
|
||||
Some(map) if scroll_up => map.nth_visible_back(old_top, magnitude),
|
||||
Some(map) => map
|
||||
.nth_visible_from(old_top, magnitude)
|
||||
.min(map.visible_head_of(max_top)),
|
||||
None if scroll_up => old_top.saturating_sub(magnitude),
|
||||
None => old_top.saturating_add(magnitude).min(max_top),
|
||||
};
|
||||
// Effective view delta — buffer-boundary clamping may shrink
|
||||
// the requested move, so the cursor only follows by however
|
||||
// many lines the view actually shifted.
|
||||
let view_shift = if scroll_up {
|
||||
old_top.saturating_sub(new_top)
|
||||
} else {
|
||||
new_top.saturating_sub(old_top)
|
||||
// many lines the view actually shifted (counted in VISIBLE
|
||||
// lines once this window folds).
|
||||
let view_shift = match folds.as_ref() {
|
||||
Some(map) => map.visible_distance(old_top, new_top),
|
||||
None if scroll_up => old_top.saturating_sub(new_top),
|
||||
None => new_top.saturating_sub(old_top),
|
||||
};
|
||||
let buffer_id = core.windows[&win_id].buffer_id;
|
||||
let new_cursor = {
|
||||
|
|
@ -2289,10 +2311,13 @@ impl EditorState {
|
|||
let aw = &core.windows[&win_id];
|
||||
let cur = aw.text_view.pos_to_display(buf, aw.cursor)?;
|
||||
let cur_row = cur.row as usize;
|
||||
let target_row_usize = if scroll_up {
|
||||
cur_row.saturating_sub(view_shift)
|
||||
} else {
|
||||
cur_row.saturating_add(view_shift).min(max_top)
|
||||
let target_row_usize = match folds.as_ref() {
|
||||
Some(map) if scroll_up => map.nth_visible_back(cur_row, view_shift),
|
||||
Some(map) => map
|
||||
.nth_visible_from(cur_row, view_shift)
|
||||
.min(map.visible_head_of(max_top)),
|
||||
None if scroll_up => cur_row.saturating_sub(view_shift),
|
||||
None => cur_row.saturating_add(view_shift).min(max_top),
|
||||
};
|
||||
let target_row = u32::try_from(target_row_usize).ok()?;
|
||||
aw.text_view
|
||||
|
|
@ -2316,6 +2341,12 @@ impl EditorState {
|
|||
/// readline / Emacs default and is what most terminal users expect.
|
||||
const SCROLL_LINES: i32 = 3;
|
||||
|
||||
/// Gutter marker drawn on a collapsed region's head row (Arc 6 Stage 2,
|
||||
/// Q#FD20). Occupies the gutter's leading pad cell — the same cell the
|
||||
/// diagnostic sign uses — so it adds no column and changes no width; it
|
||||
/// therefore only appears when a line-number mode reserves a gutter.
|
||||
const FOLD_GUTTER_GLYPH: char = '▸';
|
||||
|
||||
/// Shared outer/content geometry consumed by terminal paint and PTY resize.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct WindowPlacement {
|
||||
|
|
@ -2860,6 +2891,13 @@ pub fn paint_frame(
|
|||
&& let Some(buf_id) = buf_id
|
||||
&& let Ok(buf) = reg.get(buf_id)
|
||||
{
|
||||
// Arc 6 Stage 2 (Q#FD18): the auto-scroll clamp reckons in
|
||||
// VISIBLE lines. Built from the active window itself, before
|
||||
// the mutable borrow below.
|
||||
let folds = core
|
||||
.windows
|
||||
.get(&active)
|
||||
.and_then(|w| crate::fold_view::map_for_window(&state.fold_registry, w));
|
||||
let aw = core.windows.get_mut(&active).expect(
|
||||
"invariant: active_window_id always references a live window in core.windows",
|
||||
);
|
||||
|
|
@ -2867,10 +2905,31 @@ pub fn paint_frame(
|
|||
.text_view
|
||||
.pos_to_display(buf, aw.cursor)
|
||||
.map_or(0, |d| d.row as usize);
|
||||
if cursor_row < aw.view_top {
|
||||
aw.view_top = cursor_row;
|
||||
} else if inner_rows > 0 && cursor_row >= aw.view_top + inner_rows as usize {
|
||||
aw.view_top = cursor_row + 1 - inner_rows as usize;
|
||||
match folds.as_ref() {
|
||||
// The logical cursor may sit on a hidden line (a shared
|
||||
// fold, or goto-line into one); the row that actually
|
||||
// renders — and so the row to scroll to — is its visible
|
||||
// head (Q#FD16/FD18, framing acceptance 8).
|
||||
Some(map) => {
|
||||
let anchor = map.visible_head_of(cursor_row);
|
||||
let top = map.clamp_view_top(aw.view_top);
|
||||
aw.view_top = if anchor < top {
|
||||
anchor
|
||||
} else if inner_rows > 0
|
||||
&& map.visible_rows_between(top, anchor) >= inner_rows as usize
|
||||
{
|
||||
map.nth_visible_back(anchor, inner_rows as usize - 1)
|
||||
} else {
|
||||
top
|
||||
};
|
||||
}
|
||||
None => {
|
||||
if cursor_row < aw.view_top {
|
||||
aw.view_top = cursor_row;
|
||||
} else if inner_rows > 0 && cursor_row >= aw.view_top + inner_rows as usize {
|
||||
aw.view_top = cursor_row + 1 - inner_rows as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2923,6 +2982,19 @@ pub fn paint_frame(
|
|||
let Ok(buf) = reg.get(window.buffer_id) else {
|
||||
continue;
|
||||
};
|
||||
// Arc 6 Stage 2 (Q#FD12, round-2 F2): ONE visible-line map per
|
||||
// rendered document window, keyed on that window's own buffer and
|
||||
// line offsets. A split may show different buffers with only one
|
||||
// folded, so a per-frame singleton would leak one pane's folds
|
||||
// into the other. `None` when this buffer has no folds — the
|
||||
// unfolded path then paints exactly as before.
|
||||
let folds = crate::fold_view::map_for_window(&state.fold_registry, window);
|
||||
// `view_top` stays a source-line index (Bet B5) but must never
|
||||
// rest on a hidden line: clamp BACKWARD so a fold at the top of
|
||||
// the viewport shows its head (Q#FD18, acceptance 8).
|
||||
if let Some(map) = folds.as_ref() {
|
||||
window.view_top = map.clamp_view_top(window.view_top);
|
||||
}
|
||||
let viewport_buffer_start = window.text_view.line_offset(window.view_top).unwrap_or(0);
|
||||
// UX gutter (Q#UX2): reserve a left strip for line numbers and
|
||||
// shrink+shift the text area into the remainder, so every
|
||||
|
|
@ -2939,6 +3011,7 @@ pub fn paint_frame(
|
|||
cell_origin: CellCoord::new(rect.origin.row, rect.origin.col + gutter_w),
|
||||
cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w),
|
||||
gutter_w,
|
||||
folds: folds.as_ref(),
|
||||
};
|
||||
// Composition (T M2.9): base text_view paints first, then the
|
||||
// gutter numbers — before the overlays, so a diagnostic overlay
|
||||
|
|
@ -2947,24 +3020,52 @@ pub fn paint_frame(
|
|||
// overlay in attach order. See [`crate::view::View`].
|
||||
window.text_view.render(buf, viewport, grid);
|
||||
if gutter_w > 0 {
|
||||
paint_line_number_gutter(grid, window, &rect, inner_rows, gutter_w, &theme);
|
||||
paint_line_number_gutter(
|
||||
grid,
|
||||
window,
|
||||
&rect,
|
||||
inner_rows,
|
||||
gutter_w,
|
||||
folds.as_ref(),
|
||||
&theme,
|
||||
);
|
||||
}
|
||||
for overlay in &mut window.overlays {
|
||||
overlay.render(buf, viewport, grid);
|
||||
}
|
||||
paint_local_selection(grid, buf, window, &rect, inner_rows, gutter_w, &theme);
|
||||
paint_local_selection(
|
||||
grid,
|
||||
buf,
|
||||
window,
|
||||
&rect,
|
||||
inner_rows,
|
||||
gutter_w,
|
||||
folds.as_ref(),
|
||||
&theme,
|
||||
);
|
||||
// Mode line for this window. Painted last so the line
|
||||
// itself is always visible regardless of overlay activity.
|
||||
let coord = window
|
||||
.text_view
|
||||
.pos_to_display(buf, window.cursor)
|
||||
.unwrap_or_default();
|
||||
let scroll = format_scroll_indicator(
|
||||
window.view_top,
|
||||
inner_rows as usize,
|
||||
window.text_view.line_count(),
|
||||
coord.row as usize,
|
||||
);
|
||||
// Arc 6 Stage 2 (Q#FD18): All/Top/Bot/% are reckoned in
|
||||
// VISIBLE-line space — a buffer whose remainder is collapsed
|
||||
// reads "All", not "Top". The cursor's ordinal anchors on its
|
||||
// visible head, since that is the row it renders on.
|
||||
let (ind_top, ind_total, ind_cursor) = match folds.as_ref() {
|
||||
Some(map) => (
|
||||
map.visible_rows_between(0, window.view_top),
|
||||
map.visible_line_count(window.text_view.line_count()),
|
||||
map.visible_rows_between(0, map.visible_head_of(coord.row as usize)),
|
||||
),
|
||||
None => (
|
||||
window.view_top,
|
||||
window.text_view.line_count(),
|
||||
coord.row as usize,
|
||||
),
|
||||
};
|
||||
let scroll = format_scroll_indicator(ind_top, inner_rows as usize, ind_total, ind_cursor);
|
||||
// Lock scoped to the summary computation only: the overlay
|
||||
// renders above include `DiagnosticView`, which takes this
|
||||
// same mutex — holding the guard across the loop deadlocked
|
||||
|
|
@ -3030,9 +3131,31 @@ pub fn paint_frame(
|
|||
let aw = &core.windows[&active];
|
||||
let inner_rows = inner_rows(&active_rect);
|
||||
let buf = reg.get(aw.buffer_id).ok()?;
|
||||
let disp = aw.text_view.pos_to_display(buf, aw.cursor)?;
|
||||
if (disp.row as usize) < aw.view_top || (disp.row as usize) >= aw.view_top + inner_rows as usize
|
||||
{
|
||||
// Arc 6 Stage 2 (Q#FD16, round-2 F3): a logical cursor on a hidden
|
||||
// line renders at its hidden component's head POSITION — the visible
|
||||
// head row *and* that head's end-of-content column, i.e. exactly
|
||||
// where Stage 1 moves point on a fold-at-cursor. Row-only clamping
|
||||
// would leave the column unspecified; resolving through the merged
|
||||
// component (rather than the innermost containing fold) also keeps a
|
||||
// crossing overlap from landing on another hidden position.
|
||||
let folds = crate::fold_view::map_for_window(&state.fold_registry, aw);
|
||||
let cursor = match folds.as_ref() {
|
||||
Some(map) => map.visible_position(aw.text_view.line_at_offset(aw.cursor), aw.cursor),
|
||||
None => aw.cursor,
|
||||
};
|
||||
let disp = aw.text_view.pos_to_display(buf, cursor)?;
|
||||
let row_offset = match folds.as_ref() {
|
||||
Some(map) => {
|
||||
let top = map.clamp_view_top(aw.view_top);
|
||||
let row = disp.row as usize;
|
||||
if row < top {
|
||||
return None;
|
||||
}
|
||||
map.visible_rows_between(top, row)
|
||||
}
|
||||
None => (disp.row as usize).checked_sub(aw.view_top)?,
|
||||
};
|
||||
if row_offset >= inner_rows as usize {
|
||||
return None;
|
||||
}
|
||||
// UX gutter: the terminal caret sits in the text area, past the
|
||||
|
|
@ -3041,7 +3164,7 @@ pub fn paint_frame(
|
|||
let w = aw.gutter_width();
|
||||
if w >= active_rect.size.cols { 0 } else { w }
|
||||
};
|
||||
let grid_row = active_rect.origin.row + (disp.row - aw.view_top as u32);
|
||||
let grid_row = active_rect.origin.row + u32::try_from(row_offset).ok()?;
|
||||
let max_col = active_rect.origin.col + active_rect.size.cols.saturating_sub(1);
|
||||
let grid_col = (active_rect.origin.col + gutter_w + disp.col).min(max_col);
|
||||
Some(CellCoord::new(grid_row, grid_col))
|
||||
|
|
@ -3180,13 +3303,23 @@ fn paint_line_number_gutter(
|
|||
rect: &crate::window::Rect,
|
||||
inner_rows: u32,
|
||||
gutter_w: u32,
|
||||
// Arc 6 Stage 2: this window's collapsed regions, or `None` when it
|
||||
// has no folds (then every line below is the pre-folding walk).
|
||||
folds: Option<&crate::fold_view::VisibleLineMap>,
|
||||
theme: &crate::highlight::Theme,
|
||||
) {
|
||||
let line_count = window.text_view.line_count();
|
||||
// Relative/Hybrid measure distance from the cursor's buffer line;
|
||||
// Absolute ignores it. Computed once per frame (the gutter repaints on
|
||||
// cursor motion, so this stays current).
|
||||
//
|
||||
// Arc 6 Stage 2 (Q#FD14): the anchor is the cursor's **visible head**
|
||||
// — a shared fold (or goto-line) can leave the logical cursor on a
|
||||
// hidden line, and the distance must be measured from the row the
|
||||
// caret actually renders on. With no folds this is `cursor_line`
|
||||
// verbatim, so the unfolded gutter is unchanged.
|
||||
let cursor_line = window.text_view.line_at_offset(window.cursor);
|
||||
let anchor = folds.map_or(cursor_line, |map| map.visible_head_of(cursor_line));
|
||||
// Themes Q#TH5: a set `ui.gutter` face owns the strip within its
|
||||
// {fg} mask; unset keeps the dim Indexed(8).
|
||||
let style = theme.face("ui.gutter").map_or(
|
||||
|
|
@ -3202,6 +3335,9 @@ fn paint_line_number_gutter(
|
|||
// The number's rightmost digit sits at `field - 1`; the last gutter
|
||||
// cell (`gutter_w - 1`) is a trailing pad separating it from the code.
|
||||
let field = gutter_w.saturating_sub(1);
|
||||
// Row `r` shows the `r`-th VISIBLE line at or after `view_top`, the
|
||||
// same walk `TextView::render` performs (Q#FD13/FD14).
|
||||
let mut buffer_line = folds.map_or(window.view_top, |map| map.visible_head_of(window.view_top));
|
||||
for r in 0..inner_rows {
|
||||
let grid_row = rect.origin.row + r;
|
||||
// Blank + style the whole strip first, so a number that shrank a
|
||||
|
|
@ -3212,16 +3348,43 @@ fn paint_line_number_gutter(
|
|||
cell.style = style;
|
||||
cell.attachment = None;
|
||||
}
|
||||
let buffer_line = window.view_top + r as usize;
|
||||
if buffer_line >= line_count {
|
||||
continue; // past end-of-buffer: blank gutter
|
||||
}
|
||||
let this_line = buffer_line;
|
||||
buffer_line = folds.map_or(this_line + 1, |map| map.next_visible(this_line));
|
||||
|
||||
// Fold marker (Q#FD20, round-1 F3): the col-0 sign cell only
|
||||
// exists when a gutter does, so the glyph is conditional on it —
|
||||
// with line numbers off the content-area ellipsis is the sole
|
||||
// indicator. Painted here, before the overlays: `DiagnosticView`
|
||||
// writes the same cell later in the frame, so a diagnostic
|
||||
// clamped onto this head wins (an error inside the collapsed
|
||||
// region is higher-signal than "this is collapsed").
|
||||
if folds.is_some_and(|map| map.is_head(this_line)) {
|
||||
grid.at(CellCoord::new(grid_row, rect.origin.col)).glyph =
|
||||
crate::cell::Glyph::Char(FOLD_GUTTER_GLYPH);
|
||||
}
|
||||
|
||||
// The mode picks the number: absolute (`line+1`), relative
|
||||
// distance, or hybrid (absolute on the cursor line, else relative).
|
||||
// Written right-aligned, rightmost digit first, alloc-free.
|
||||
// `field >= digits(line_count)` by construction, so the leftmost
|
||||
// digit always leaves at least a leading pad cell.
|
||||
let Some(mut val) = window.line_numbers.number_for(buffer_line, cursor_line) else {
|
||||
//
|
||||
// Arc 6 Stage 2 (Q#FD14): with folds present, Relative/Hybrid
|
||||
// distance is counted in VISIBLE lines across the collapse;
|
||||
// Absolute keeps the raw `line + 1` (hidden numbers simply do not
|
||||
// appear, so the column jumps from the head's number to the first
|
||||
// post-fold number). Without folds this is `number_for` verbatim.
|
||||
let number = match (folds, window.line_numbers) {
|
||||
(Some(map), LineNumberMode::Relative) => Some(map.visible_distance(anchor, this_line)),
|
||||
(Some(map), LineNumberMode::Hybrid) if this_line != anchor => {
|
||||
Some(map.visible_distance(anchor, this_line))
|
||||
}
|
||||
_ => window.line_numbers.number_for(this_line, anchor),
|
||||
};
|
||||
let Some(mut val) = number else {
|
||||
continue;
|
||||
};
|
||||
let mut col = field;
|
||||
|
|
@ -3238,6 +3401,10 @@ fn paint_line_number_gutter(
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::too_many_arguments,
|
||||
reason = "one window's already-resolved paint geometry; mirrors paint_selection_in_window"
|
||||
)]
|
||||
fn paint_local_selection(
|
||||
grid: &mut crate::cell::CellGrid<'_>,
|
||||
buf: &crate::buffer::Buffer,
|
||||
|
|
@ -3248,11 +3415,24 @@ fn paint_local_selection(
|
|||
// text-relative display column shifted right by this (Q#UX2). 0 when
|
||||
// the gutter is off, so this is a no-op then.
|
||||
gutter_w: u32,
|
||||
// Arc 6 Stage 2: this window's collapsed regions, or `None`.
|
||||
folds: Option<&crate::fold_view::VisibleLineMap>,
|
||||
theme: &crate::highlight::Theme,
|
||||
) {
|
||||
let Some((sel_start, sel_end)) = window.region() else {
|
||||
return;
|
||||
};
|
||||
// Arc 6 Stage 2 (Q#FD16): each ENDPOINT on a hidden line projects to
|
||||
// its component's head position; hidden interior cells simply have no
|
||||
// row and drop. The visible portion then paints on the visible head
|
||||
// row and the visible tail rows, contiguous on screen.
|
||||
let (sel_start, sel_end) = match folds {
|
||||
Some(map) => (
|
||||
map.visible_position(window.text_view.line_at_offset(sel_start), sel_start),
|
||||
map.visible_position(window.text_view.line_at_offset(sel_end), sel_end),
|
||||
),
|
||||
None => (sel_start, sel_end),
|
||||
};
|
||||
// Themes Q#TH5: the selection is a wash — a set `ui.selection`
|
||||
// face replaces the default overlay wholesale within its {bg}
|
||||
// mask (an all-default face disables the wash; out-of-mask
|
||||
|
|
@ -3272,9 +3452,11 @@ fn paint_local_selection(
|
|||
}
|
||||
let text_cols = rect.size.cols.saturating_sub(gutter_w);
|
||||
|
||||
let first_row = window.view_top;
|
||||
let last_row = first_row.saturating_add(inner_rows as usize);
|
||||
for display_row in first_row..last_row {
|
||||
// Row `r` shows the `r`-th VISIBLE line at or after `view_top`.
|
||||
let mut next_line = folds.map_or(window.view_top, |map| map.visible_head_of(window.view_top));
|
||||
for row_offset in 0..inner_rows {
|
||||
let display_row = next_line;
|
||||
next_line = folds.map_or(display_row + 1, |map| map.next_visible(display_row));
|
||||
let Some(line_start) = window.text_view.line_offset(display_row) else {
|
||||
continue;
|
||||
};
|
||||
|
|
@ -3298,7 +3480,6 @@ fn paint_local_selection(
|
|||
continue;
|
||||
}
|
||||
|
||||
let row_offset = display_row.saturating_sub(first_row) as u32;
|
||||
let start_col = start_coord.col.min(text_cols);
|
||||
let end_col = end_coord.col.min(text_cols);
|
||||
if start_col >= end_col {
|
||||
|
|
@ -4028,6 +4209,7 @@ mod tests {
|
|||
&rect,
|
||||
rows,
|
||||
4,
|
||||
None,
|
||||
&crate::highlight::Theme::empty(),
|
||||
);
|
||||
|
||||
|
|
@ -6672,6 +6854,7 @@ mod tests {
|
|||
cell_origin: rect.origin,
|
||||
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
};
|
||||
let mut grid = CellGrid {
|
||||
cells: &mut backing,
|
||||
|
|
@ -6806,6 +6989,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(24, 80),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
};
|
||||
|
||||
// Two no-op overlays: probe the dispatch cost only.
|
||||
|
|
|
|||
|
|
@ -386,6 +386,8 @@ impl EditorCore {
|
|||
FrontendView {
|
||||
layout: Layout::single(id),
|
||||
active: id,
|
||||
// LOCAL is the in-process grid editor (Q#FD21).
|
||||
fold_projection: true,
|
||||
},
|
||||
);
|
||||
Self {
|
||||
|
|
@ -534,6 +536,51 @@ impl EditorCore {
|
|||
self.windows.get_mut(&win_id)
|
||||
}
|
||||
|
||||
/// Whether the **acting** frontend's display collapses folds (Arc 6
|
||||
/// Stage 2, Q#FD21).
|
||||
///
|
||||
/// The gate on every command/event-time visible-line reckoning —
|
||||
/// motion, paging, wheel, the click inverse, the auto-scroll clamp.
|
||||
/// A `semantic_render` (GPU) session still displays every source
|
||||
/// line until Stage 3, so with this `false` those sites keep their
|
||||
/// raw-line behavior and its cursor never skips a line it is showing
|
||||
/// (even while a grid session folds the same shared buffer).
|
||||
#[must_use]
|
||||
pub fn fold_projection_active(&self) -> bool {
|
||||
self.active_view().fold_projection
|
||||
}
|
||||
|
||||
/// The visible-line map for `win_id`'s buffer, or `None` when the
|
||||
/// acting frontend does not project folds, `win_id` is unknown, or
|
||||
/// that buffer has no folds (Q#FD12).
|
||||
///
|
||||
/// The two axes are deliberately separate (round-3 F1): the **acting
|
||||
/// frontend** supplies the projection policy, while the
|
||||
/// **operation's target window** supplies the buffer and line
|
||||
/// offsets. Motion, paging, and auto-scroll target the active
|
||||
/// window; the click inverse and wheel scrolling name an explicit
|
||||
/// `win_id` — a wheel event over an inactive pane does not activate
|
||||
/// it, so deriving the active window's map there would project one
|
||||
/// buffer's folds onto another.
|
||||
#[must_use]
|
||||
pub fn fold_map_for_window(
|
||||
&self,
|
||||
win_id: WindowId,
|
||||
) -> Option<crate::fold_view::VisibleLineMap> {
|
||||
if !self.fold_projection_active() {
|
||||
return None;
|
||||
}
|
||||
let window = self.windows.get(&win_id)?;
|
||||
crate::fold_view::map_for_window(&self.fold_registry, window)
|
||||
}
|
||||
|
||||
/// [`Self::fold_map_for_window`] for the active window — the target
|
||||
/// of motion, paging, and the auto-scroll clamp.
|
||||
#[must_use]
|
||||
pub fn fold_map_active(&self) -> Option<crate::fold_view::VisibleLineMap> {
|
||||
self.fold_map_for_window(self.active_window_id())
|
||||
}
|
||||
|
||||
/// T M10.8 — register a `FrontendView` for `fid`. Called by the
|
||||
/// daemon on attach (Day 3 dispatcher work). Day 2's fallback
|
||||
/// path makes this optional; Day 3 makes it required.
|
||||
|
|
@ -1264,6 +1311,16 @@ impl EditorCore {
|
|||
///
|
||||
/// Returns a stringified error on buffer or view failure.
|
||||
pub fn apply_active_edit(&mut self, op: EditOp<'_>) -> Result<Edit, String> {
|
||||
// Arc 6 Stage 2 (Q#FD19): ONE pre-edit unfold funnel for every
|
||||
// local point-anchored edit. This subsumes the six `dispatch_key`
|
||||
// primitives' individual calls (retired) and widens the behavior
|
||||
// to **yank** and **query-replace**, which reach the buffer
|
||||
// through here rather than through those primitives — both place
|
||||
// point at the edit site first, so keying on the active point
|
||||
// covers them exactly. `apply_active_edit` is never the
|
||||
// remote-apply path, so this funnel is inherently local: a remote
|
||||
// peer's edit inside my fold must not unfold it (Stage 3).
|
||||
self.unfold_before_point_edit();
|
||||
let buffer_id = self.active_buffer_id();
|
||||
// Scope the registry borrow: the origin translation below needs
|
||||
// `&mut self` after the views have been notified.
|
||||
|
|
@ -1541,27 +1598,42 @@ impl EditorCore {
|
|||
}
|
||||
|
||||
/// Move the cursor up one line, preserving display column.
|
||||
///
|
||||
/// Arc 6 Stage 2 (Q#FD17, ruled: include): on a fold-projecting
|
||||
/// frontend this steps to the previous **visible** line, so a
|
||||
/// collapsed region is one motion step and the cursor never comes to
|
||||
/// rest hidden. Motion that *begins* from a hidden logical cursor (a
|
||||
/// shared fold, or goto-line into one) first normalizes to the
|
||||
/// visible head. Scoped by Q#FD21 — a semantic frontend keeps
|
||||
/// raw-line motion until Stage 3.
|
||||
pub fn move_up(&mut self) {
|
||||
let folds = self.fold_map_active();
|
||||
let id = self.active_buffer_id();
|
||||
let cursor = self.active_window().cursor;
|
||||
let goal_col = self.active_window().goal_col;
|
||||
let result = {
|
||||
let reg = self.registry.borrow();
|
||||
let Ok(buffer) = reg.get(id) else { return };
|
||||
let coord = self
|
||||
.active_window()
|
||||
let aw = self.active_window();
|
||||
let coord = aw
|
||||
.text_view
|
||||
.pos_to_display(buffer, cursor)
|
||||
.unwrap_or_default();
|
||||
if coord.row == 0 {
|
||||
let from_row = folds.as_ref().map_or(coord.row as usize, |map| {
|
||||
map.visible_head_of(coord.row as usize)
|
||||
});
|
||||
if from_row == 0 {
|
||||
return;
|
||||
}
|
||||
let target_row = folds
|
||||
.as_ref()
|
||||
.map_or(from_row - 1, |map| map.prev_visible(from_row));
|
||||
let goal = goal_col.unwrap_or(coord.col);
|
||||
let target = DisplayCoord::new(coord.row - 1, goal);
|
||||
let new_pos = self
|
||||
.active_window()
|
||||
.text_view
|
||||
.display_to_pos(buffer, target);
|
||||
let Ok(target_row) = u32::try_from(target_row) else {
|
||||
return;
|
||||
};
|
||||
let target = DisplayCoord::new(target_row, goal);
|
||||
let new_pos = aw.text_view.display_to_pos(buffer, target);
|
||||
(goal, new_pos)
|
||||
};
|
||||
let (goal, new_pos) = result;
|
||||
|
|
@ -1573,28 +1645,35 @@ impl EditorCore {
|
|||
}
|
||||
|
||||
/// Move the cursor down one line, preserving display column.
|
||||
/// Visible-line stepping mirrors [`Self::move_up`] (Q#FD17/FD21).
|
||||
pub fn move_down(&mut self) {
|
||||
let folds = self.fold_map_active();
|
||||
let id = self.active_buffer_id();
|
||||
let cursor = self.active_window().cursor;
|
||||
let goal_col = self.active_window().goal_col;
|
||||
let result = {
|
||||
let reg = self.registry.borrow();
|
||||
let Ok(buffer) = reg.get(id) else { return };
|
||||
let coord = self
|
||||
.active_window()
|
||||
let aw = self.active_window();
|
||||
let coord = aw
|
||||
.text_view
|
||||
.pos_to_display(buffer, cursor)
|
||||
.unwrap_or_default();
|
||||
let next_row = coord.row + 1;
|
||||
if (next_row as usize) >= self.active_window().text_view.line_count() {
|
||||
let from_row = folds.as_ref().map_or(coord.row as usize, |map| {
|
||||
map.visible_head_of(coord.row as usize)
|
||||
});
|
||||
let next_row = folds
|
||||
.as_ref()
|
||||
.map_or(from_row + 1, |map| map.next_visible(from_row));
|
||||
if next_row >= aw.text_view.line_count() {
|
||||
return;
|
||||
}
|
||||
let goal = goal_col.unwrap_or(coord.col);
|
||||
let Ok(next_row) = u32::try_from(next_row) else {
|
||||
return;
|
||||
};
|
||||
let target = DisplayCoord::new(next_row, goal);
|
||||
let new_pos = self
|
||||
.active_window()
|
||||
.text_view
|
||||
.display_to_pos(buffer, target);
|
||||
let new_pos = aw.text_view.display_to_pos(buffer, target);
|
||||
(goal, new_pos)
|
||||
};
|
||||
let (goal, new_pos) = result;
|
||||
|
|
@ -1743,6 +1822,9 @@ impl EditorCore {
|
|||
/// of context); falls back to a sane default before the first
|
||||
/// frame has rendered.
|
||||
pub fn move_page_down(&mut self) {
|
||||
// Arc 6 Stage 2 (Q#FD18/FD21): a screenful is a screenful of
|
||||
// VISIBLE lines, and `view_top` never lands hidden.
|
||||
let folds = self.fold_map_active();
|
||||
let step = self.page_step();
|
||||
let cursor = self.active_window().cursor;
|
||||
let view_top = self.active_window().view_top;
|
||||
|
|
@ -1755,12 +1837,25 @@ impl EditorCore {
|
|||
.text_view
|
||||
.pos_to_display(buffer, cursor)
|
||||
.unwrap_or_default();
|
||||
let max_line = aw.text_view.line_count().saturating_sub(1) as u32;
|
||||
let max_line = aw.text_view.line_count().saturating_sub(1);
|
||||
let goal_col = aw.goal_col.unwrap_or(coord.col);
|
||||
let target_row = (coord.row + step).min(max_line);
|
||||
let (target_row, new_top) = match folds.as_ref() {
|
||||
Some(map) => (
|
||||
map.nth_visible_from(coord.row as usize, step as usize)
|
||||
.min(map.visible_head_of(max_line)),
|
||||
map.nth_visible_from(view_top, step as usize),
|
||||
),
|
||||
None => (
|
||||
(coord.row as usize + step as usize).min(max_line),
|
||||
view_top.saturating_add(step as usize),
|
||||
),
|
||||
};
|
||||
let Ok(target_row) = u32::try_from(target_row) else {
|
||||
return;
|
||||
};
|
||||
let target = DisplayCoord::new(target_row, goal_col);
|
||||
let new_pos = aw.text_view.display_to_pos(buffer, target);
|
||||
(goal_col, new_pos, view_top.saturating_add(step as usize))
|
||||
(goal_col, new_pos, new_top)
|
||||
};
|
||||
let (goal, new_pos, new_top) = result;
|
||||
let aw = self.active_window_mut();
|
||||
|
|
@ -1771,12 +1866,16 @@ impl EditorCore {
|
|||
// Also nudge view_top; render's scroll-into-view will clamp
|
||||
// and align further if needed.
|
||||
let max_top = aw.text_view.line_count().saturating_sub(1);
|
||||
aw.view_top = new_top.min(max_top);
|
||||
let clamped = new_top.min(max_top);
|
||||
aw.view_top = folds
|
||||
.as_ref()
|
||||
.map_or(clamped, |map| map.clamp_view_top(clamped));
|
||||
}
|
||||
|
||||
/// Move the cursor up by approximately one screenful. Mirror of
|
||||
/// [`Self::move_page_down`].
|
||||
pub fn move_page_up(&mut self) {
|
||||
let folds = self.fold_map_active();
|
||||
let step = self.page_step();
|
||||
let cursor = self.active_window().cursor;
|
||||
let view_top = self.active_window().view_top;
|
||||
|
|
@ -1790,10 +1889,22 @@ impl EditorCore {
|
|||
.pos_to_display(buffer, cursor)
|
||||
.unwrap_or_default();
|
||||
let goal_col = aw.goal_col.unwrap_or(coord.col);
|
||||
let target_row = coord.row.saturating_sub(step);
|
||||
let (target_row, new_top) = match folds.as_ref() {
|
||||
Some(map) => (
|
||||
map.nth_visible_back(coord.row as usize, step as usize),
|
||||
map.nth_visible_back(view_top, step as usize),
|
||||
),
|
||||
None => (
|
||||
(coord.row as usize).saturating_sub(step as usize),
|
||||
view_top.saturating_sub(step as usize),
|
||||
),
|
||||
};
|
||||
let Ok(target_row) = u32::try_from(target_row) else {
|
||||
return;
|
||||
};
|
||||
let target = DisplayCoord::new(target_row, goal_col);
|
||||
let new_pos = aw.text_view.display_to_pos(buffer, target);
|
||||
(goal_col, new_pos, view_top.saturating_sub(step as usize))
|
||||
(goal_col, new_pos, new_top)
|
||||
};
|
||||
let (goal, new_pos, new_top) = result;
|
||||
let aw = self.active_window_mut();
|
||||
|
|
@ -1835,15 +1946,21 @@ impl EditorCore {
|
|||
aw.goal_col = None;
|
||||
}
|
||||
|
||||
/// Dispatch-layer pre-edit unfold (Arc 6, Q#FD5). Before a
|
||||
/// command-path point-anchored edit (the six primitives below),
|
||||
/// unfold every fold containing the active point so a self-insert or
|
||||
/// delete inside a collapsed region reveals it rather than landing
|
||||
/// invisibly. Keyed on the authenticated source frontend's active
|
||||
/// point (this is `active_window().cursor`), not the transport. A
|
||||
/// no-op when the buffer has no folds. Interactive Lua-command edits
|
||||
/// (yank/query-replace/comment) reach the buffer through a different
|
||||
/// path and are a named Stage 2 widening; CRDT-origin is Stage 3.
|
||||
/// Pre-edit unfold (Arc 6, Q#FD5 / Stage 2 Q#FD19). Before a local
|
||||
/// point-anchored edit, unfold every fold containing the active point
|
||||
/// so an edit inside a collapsed region reveals it rather than
|
||||
/// landing invisibly. Keyed on the authenticated source frontend's
|
||||
/// active point (`active_window().cursor`), not the transport. A
|
||||
/// no-op when the buffer has no folds.
|
||||
///
|
||||
/// **Stage 2 widening:** Stage 1 called this from each of the six
|
||||
/// `dispatch_key` edit primitives. It now runs once at the top of
|
||||
/// [`Self::apply_active_edit`] — the single funnel those primitives
|
||||
/// (and yank, and query-replace) all pass through. Interactive
|
||||
/// Lua-mutator edits (comment-toggle, yank-pop) take a *different*
|
||||
/// path and are hooked at `run_buffer_edit` in the Lua bindings; the
|
||||
/// remote/optimistic-CRDT apply path is deliberately excluded
|
||||
/// (Stage 3), as is undo/redo (deferred).
|
||||
fn unfold_before_point_edit(&self) {
|
||||
let id = self.active_buffer_id();
|
||||
let point = self.active_window().cursor;
|
||||
|
|
@ -1855,7 +1972,6 @@ impl EditorCore {
|
|||
/// line and returns `false`, and callers must not mutate dependent
|
||||
/// state (e.g. selection anchors) on a failed insert (Q#AI9).
|
||||
pub fn insert_char(&mut self, ch: char) -> bool {
|
||||
self.unfold_before_point_edit();
|
||||
self.active_window_mut().goal_col = None;
|
||||
let mut buf = [0u8; 4];
|
||||
let s = ch.encode_utf8(&mut buf);
|
||||
|
|
@ -1891,7 +2007,6 @@ impl EditorCore {
|
|||
/// delegates to [`Self::insert_char`] (a plain insert). The cursor
|
||||
/// lands just past the inserted bytes and any selection is cleared.
|
||||
pub fn insert_char_over_region(&mut self, ch: char) {
|
||||
self.unfold_before_point_edit();
|
||||
let Some((lo, hi)) = self.active_region() else {
|
||||
// Q#AI9: an empty selection (anchor == cursor) reports no
|
||||
// region yet stays armed — the insert moves the cursor off
|
||||
|
|
@ -1933,7 +2048,6 @@ impl EditorCore {
|
|||
|
||||
/// Delete the codepoint immediately before the cursor.
|
||||
pub fn backspace(&mut self) {
|
||||
self.unfold_before_point_edit();
|
||||
self.active_window_mut().goal_col = None;
|
||||
let cursor = self.active_window().cursor;
|
||||
if cursor == 0 {
|
||||
|
|
@ -1955,7 +2069,6 @@ impl EditorCore {
|
|||
|
||||
/// Delete the codepoint at the cursor (forward delete).
|
||||
pub fn delete_forward(&mut self) {
|
||||
self.unfold_before_point_edit();
|
||||
self.active_window_mut().goal_col = None;
|
||||
let cursor = self.active_window().cursor;
|
||||
let id = self.active_buffer_id();
|
||||
|
|
@ -1979,7 +2092,6 @@ impl EditorCore {
|
|||
/// between the cursor and where [`Self::move_word_left`] would
|
||||
/// land.
|
||||
pub fn delete_word_backward(&mut self) {
|
||||
self.unfold_before_point_edit();
|
||||
self.active_window_mut().goal_col = None;
|
||||
let cursor = self.active_window().cursor;
|
||||
if cursor == 0 {
|
||||
|
|
@ -2007,7 +2119,6 @@ impl EditorCore {
|
|||
/// [`Self::delete_forward`] over the gap from the cursor to where
|
||||
/// [`Self::move_word_right`] would land.
|
||||
pub fn delete_word_forward(&mut self) {
|
||||
self.unfold_before_point_edit();
|
||||
self.active_window_mut().goal_col = None;
|
||||
let cursor = self.active_window().cursor;
|
||||
let id = self.active_buffer_id();
|
||||
|
|
@ -3345,6 +3456,7 @@ mod tests {
|
|||
FrontendView {
|
||||
layout: Layout::single(win_id),
|
||||
active: win_id,
|
||||
fold_projection: true,
|
||||
},
|
||||
);
|
||||
win_id
|
||||
|
|
|
|||
|
|
@ -0,0 +1,466 @@
|
|||
// 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -413,7 +413,7 @@ impl View for SyntaxHighlightView {
|
|||
"syntax-highlight"
|
||||
}
|
||||
|
||||
fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||
fn render(&mut self, _buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
|
||||
self.refresh_cache_if_stale();
|
||||
let Some(bundle) = self.cache.bundle.clone() else {
|
||||
return;
|
||||
|
|
@ -436,7 +436,11 @@ impl View for SyntaxHighlightView {
|
|||
// `compute_highlight_spans_for`) lets narrower captures override.
|
||||
for layer in &self.cache.layers {
|
||||
for row_offset in 0..max_rows {
|
||||
let line_idx = start_line + row_offset;
|
||||
// Arc 6 Stage 2: row `r` shows the `r`-th VISIBLE line,
|
||||
// matching `TextView::render`'s walk (Q#FD13).
|
||||
let line_idx =
|
||||
u32::try_from(viewport.line_at_row_offset(start_line as usize, row_offset))
|
||||
.unwrap_or(u32::MAX);
|
||||
if line_idx >= total_lines {
|
||||
break;
|
||||
}
|
||||
|
|
@ -593,7 +597,7 @@ impl View for LspStyleView {
|
|||
"lsp-style"
|
||||
}
|
||||
|
||||
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||
fn render(&mut self, buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
|
||||
let Some(path) = buf.file_path() else {
|
||||
return; // No path ⇒ no URI ⇒ nothing to look up.
|
||||
};
|
||||
|
|
@ -644,7 +648,10 @@ impl View for LspStyleView {
|
|||
let total_lines = line_offsets.len() as u32;
|
||||
|
||||
for row_offset in 0..max_rows {
|
||||
let line_idx = start_line + row_offset;
|
||||
// Arc 6 Stage 2: row `r` shows the `r`-th VISIBLE line.
|
||||
let line_idx =
|
||||
u32::try_from(viewport.line_at_row_offset(start_line as usize, row_offset))
|
||||
.unwrap_or(u32::MAX);
|
||||
if line_idx >= total_lines {
|
||||
break;
|
||||
}
|
||||
|
|
@ -1058,6 +1065,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(1, 20),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
};
|
||||
let registry = state.core.borrow().registry.clone();
|
||||
let reg = registry.borrow();
|
||||
|
|
@ -1155,6 +1163,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(1, 20),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
};
|
||||
let registry = state.core.borrow().registry.clone();
|
||||
let reg = registry.borrow();
|
||||
|
|
@ -1274,6 +1283,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(1, 20),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
};
|
||||
let registry = state.core.borrow().registry.clone();
|
||||
let reg = registry.borrow();
|
||||
|
|
@ -1333,6 +1343,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(rows as u32, cols as u32),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
};
|
||||
let registry = buf; // keep buf alive
|
||||
hv.render(®istry, viewport, &mut grid);
|
||||
|
|
@ -1389,6 +1400,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(rows as u32, cols as u32),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
};
|
||||
let registry = buf; // keep buf alive
|
||||
hv.render(®istry, viewport, &mut grid);
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ impl HoverView {
|
|||
}
|
||||
|
||||
impl View for HoverView {
|
||||
fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||
fn render(&mut self, _buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
|
||||
let lines: Vec<String> = {
|
||||
let guard = self.store.lock().expect("hover store poisoned");
|
||||
guard
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ pub mod editor;
|
|||
pub mod editor_core;
|
||||
pub mod file_io;
|
||||
pub mod fold;
|
||||
pub mod fold_view;
|
||||
pub mod font_pref;
|
||||
pub mod formatting;
|
||||
pub mod frontend;
|
||||
|
|
|
|||
|
|
@ -1308,6 +1308,11 @@ fn run_buffer_edit(
|
|||
op: EditOp<'_>,
|
||||
bypass_intercept: bool,
|
||||
) -> mlua::Result<crate::rope::Edit> {
|
||||
// Arc 6 Stage 2 (Q#FD19): the interactive-Lua-command unfold seam.
|
||||
// Hooked HERE — above both `run_managed_edit` and `run_bypass_edit` —
|
||||
// so an interactive command that passes `bypass_intercept` does not
|
||||
// escape the widening.
|
||||
unfold_before_interactive_lua_edit(lua, id);
|
||||
if bypass_intercept {
|
||||
run_bypass_edit(lua, id, op)
|
||||
} else {
|
||||
|
|
@ -1315,6 +1320,50 @@ fn run_buffer_edit(
|
|||
}
|
||||
}
|
||||
|
||||
/// Unfold at the point before an **interactive** Lua-mutator edit
|
||||
/// (comment-toggle, yank-pop, and any other `pmacs.buffer.X` mutation a
|
||||
/// command body performs on the buffer the user is looking at).
|
||||
///
|
||||
/// Unfolds only when **all** of:
|
||||
///
|
||||
/// 1. [`InteractiveCommandOrigin::current`] is `Some(f)` — an
|
||||
/// interactive command is running, and `f` is the authenticated
|
||||
/// frontend that invoked it. This is the scoped authority that
|
||||
/// distinguishes a user command's edit from a plugin's or the data
|
||||
/// API's programmatic one; no command-history inference is involved,
|
||||
/// and the guard clears even when the Lua command errors.
|
||||
/// 2. The edited buffer **is `f`'s active-window buffer**. An explicit
|
||||
/// mutation of some *other*, inactive buffer stays programmatic — it
|
||||
/// is not an edit at the user's point.
|
||||
/// 3. A fold actually contains that point (handled by
|
||||
/// [`crate::fold::FoldRegistry::unfold_containing`], which is a no-op
|
||||
/// otherwise).
|
||||
///
|
||||
/// Matches Stage 1's data-API exemption: `pmacs.buffer.insert` called
|
||||
/// from a plugin, a hook, or a bare Lua chunk unfolds nothing.
|
||||
fn unfold_before_interactive_lua_edit(lua: &Lua, id: BufferId) {
|
||||
let Some(origin) = lua
|
||||
.app_data_ref::<InteractiveCommandOrigin>()
|
||||
.and_then(|origin| origin.current())
|
||||
else {
|
||||
return; // programmatic: no interactive command in scope
|
||||
};
|
||||
let Some(folds) = lua.app_data_ref::<crate::fold::SharedFoldRegistry>() else {
|
||||
return;
|
||||
};
|
||||
let Some(core) = lua.app_data_ref::<SharedCore>() else {
|
||||
return;
|
||||
};
|
||||
let core = core.borrow();
|
||||
let Some(window) = core.active_window_for(origin) else {
|
||||
return; // the invoking frontend has no view (nothing to anchor on)
|
||||
};
|
||||
if window.buffer_id != id {
|
||||
return; // an inactive-buffer mutation stays programmatic
|
||||
}
|
||||
folds.unfold_containing(id, window.cursor);
|
||||
}
|
||||
|
||||
fn run_bypass_edit(lua: &Lua, id: BufferId, op: EditOp<'_>) -> mlua::Result<crate::rope::Edit> {
|
||||
with_registry_mut(lua, |r| {
|
||||
let buf = resolve_mut(r, id)?;
|
||||
|
|
|
|||
|
|
@ -378,7 +378,7 @@ impl View for MenuView {
|
|||
"context-menu"
|
||||
}
|
||||
|
||||
fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||
fn render(&mut self, _buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
|
||||
let guard = self.menu.lock().expect("menu mutex poisoned");
|
||||
let Some(menu) = guard.as_ref() else {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ impl StyleSpanOverlay {
|
|||
}
|
||||
|
||||
impl View for StyleSpanOverlay {
|
||||
fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||
fn render(&mut self, _buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
|
||||
for span in &self.spans {
|
||||
if span.row >= viewport.cell_size.rows {
|
||||
continue;
|
||||
|
|
@ -312,7 +312,7 @@ impl View for BufferStyleOverlay {
|
|||
Some(Box::new(self.clone()))
|
||||
}
|
||||
|
||||
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||
fn render(&mut self, buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
|
||||
let spans = self
|
||||
.spans
|
||||
.lock()
|
||||
|
|
@ -369,7 +369,7 @@ fn render_buffer_style_span(
|
|||
buf: &Buffer,
|
||||
line_offsets: &[u64],
|
||||
start_line: usize,
|
||||
viewport: Viewport,
|
||||
viewport: Viewport<'_>,
|
||||
cells: &mut CellGrid<'_>,
|
||||
span: BufferStyleSpan,
|
||||
) {
|
||||
|
|
@ -379,10 +379,11 @@ fn render_buffer_style_span(
|
|||
let first_line = line_at_offset(line_offsets, span.start);
|
||||
let last_line = line_at_offset(line_offsets, span.end.saturating_sub(1));
|
||||
for line in first_line..=last_line {
|
||||
if line < start_line {
|
||||
// Arc 6 Stage 2: a span on a collapsed line paints nothing (that
|
||||
// line has no row); one below a fold lands on its shifted-up row.
|
||||
let Some(row_offset) = viewport.row_offset_of(start_line, line) else {
|
||||
continue;
|
||||
}
|
||||
let row_offset = (line - start_line) as u32;
|
||||
};
|
||||
if row_offset >= viewport.cell_size.rows {
|
||||
break;
|
||||
}
|
||||
|
|
@ -457,7 +458,7 @@ impl VirtualCellOverlay {
|
|||
}
|
||||
|
||||
impl View for VirtualCellOverlay {
|
||||
fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||
fn render(&mut self, _buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
|
||||
for vc in &self.cells {
|
||||
if vc.row >= viewport.cell_size.rows || vc.col >= viewport.cell_size.cols {
|
||||
continue;
|
||||
|
|
@ -487,13 +488,14 @@ mod tests {
|
|||
vec![Cell::default(); (rows * cols) as usize]
|
||||
}
|
||||
|
||||
fn viewport(rows: u32, cols: u32) -> Viewport {
|
||||
fn viewport(rows: u32, cols: u32) -> Viewport<'static> {
|
||||
Viewport {
|
||||
buffer_start: 0,
|
||||
buffer_end: u64::MAX,
|
||||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(rows, cols),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -144,21 +144,47 @@ pub fn paint_other_frontend_overlays(
|
|||
if g >= rect.size.cols { 0 } else { g }
|
||||
};
|
||||
let text_cols = rect.size.cols.saturating_sub(gutter_w);
|
||||
// Arc 6 Stage 2 (round-2 F2): the fold map belongs to THIS
|
||||
// RECIPIENT WINDOW — built from its own buffer and line
|
||||
// offsets, exactly like its `paint_frame` pass. A split
|
||||
// showing two buffers derives two maps, and a peer in the
|
||||
// unfolded pane is never projected through the folded one's.
|
||||
let folds = crate::fold_view::map_for_window(&state.fold_registry, window);
|
||||
// Source's byte position → display coords via THIS
|
||||
// recipient window's text_view (the recipient's view
|
||||
// of the buffer).
|
||||
let Some(disp) = window
|
||||
.text_view
|
||||
.pos_to_display(buf, presence.snapshot.cursor)
|
||||
else {
|
||||
// of the buffer). Q#FD16: a peer cursor on a line this
|
||||
// recipient has collapsed clamps to the head POSITION.
|
||||
let peer_cursor = match folds.as_ref() {
|
||||
Some(map) => map.visible_position(
|
||||
window.text_view.line_at_offset(presence.snapshot.cursor),
|
||||
presence.snapshot.cursor,
|
||||
),
|
||||
None => presence.snapshot.cursor,
|
||||
};
|
||||
let Some(disp) = window.text_view.pos_to_display(buf, peer_cursor) else {
|
||||
continue;
|
||||
};
|
||||
// Filter to viewport visible range. `view_top` is the
|
||||
// top visible buffer-line; cells below it are in-frame
|
||||
// until `view_top + inner_rows`.
|
||||
let row_in_window = match (disp.row as usize).checked_sub(window.view_top) {
|
||||
Some(r) if r < inner_rows as usize => r,
|
||||
_ => continue,
|
||||
// until `view_top + inner_rows` — counted in VISIBLE rows
|
||||
// once this window has folds.
|
||||
let row_in_window = match folds.as_ref() {
|
||||
Some(map) => {
|
||||
let top = map.clamp_view_top(window.view_top);
|
||||
let row = disp.row as usize;
|
||||
if row < top {
|
||||
continue;
|
||||
}
|
||||
let r = map.visible_rows_between(top, row);
|
||||
if r >= inner_rows as usize {
|
||||
continue;
|
||||
}
|
||||
r
|
||||
}
|
||||
None => match (disp.row as usize).checked_sub(window.view_top) {
|
||||
Some(r) if r < inner_rows as usize => r,
|
||||
_ => continue,
|
||||
},
|
||||
};
|
||||
// Column bounds: disp.col is the buffer column; window
|
||||
// doesn't horizontally scroll in v1.0, so cells past
|
||||
|
|
@ -181,7 +207,16 @@ pub fn paint_other_frontend_overlays(
|
|||
(sel.active, sel.anchor)
|
||||
};
|
||||
paint_selection_in_window(
|
||||
grid, buf, window, rect, inner_rows, gutter_w, lo, hi, color,
|
||||
grid,
|
||||
buf,
|
||||
window,
|
||||
rect,
|
||||
inner_rows,
|
||||
gutter_w,
|
||||
folds.as_ref(),
|
||||
lo,
|
||||
hi,
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -235,10 +270,21 @@ fn paint_selection_in_window(
|
|||
rect: Rect,
|
||||
inner_rows: u32,
|
||||
gutter_w: u32,
|
||||
folds: Option<&crate::fold_view::VisibleLineMap>,
|
||||
lo: crate::rope::Position,
|
||||
hi: crate::rope::Position,
|
||||
color: Color,
|
||||
) {
|
||||
// Arc 6 Stage 2 (Q#FD16): endpoints on a collapsed line project to
|
||||
// their component's head position; hidden interior bytes have no row
|
||||
// and drop below.
|
||||
let (lo, hi) = match folds {
|
||||
Some(map) => (
|
||||
map.visible_position(window.text_view.line_at_offset(lo), lo),
|
||||
map.visible_position(window.text_view.line_at_offset(hi), hi),
|
||||
),
|
||||
None => (lo, hi),
|
||||
};
|
||||
if lo >= hi {
|
||||
return;
|
||||
}
|
||||
|
|
@ -261,7 +307,15 @@ fn paint_selection_in_window(
|
|||
let Some(disp) = window.text_view.pos_to_display(buf, pos) else {
|
||||
break;
|
||||
};
|
||||
match (disp.row as usize).checked_sub(window.view_top) {
|
||||
let row_in_window = match folds {
|
||||
Some(map) if map.is_hidden(disp.row as usize) => None,
|
||||
Some(map) => {
|
||||
let top = map.clamp_view_top(window.view_top);
|
||||
(disp.row as usize >= top).then(|| map.visible_rows_between(top, disp.row as usize))
|
||||
}
|
||||
None => (disp.row as usize).checked_sub(window.view_top),
|
||||
};
|
||||
match row_in_window {
|
||||
Some(r) if r < inner_rows as usize && disp.col < text_cols => {
|
||||
let grid_row = rect.origin.row + r as u32;
|
||||
let grid_col = rect.origin.col + gutter_w + disp.col;
|
||||
|
|
|
|||
|
|
@ -411,7 +411,7 @@ impl View for SearchView {
|
|||
"search"
|
||||
}
|
||||
|
||||
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||
fn render(&mut self, buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
|
||||
let buffer_id = buf.id();
|
||||
// Snapshot the matches under the lock, release immediately
|
||||
// (same discipline as DiagnosticView).
|
||||
|
|
@ -439,6 +439,9 @@ impl View for SearchView {
|
|||
let line_offsets = crate::diag::compute_line_offsets(&source);
|
||||
let start_line_buf =
|
||||
crate::diag::line_at_offset(&line_offsets, viewport.buffer_start as u32);
|
||||
// Arc 6 Stage 2: source line → this viewport's row, `None` when
|
||||
// the line is collapsed away (the wash then paints nothing).
|
||||
let row_of = |line: u32| viewport.row_offset_of(start_line_buf as usize, line as usize);
|
||||
let max_rows = viewport.cell_size.rows;
|
||||
let max_cols = viewport.cell_size.cols;
|
||||
let cell_origin = viewport.cell_origin;
|
||||
|
|
@ -479,17 +482,19 @@ impl View for SearchView {
|
|||
// Single-line matches (every literal match) touch one row.
|
||||
let first_line = crate::diag::line_at_offset(&line_offsets, m.start as u32);
|
||||
// Matches are sorted ascending, so once one starts below the
|
||||
// viewport every later one does too — stop.
|
||||
if first_line >= start_line_buf.saturating_add(max_rows) {
|
||||
// viewport every later one does too — stop. Arc 6 Stage 2:
|
||||
// measured in VISIBLE rows, since a collapse puts a far-away
|
||||
// raw line back on screen; a hidden start yields no offset
|
||||
// and falls through to the per-line skip below.
|
||||
if row_of(first_line).is_some_and(|row| row >= max_rows) {
|
||||
break;
|
||||
}
|
||||
let last_byte = m.end.saturating_sub(1).max(m.start) as u32;
|
||||
let last_line = crate::diag::line_at_offset(&line_offsets, last_byte);
|
||||
for line in first_line..=last_line {
|
||||
if line < start_line_buf {
|
||||
let Some(row_offset) = row_of(line) else {
|
||||
continue;
|
||||
}
|
||||
let row_offset = line - start_line_buf;
|
||||
};
|
||||
if row_offset >= max_rows {
|
||||
break;
|
||||
}
|
||||
|
|
@ -753,6 +758,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(1, 10),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
},
|
||||
&mut grid,
|
||||
);
|
||||
|
|
@ -781,6 +787,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(1, 10),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
},
|
||||
&mut grid2,
|
||||
);
|
||||
|
|
@ -822,6 +829,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(rows, cols),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
},
|
||||
&mut grid,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -318,7 +318,7 @@ impl SignatureView {
|
|||
}
|
||||
|
||||
impl View for SignatureView {
|
||||
fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||
fn render(&mut self, _buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
|
||||
let snap = self.snapshot();
|
||||
let max_rows = viewport.cell_size.rows;
|
||||
let max_cols = viewport.cell_size.cols;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ use crate::view::{DisplayCoord, View, Viewport};
|
|||
/// [`TextView::pos_to_display`]; longer prefixes fall back to a heap buffer.
|
||||
const STACK_CAP: usize = 256;
|
||||
|
||||
/// Trailing marker painted after a collapsed region's head line (Arc 6
|
||||
/// Stage 2, Q#FD13). One column wide, so it never disturbs layout.
|
||||
pub const FOLD_ELLIPSIS: char = '…';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TextView
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -204,14 +208,26 @@ impl View for TextView {
|
|||
Some(line_start + walked_bytes as u64)
|
||||
}
|
||||
|
||||
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||
let start_line = self.line_at_offset(viewport.buffer_start);
|
||||
fn render(&mut self, buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
|
||||
// Arc 6 Stage 2 (Q#FD13): row `r` shows the `r`-th VISIBLE source
|
||||
// line at or after `view_top`; a collapsed region's lines are
|
||||
// skipped entirely and the rows below shift up. Without a fold
|
||||
// map this walk is the pre-folding `start_line + row_offset`
|
||||
// identity. Folding is deliberately not an overlay — overlays
|
||||
// repaint cells, they cannot delete rows.
|
||||
let folds = viewport.folds.filter(|m| !m.is_identity());
|
||||
// `view_top` is clamped backward before the frame, but a caller
|
||||
// that hands us a hidden start still gets its head.
|
||||
let start_line = {
|
||||
let raw = self.line_at_offset(viewport.buffer_start);
|
||||
folds.map_or(raw, |m| m.visible_head_of(raw))
|
||||
};
|
||||
let max_rows = viewport.cell_size.rows;
|
||||
let max_cols = viewport.cell_size.cols;
|
||||
let origin = viewport.cell_origin;
|
||||
|
||||
let mut line = start_line;
|
||||
for row_offset in 0..max_rows {
|
||||
let line = start_line + row_offset as usize;
|
||||
let cell_row = origin.row + row_offset;
|
||||
|
||||
// Always clear the visible row first so previous content does
|
||||
|
|
@ -222,8 +238,10 @@ impl View for TextView {
|
|||
if line >= self.line_count() {
|
||||
continue;
|
||||
}
|
||||
let this_line = line;
|
||||
line = folds.map_or(this_line + 1, |m| m.next_visible(this_line));
|
||||
|
||||
let line_bytes = self.read_line_bytes(buf, line);
|
||||
let line_bytes = self.read_line_bytes(buf, this_line);
|
||||
let Ok(s) = std::str::from_utf8(&line_bytes) else {
|
||||
continue;
|
||||
};
|
||||
|
|
@ -267,6 +285,23 @@ impl View for TextView {
|
|||
}
|
||||
col += width;
|
||||
}
|
||||
|
||||
// The head of a collapsed region carries a trailing ellipsis
|
||||
// in the CONTENT area (Q#FD13/FD20): the authoritative,
|
||||
// layout-neutral fold indicator, present in every gutter
|
||||
// state, clipped like any long line.
|
||||
if folds.is_some_and(|m| m.is_head(this_line)) {
|
||||
for marker in [' ', FOLD_ELLIPSIS] {
|
||||
if col >= max_cols {
|
||||
break;
|
||||
}
|
||||
let cell = cells.at(CellCoord::new(cell_row, origin.col + col));
|
||||
cell.glyph = Glyph::Char(marker);
|
||||
cell.style = Style::default();
|
||||
cell.attachment = None;
|
||||
col += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -534,6 +569,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(1, 16),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
},
|
||||
&mut grid,
|
||||
);
|
||||
|
|
@ -562,6 +598,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(1, 16),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
},
|
||||
&mut grid,
|
||||
);
|
||||
|
|
@ -593,6 +630,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(5, 5),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
},
|
||||
&mut grid,
|
||||
);
|
||||
|
|
@ -625,6 +663,7 @@ mod tests {
|
|||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(1, 5),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
},
|
||||
&mut grid,
|
||||
);
|
||||
|
|
|
|||
98
src/view.rs
98
src/view.rs
|
|
@ -40,6 +40,7 @@
|
|||
|
||||
use crate::buffer::{Buffer, BufferError, BufferId, EditOp};
|
||||
use crate::cell::{CellCoord, CellGrid, CellSize};
|
||||
use crate::fold_view::VisibleLineMap;
|
||||
use crate::rope::{Edit, Position};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -127,7 +128,7 @@ impl DisplayCoord {
|
|||
/// cell origin) and hands it to the view. The view fills cells inside that
|
||||
/// origin/size window.
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
|
||||
pub struct Viewport {
|
||||
pub struct Viewport<'a> {
|
||||
/// First byte in the buffer to consider rendering.
|
||||
pub buffer_start: Position,
|
||||
/// One past the last byte to consider.
|
||||
|
|
@ -142,6 +143,58 @@ pub struct Viewport {
|
|||
/// `cell_origin.col - gutter_w`; overlays that only touch the text area
|
||||
/// ignore it (the origin is already shifted past the gutter).
|
||||
pub gutter_w: u32,
|
||||
/// The rendering window's collapsed regions (Arc 6 Stage 2, Q#FD12),
|
||||
/// or `None` when this window's buffer has no folds — the unfolded
|
||||
/// path then stays byte-identical to the pre-folding renderer.
|
||||
///
|
||||
/// Borrowed rather than owned so `Viewport` stays [`Copy`]: the frame
|
||||
/// builds **one map per rendered window** (never one per frame — a
|
||||
/// split may show different buffers) and hands the same shared
|
||||
/// reference to every painter of that window.
|
||||
pub folds: Option<&'a VisibleLineMap>,
|
||||
}
|
||||
|
||||
impl Viewport<'_> {
|
||||
/// Row offset within this viewport for source `line`, given the
|
||||
/// viewport's first (visible) source line.
|
||||
///
|
||||
/// `None` when `line` is above `start_line` or collapsed away — a
|
||||
/// hidden line simply has no row, so its painter skips it. Without a
|
||||
/// map this is the identity `line - start_line` every consumer used
|
||||
/// before folding.
|
||||
///
|
||||
/// The result is monotonically non-decreasing in `line`, so a caller
|
||||
/// that bounds its walk with `row_offset >= rows` may still `break`.
|
||||
#[must_use]
|
||||
pub fn row_offset_of(self, start_line: usize, line: usize) -> Option<u32> {
|
||||
let raw = line.checked_sub(start_line)?;
|
||||
let rows = match self.folds {
|
||||
Some(map) if !map.is_identity() => {
|
||||
if map.is_hidden(line) {
|
||||
return None;
|
||||
}
|
||||
map.visible_rows_between(start_line, line)
|
||||
}
|
||||
_ => raw,
|
||||
};
|
||||
u32::try_from(rows).ok()
|
||||
}
|
||||
|
||||
/// The inverse of [`Self::row_offset_of`]: the source line rendered
|
||||
/// at `row_offset`, given the viewport's first (visible) source line.
|
||||
///
|
||||
/// Painters that walk rows rather than spans (the syntax and LSP
|
||||
/// style views) use this; the result may exceed the buffer's line
|
||||
/// count, which those callers already bound.
|
||||
#[must_use]
|
||||
pub fn line_at_row_offset(self, start_line: usize, row_offset: u32) -> usize {
|
||||
match self.folds {
|
||||
Some(map) if !map.is_identity() => {
|
||||
map.nth_visible_from(start_line, row_offset as usize)
|
||||
}
|
||||
_ => start_line + row_offset as usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -209,7 +262,7 @@ pub trait View {
|
|||
///
|
||||
/// Default: no-op (used by views that participate in `on_edit` but not
|
||||
/// in rendering).
|
||||
fn render(&mut self, _buf: &Buffer, _viewport: Viewport, _cells: &mut CellGrid<'_>) {}
|
||||
fn render(&mut self, _buf: &Buffer, _viewport: Viewport<'_>, _cells: &mut CellGrid<'_>) {}
|
||||
|
||||
/// Translate a buffer byte position to a display coordinate, if the
|
||||
/// view holds a meaningful mapping for that position.
|
||||
|
|
@ -277,8 +330,45 @@ mod tests {
|
|||
#[test]
|
||||
fn viewport_is_copy() {
|
||||
// Compile-time assertion: Viewport must be Copy so the frontend can
|
||||
// hand the same descriptor to multiple views without ceremony.
|
||||
// hand the same descriptor to multiple views without ceremony. Arc 6
|
||||
// Stage 2 (Bet B7) keeps this true by borrowing the fold map — a
|
||||
// shared reference is itself `Copy`.
|
||||
fn assert_copy<T: Copy>() {}
|
||||
assert_copy::<Viewport>();
|
||||
assert_copy::<Viewport<'_>>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_offset_without_folds_is_the_identity_map() {
|
||||
let vp = Viewport {
|
||||
buffer_start: 0,
|
||||
buffer_end: 0,
|
||||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(10, 10),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
};
|
||||
assert_eq!(vp.row_offset_of(4, 4), Some(0));
|
||||
assert_eq!(vp.row_offset_of(4, 9), Some(5));
|
||||
assert_eq!(vp.row_offset_of(4, 3), None, "above the viewport");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_offset_skips_hidden_lines_and_compacts_rows() {
|
||||
// Fold heading line 1, hiding lines 2..=4 (8-byte lines).
|
||||
let map =
|
||||
VisibleLineMap::build(&[pmacs_protocol::ByteRange { start: 15, end: 39 }], |off| {
|
||||
(off / 8) as usize
|
||||
});
|
||||
let vp = Viewport {
|
||||
buffer_start: 0,
|
||||
buffer_end: 0,
|
||||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(10, 10),
|
||||
gutter_w: 0,
|
||||
folds: Some(&map),
|
||||
};
|
||||
assert_eq!(vp.row_offset_of(0, 1), Some(1), "the head keeps its row");
|
||||
assert_eq!(vp.row_offset_of(0, 3), None, "hidden lines have no row");
|
||||
assert_eq!(vp.row_offset_of(0, 5), Some(2), "rows below shift up");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -324,6 +324,28 @@ pub struct FrontendView {
|
|||
/// `layout` references (invariant: `layout.iter_ids()` contains
|
||||
/// `active`).
|
||||
pub active: WindowId,
|
||||
/// Whether this frontend's display *projects* folds — i.e. whether
|
||||
/// it collapses hidden lines away (Arc 6 Stage 2, Q#FD21).
|
||||
///
|
||||
/// Motion, paging, wheel scrolling, click inverses, and the
|
||||
/// auto-scroll clamp all live in shared
|
||||
/// [`EditorCore`](crate::editor_core::EditorCore) code, but Stage 2
|
||||
/// collapses only the **grid** renderer — a `semantic_render` (GPU)
|
||||
/// session still displays every source line until Stage 3, and both
|
||||
/// kinds may attach to one buffer at once over the same shared fold
|
||||
/// store. Reckoning in visible lines unconditionally would make that
|
||||
/// GPU session's cursor skip lines it is still showing, so every
|
||||
/// command/event-time visible-line reckoning is gated on the
|
||||
/// **acting** frontend's flag. Render-time clamps need no gate: a
|
||||
/// semantic session never enters `paint_frame`.
|
||||
///
|
||||
/// Set at attach from the negotiated selected-render bit (grid ⇒
|
||||
/// `true`, semantic ⇒ `false`), cleared with the view at detach, and
|
||||
/// `true` for [`FrontendId::LOCAL`](crate::protocol::FrontendId).
|
||||
/// Deliberately has no `Default`: every construction site chooses
|
||||
/// explicitly, so the projection is never inferred from a
|
||||
/// `FrontendId` (**Bet B8**).
|
||||
pub fold_projection: bool,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
|
|
|
|||
|
|
@ -250,6 +250,7 @@ fn render_active_window_to_grid(
|
|||
cell_origin: rect.origin,
|
||||
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
};
|
||||
let mut grid = CellGrid {
|
||||
cells: &mut backing,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -498,6 +498,7 @@ fn render_active_window_to_grid(
|
|||
cell_origin: rect.origin,
|
||||
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
};
|
||||
let mut grid = CellGrid {
|
||||
cells: &mut backing,
|
||||
|
|
|
|||
|
|
@ -464,6 +464,7 @@ fn a05_08_evaluator_latches_reentrancy_contexts_and_mutation_guards() {
|
|||
pmacs::window::FrontendView {
|
||||
layout: pmacs::window::Layout::single(window_id),
|
||||
active: window_id,
|
||||
fold_projection: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,14 @@ use pmacs::overlay::{BufferStyleOverlay, BufferStyleSpan, SharedBufferStyleSpans
|
|||
use pmacs::text_view::TextView;
|
||||
use pmacs::view::{DisplayCoord, View, Viewport};
|
||||
|
||||
fn viewport(rows: u32, cols: u32, buffer_end: u64) -> Viewport {
|
||||
fn viewport(rows: u32, cols: u32, buffer_end: u64) -> Viewport<'static> {
|
||||
Viewport {
|
||||
buffer_start: 0,
|
||||
buffer_end,
|
||||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(rows, cols),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ fn attach_view(
|
|||
FrontendView {
|
||||
layout: Layout::single(window_id),
|
||||
active: window_id,
|
||||
fold_projection: true,
|
||||
},
|
||||
);
|
||||
window_id
|
||||
|
|
|
|||
Loading…
Reference in New Issue