fix(fold): address PR #149 review round 4
All four findings were correct. F1 (major) — the Lua widening keyed on the POINT, not the edit site. Q#FD19 condition (iii) is `edit.range.start`; `run_buffer_edit` discarded `op` and opened whatever fold contained `window.cursor`. Two wrong behaviors followed: a command inserting elsewhere opened an unrelated fold at the point, and — worse — a command editing INTO a fold from an outside point left its edit hidden. `run_buffer_edit` now reads `edit_start_of(&op)` before `op` is consumed and passes it down. The `apply_active_edit` funnel stays point-keyed, which is correct and deliberate: the six primitives, yank, and query-replace all place point at the edit site first, and only the Lua path can diverge — now said so in the doc comment. The acceptance test encoded the wrong behavior (inserted at byte 0, expected the cursor's fold to open). Replaced by two tests that pin both directions, plus the framing's named **comment-toggle** case driven through the real `M-;` on a file-backed Rust buffer rather than a synthetic mutator. F2 (major) — hidden-cursor motion normalized only the row. `move_up` / `move_down` clamped `coord.row` but derived `goal` from the hidden line's raw column, and returned early at a buffer boundary without normalizing at all, so `Up` inside a fold headed on line 0 left the logical cursor hidden. Added `normalize_cursor_to_visible`, which projects the whole POSITION through `visible_position` as a real mutation before any step is computed (and drops the sticky goal column, since the jump is discontinuous). Paging shares it — the same latent bug. The old test used equal-width lines and asserted only the line, so it could not tell the two apart; the new fixture is deliberately ragged and asserts the resulting BYTE in both directions, plus the boundary case. F3 (moderate) — `set_view_top` bypassed the fold clamp. Q#FD12/Q#FD18 say `view_top` is always set through `clamp_view_top`, naming the `set_view_top` contract specifically. Rendering repaired it at the next frame, but until then `view_top()` handed out a hidden line and command/event reckoning could start from a non-visible origin. The clamp now lives in the setter — the contract's home, what `saveplace` and `pmacs.editor.set_view_top` call. New test reads `view_top()` directly after the setter, before any paint. F4 (moderate) — the suite claimed items 1–14 but omitted approved clauses. Added: peer SELECTION endpoint projection with hidden interiors dropped; the crossing-fold repeat for the peer cursor AND a peer selection endpoint; a style/syntax span straddling a fold (alignment on the shifted row); the completion popup anchored below a fold; and peer presence in the split-buffer case using the RECIPIENT window's map. Suite: 35 -> 45 tests. Eight new bite cases, each falsifying exactly the line that implements its claim. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
This commit is contained in:
parent
97a27e9873
commit
90bb86d355
|
|
@ -708,9 +708,21 @@ impl EditorCore {
|
|||
/// clamped to the buffer's line count (Arc 3 Q#PS1 — desktop
|
||||
/// restore). A file that shrank since the desktop was saved can't
|
||||
/// scroll past its end.
|
||||
/// Arc 6 Stage 2 (Q#FD12/Q#FD18, round-4 F3): `view_top` stays a
|
||||
/// source-line index but must never *name* a hidden line, so a
|
||||
/// fold-projecting frontend also clamps **backward** to the visible
|
||||
/// head here. The render pass repairs a hidden `view_top` too, but
|
||||
/// only at the next frame — until then [`Self::view_top`] would hand
|
||||
/// out a collapsed line and command/event reckoning would start from
|
||||
/// a non-visible origin. This setter is the contract's home (it is
|
||||
/// what `saveplace` and `pmacs.editor.set_view_top` call), so the
|
||||
/// invariant is established here rather than repaired downstream.
|
||||
pub fn set_view_top(&mut self, top: usize) {
|
||||
let lines = self.active_window().text_view.line_count();
|
||||
let clamped = top.min(lines.saturating_sub(1));
|
||||
let clamped = self
|
||||
.fold_map_active()
|
||||
.map_or(clamped, |map| map.clamp_view_top(clamped));
|
||||
self.active_window_mut().view_top = clamped;
|
||||
}
|
||||
|
||||
|
|
@ -1608,6 +1620,14 @@ impl EditorCore {
|
|||
/// raw-line motion until Stage 3.
|
||||
pub fn move_up(&mut self) {
|
||||
let folds = self.fold_map_active();
|
||||
// Normalize FIRST and as a real mutation (round-4 F2): a hidden
|
||||
// logical cursor moves to its component's head POSITION — head
|
||||
// row *and* head end-of-content column — before any step is
|
||||
// computed. Deriving the goal column from the hidden line would
|
||||
// carry a column the head may not even have, and returning early
|
||||
// at a buffer boundary (a fold headed on line 0) would leave the
|
||||
// cursor hidden entirely.
|
||||
self.normalize_cursor_to_visible(folds.as_ref());
|
||||
let id = self.active_buffer_id();
|
||||
let cursor = self.active_window().cursor;
|
||||
let goal_col = self.active_window().goal_col;
|
||||
|
|
@ -1619,9 +1639,7 @@ impl EditorCore {
|
|||
.text_view
|
||||
.pos_to_display(buffer, cursor)
|
||||
.unwrap_or_default();
|
||||
let from_row = folds.as_ref().map_or(coord.row as usize, |map| {
|
||||
map.visible_head_of(coord.row as usize)
|
||||
});
|
||||
let from_row = coord.row as usize;
|
||||
if from_row == 0 {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1648,6 +1666,7 @@ impl EditorCore {
|
|||
/// Visible-line stepping mirrors [`Self::move_up`] (Q#FD17/FD21).
|
||||
pub fn move_down(&mut self) {
|
||||
let folds = self.fold_map_active();
|
||||
self.normalize_cursor_to_visible(folds.as_ref());
|
||||
let id = self.active_buffer_id();
|
||||
let cursor = self.active_window().cursor;
|
||||
let goal_col = self.active_window().goal_col;
|
||||
|
|
@ -1659,9 +1678,7 @@ impl EditorCore {
|
|||
.text_view
|
||||
.pos_to_display(buffer, cursor)
|
||||
.unwrap_or_default();
|
||||
let from_row = folds.as_ref().map_or(coord.row as usize, |map| {
|
||||
map.visible_head_of(coord.row as usize)
|
||||
});
|
||||
let from_row = coord.row as usize;
|
||||
let next_row = folds
|
||||
.as_ref()
|
||||
.map_or(from_row + 1, |map| map.next_visible(from_row));
|
||||
|
|
@ -1823,8 +1840,11 @@ impl EditorCore {
|
|||
/// 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.
|
||||
// VISIBLE lines, and `view_top` never lands hidden. Paging shares
|
||||
// vertical motion's hidden-cursor normalization (round-4 F2) —
|
||||
// the goal column must come from the head, not the hidden line.
|
||||
let folds = self.fold_map_active();
|
||||
self.normalize_cursor_to_visible(folds.as_ref());
|
||||
let step = self.page_step();
|
||||
let cursor = self.active_window().cursor;
|
||||
let view_top = self.active_window().view_top;
|
||||
|
|
@ -1876,6 +1896,7 @@ impl EditorCore {
|
|||
/// [`Self::move_page_down`].
|
||||
pub fn move_page_up(&mut self) {
|
||||
let folds = self.fold_map_active();
|
||||
self.normalize_cursor_to_visible(folds.as_ref());
|
||||
let step = self.page_step();
|
||||
let cursor = self.active_window().cursor;
|
||||
let view_top = self.active_window().view_top;
|
||||
|
|
@ -1961,6 +1982,34 @@ impl EditorCore {
|
|||
/// 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).
|
||||
/// Move a hidden logical cursor to its component's visible head
|
||||
/// **position** — the head row *and* that head's end-of-content
|
||||
/// column, i.e. exactly where Stage 1 moves point on a fold-at-cursor
|
||||
/// (Q#FD16/Q#FD17, round-4 F2).
|
||||
///
|
||||
/// A cursor can be hidden without this frontend ever having moved it
|
||||
/// there: another frontend folds through the shared store, or
|
||||
/// goto-line targets a line inside a collapse. Vertical motion and
|
||||
/// paging normalize through here *before* computing their step, so
|
||||
/// the goal column is the head's — never a column of a line that
|
||||
/// renders no row — and so a step that turns out to be a no-op at a
|
||||
/// buffer boundary still leaves the cursor visible.
|
||||
///
|
||||
/// The jump is discontinuous, so the sticky goal column is dropped
|
||||
/// (the click/goto convention), and only when the cursor actually
|
||||
/// was hidden: ordinary motion keeps its sticky column untouched.
|
||||
fn normalize_cursor_to_visible(&mut self, folds: Option<&crate::fold_view::VisibleLineMap>) {
|
||||
let Some(map) = folds else { return };
|
||||
let aw = self.active_window();
|
||||
let line = aw.text_view.line_at_offset(aw.cursor);
|
||||
let projected = map.visible_position(line, aw.cursor);
|
||||
if projected != aw.cursor {
|
||||
let aw = self.active_window_mut();
|
||||
aw.cursor = projected;
|
||||
aw.goal_col = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn unfold_before_point_edit(&self) {
|
||||
let id = self.active_buffer_id();
|
||||
let point = self.active_window().cursor;
|
||||
|
|
|
|||
|
|
@ -1311,8 +1311,9 @@ fn run_buffer_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);
|
||||
// escape the widening. Keyed on where the edit LANDS, read before
|
||||
// `op` is consumed below.
|
||||
unfold_before_interactive_lua_edit(lua, id, edit_start_of(&op));
|
||||
if bypass_intercept {
|
||||
run_bypass_edit(lua, id, op)
|
||||
} else {
|
||||
|
|
@ -1320,7 +1321,16 @@ fn run_buffer_edit(
|
|||
}
|
||||
}
|
||||
|
||||
/// Unfold at the point before an **interactive** Lua-mutator edit
|
||||
/// Where an [`EditOp`] lands — the `edit.range.start` Q#FD19 keys the
|
||||
/// interactive unfold on.
|
||||
fn edit_start_of(op: &EditOp<'_>) -> u64 {
|
||||
match op {
|
||||
EditOp::Insert { pos, .. } => *pos,
|
||||
EditOp::Delete { range } | EditOp::Replace { range, .. } => range.start,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unfold at the **edit site** 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).
|
||||
///
|
||||
|
|
@ -1334,14 +1344,24 @@ fn run_buffer_edit(
|
|||
/// 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
|
||||
/// is not an edit the user can see land.
|
||||
/// 3. A fold actually contains **`edit_start`** (handled by
|
||||
/// [`crate::fold::FoldRegistry::unfold_containing`], a no-op
|
||||
/// otherwise).
|
||||
///
|
||||
/// Condition 3 is the edit site, **not the point** (Q#FD19). A Lua
|
||||
/// command is free to edit somewhere other than where the cursor sits:
|
||||
/// keying on the cursor would open an unrelated fold when a command
|
||||
/// edits elsewhere, and — worse — leave the edit invisible when a
|
||||
/// command edits *into* a fold from an outside point. The six
|
||||
/// `EditorCore` primitives, yank, and query-replace all edit exactly at
|
||||
/// the point, so
|
||||
/// [`apply_active_edit`](crate::editor_core::EditorCore::apply_active_edit)'s
|
||||
/// funnel keys on the point; only this Lua path can diverge.
|
||||
///
|
||||
/// 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) {
|
||||
fn unfold_before_interactive_lua_edit(lua: &Lua, id: BufferId, edit_start: u64) {
|
||||
let Some(origin) = lua
|
||||
.app_data_ref::<InteractiveCommandOrigin>()
|
||||
.and_then(|origin| origin.current())
|
||||
|
|
@ -1361,7 +1381,7 @@ fn unfold_before_interactive_lua_edit(lua: &Lua, id: BufferId) {
|
|||
if window.buffer_id != id {
|
||||
return; // an inactive-buffer mutation stays programmatic
|
||||
}
|
||||
folds.unfold_containing(id, window.cursor);
|
||||
folds.unfold_containing(id, edit_start);
|
||||
}
|
||||
|
||||
fn run_bypass_edit(lua: &Lua, id: BufferId, op: EditOp<'_>) -> mlua::Result<crate::rope::Edit> {
|
||||
|
|
|
|||
|
|
@ -215,6 +215,10 @@ fn type_str(s: &mut EditorState, text: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
fn cursor(s: &EditorState) -> u64 {
|
||||
s.core.borrow().active_window().cursor
|
||||
}
|
||||
|
||||
fn cursor_line(s: &EditorState) -> usize {
|
||||
s.core.borrow().cursor_line()
|
||||
}
|
||||
|
|
@ -491,6 +495,29 @@ fn nested_fold_with_a_deeply_hidden_cursor_resolves_outermost() {
|
|||
assert_eq!(row_text(&cells, 1), " 1 L10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_view_top_clamps_before_any_frame_is_painted() {
|
||||
// Round-4 F3: the SETTER establishes the invariant, not the renderer.
|
||||
// Between a `saveplace` restore (or `pmacs.editor.set_view_top`) and
|
||||
// the next frame, `view_top()` must never name a collapsed line and
|
||||
// command/event reckoning must never start from one.
|
||||
let (s, _id) = seeded();
|
||||
fold_active(&s, 2, 5);
|
||||
exec(&s, "pmacs.editor.set_view_top(4)"); // a hidden line
|
||||
assert_eq!(
|
||||
view_top(&s),
|
||||
2,
|
||||
"clamped backward to the visible head by the setter itself — \
|
||||
read here BEFORE any paint_frame call"
|
||||
);
|
||||
// A visible target is untouched, and the raw line-count clamp still
|
||||
// applies (Arc 3 Q#PS1).
|
||||
exec(&s, "pmacs.editor.set_view_top(7)");
|
||||
assert_eq!(view_top(&s), 7);
|
||||
exec(&s, "pmacs.editor.set_view_top(9999)");
|
||||
assert_eq!(view_top(&s), 12, "still clamped to the last line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_view_top_inside_a_nest_clamps_backward_to_the_head() {
|
||||
let (s, _id) = seeded();
|
||||
|
|
@ -638,10 +665,176 @@ fn a_peer_cursor_on_a_hidden_line_clamps_to_the_head_position() {
|
|||
}
|
||||
}
|
||||
|
||||
/// Paint the presence pass for `presences` over a fresh grid.
|
||||
fn paint_presence(s: &EditorState, presences: &[pmacs::overlay_paint::OtherPresence]) -> Vec<Cell> {
|
||||
let mut backing = vec![Cell::default(); (ROWS * COLS) as usize];
|
||||
let mut grid = CellGrid {
|
||||
cells: &mut backing,
|
||||
stride: COLS,
|
||||
size: CellSize::new(ROWS, COLS),
|
||||
};
|
||||
pmacs::overlay_paint::paint_other_frontend_overlays(
|
||||
s,
|
||||
&mut grid,
|
||||
CellSize::new(ROWS, COLS),
|
||||
presences,
|
||||
);
|
||||
backing
|
||||
}
|
||||
|
||||
fn peer(
|
||||
buffer_id: BufferId,
|
||||
cursor: u64,
|
||||
selection: Option<(u64, u64)>,
|
||||
) -> pmacs::overlay_paint::OtherPresence {
|
||||
pmacs::overlay_paint::OtherPresence {
|
||||
frontend_id: FrontendId(2),
|
||||
snapshot: pmacs::presence::PresenceSnapshot {
|
||||
buffer_id,
|
||||
cursor,
|
||||
selection: selection
|
||||
.map(|(anchor, active)| pmacs::protocol::SelectionSnapshot { anchor, active }),
|
||||
},
|
||||
color_slot: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_selection_endpoints_project_and_hidden_interiors_drop() {
|
||||
let (s, id) = seeded();
|
||||
fold_active(&s, 2, 5);
|
||||
// A peer selects from the middle of line 1 through the middle of
|
||||
// line 7 — straddling the whole collapse.
|
||||
let cells = paint_presence(&s, &[peer(id, end_of(7) - 1, Some((5, end_of(7) - 1)))]);
|
||||
let underlined = |row: u32| {
|
||||
(0..COLS)
|
||||
.filter(|c| at(&cells, row, *c).style.underline == pmacs::cell::UnderlineStyle::Single)
|
||||
.count()
|
||||
};
|
||||
assert!(underlined(1) > 0, "row 1 (L01) is partly selected");
|
||||
assert!(underlined(2) > 0, "row 2 (the visible head) is selected");
|
||||
assert!(underlined(3) > 0, "row 3 (L06, shifted up) is selected");
|
||||
assert!(underlined(4) > 0, "row 4 (L07) is selected");
|
||||
assert_eq!(underlined(5), 0, "past the selection end");
|
||||
// Rows 3 and 4 are L06/L07 — the hidden lines contributed no row at
|
||||
// all, so nothing could have painted "through" them.
|
||||
let frame = paint(&s);
|
||||
assert_eq!(row_text(&frame, 3), "L06");
|
||||
assert_eq!(row_text(&frame, 4), "L07");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_peer_inside_crossing_folds_projects_to_the_first_visible_head() {
|
||||
// Round-3 F2 repeated for the PEER path (framing acceptance 6):
|
||||
// A hides 1..=3 (head 0); B is headed on hidden line 2 and hides
|
||||
// 3..=5. A peer cursor and a peer selection endpoint on line 5 must
|
||||
// both resolve to A's head position, never B's hidden `range.start`.
|
||||
let (s, id) = seeded();
|
||||
fold_active(&s, 0, 3);
|
||||
fold_active(&s, 2, 5);
|
||||
|
||||
let cells = paint_presence(&s, &[peer(id, end_of(5), None)]);
|
||||
assert!(
|
||||
at(&cells, 0, 3).style.reverse,
|
||||
"peer cursor on A's visible head row at its end-of-content column"
|
||||
);
|
||||
for row in 1..4u32 {
|
||||
assert!(
|
||||
(0..COLS).all(|c| !cells[(row * COLS + c) as usize].style.reverse),
|
||||
"nothing on a row a hidden line would have owned ({row})"
|
||||
);
|
||||
}
|
||||
|
||||
// A selection whose START endpoint is hidden inside the crossing
|
||||
// region projects the same way. Visible order is 0(head), 6, 7, 8…,
|
||||
// so the projected start puts the selection's first cell on ROW 0 —
|
||||
// and that row is the discriminator: projecting to B's hidden
|
||||
// `range.start` (end of line 2) instead would paint nothing there.
|
||||
let sel = paint_presence(
|
||||
&s,
|
||||
&[peer(id, end_of(7) - 1, Some((end_of(5), end_of(7) - 1)))],
|
||||
);
|
||||
let underlined = |row: u32| {
|
||||
(0..COLS)
|
||||
.filter(|c| at(&sel, row, *c).style.underline == pmacs::cell::UnderlineStyle::Single)
|
||||
.count()
|
||||
};
|
||||
assert!(
|
||||
underlined(0) > 0,
|
||||
"the projected start lands on A's visible head row, not B's hidden one"
|
||||
);
|
||||
assert!(underlined(1) > 0, "L06, inside the projected span");
|
||||
assert!(underlined(2) > 0, "the visible tail (L07)");
|
||||
assert_eq!(underlined(3), 0, "L08 is past the selection end");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Ordinary overlays across a fold (framing acceptance 7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_style_span_straddling_a_fold_paints_only_visible_rows() {
|
||||
let (s, _id) = seeded();
|
||||
fold_active(&s, 2, 5);
|
||||
// One buffer-byte span from the start of line 1 through the end of
|
||||
// line 7 — it covers three hidden lines on the way.
|
||||
exec(
|
||||
&s,
|
||||
&format!(
|
||||
"ov = pmacs.buffer.add_style_overlay(pmacs.window.buffer()); \
|
||||
ov:add({}, {}, {{ bg = 4 }})",
|
||||
LINE_BYTES,
|
||||
end_of(7)
|
||||
),
|
||||
);
|
||||
let cells = paint(&s);
|
||||
let washed = |row: u32| {
|
||||
(0..COLS)
|
||||
.filter(|c| at(&cells, row, *c).style.bg == pmacs::cell::Color::Indexed(4))
|
||||
.count()
|
||||
};
|
||||
assert!(washed(1) > 0, "L01 is inside the span");
|
||||
assert!(washed(2) > 0, "the visible head is inside the span");
|
||||
assert!(washed(3) > 0, "L06 — correctly aligned on its SHIFTED row");
|
||||
assert!(washed(4) > 0, "L07");
|
||||
assert_eq!(washed(5), 0, "L08 is past the span's end");
|
||||
assert_eq!(washed(0), 0, "L00 is before the span's start");
|
||||
// The alignment claim: row 3 really is L06, so the wash landed on
|
||||
// the row the collapse put that line on, not on a raw offset.
|
||||
assert_eq!(row_text(&cells, 3), "L06");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_completion_popup_below_a_fold_anchors_on_the_visible_row() {
|
||||
let (s, _id) = seeded();
|
||||
fold_active(&s, 2, 5);
|
||||
let mut s = s;
|
||||
// Cursor at the end of line 7 — three collapsed lines above it, so
|
||||
// its VISIBLE row is 4 (0, 1, 2·head, 6, 7) though its raw line is 7.
|
||||
set_cursor(&s, end_of(7));
|
||||
// A separator first, so the word at point is the prefix itself.
|
||||
type_str(&mut s, " L1"); // dabbrev prefix of L10 / L11
|
||||
let visible: bool = eval(&s, "return pmacs.completion.popup_visible()");
|
||||
assert!(visible, "the popup opened off dabbrev");
|
||||
assert_eq!(fold_count(&s), 1, "typing below the fold left it closed");
|
||||
|
||||
let cells = paint(&s);
|
||||
// Row 5 is where the popup's first row belongs. Without a fold-aware
|
||||
// anchor the popup would have gone to raw row 8 and row 5 would still
|
||||
// show source line 8.
|
||||
assert_ne!(
|
||||
row_text(&cells, 5),
|
||||
"L08",
|
||||
"the popup covers the row directly below the anchor's VISIBLE row"
|
||||
);
|
||||
assert!(
|
||||
row_text(&cells, 5).contains("L1"),
|
||||
"and that row holds a candidate: {:?}",
|
||||
row_text(&cells, 5)
|
||||
);
|
||||
assert_eq!(row_text(&cells, 4), "L07 L1", "the anchor row itself");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_wash_across_a_fold_paints_only_visible_rows_correctly() {
|
||||
let (s, _id) = seeded();
|
||||
|
|
@ -772,27 +965,98 @@ fn next_line_steps_across_a_collapsed_region_in_one_motion() {
|
|||
assert_eq!(cursor_line(&s), 2, "and one step back returns to the head");
|
||||
}
|
||||
|
||||
/// Lines of deliberately UNEQUAL width, so a normalization that only
|
||||
/// clamps the row (carrying the hidden line's raw column 12) lands on a
|
||||
/// different byte than one that projects the whole position (head column
|
||||
/// 2) — in BOTH directions.
|
||||
///
|
||||
/// ```text
|
||||
/// line 0: "ab" bytes 0..=1 end 2
|
||||
/// line 1: "PQRSTUVWXY" (10) bytes 3..=12 end 13
|
||||
/// line 2: "ef" bytes 14..=15 end 16 <- head
|
||||
/// line 3: "0123456789ABCDEF" bytes 17..=32 end 33 <- hidden
|
||||
/// line 4: "wxyz" bytes 34..=37 end 38 <- hidden
|
||||
/// line 5: "ghijklmn" (8) bytes 39..=46 end 47
|
||||
/// ```
|
||||
const RAGGED: &str = "ab\nPQRSTUVWXY\nef\n0123456789ABCDEF\nwxyz\nghijklmn\n";
|
||||
|
||||
fn ragged() -> (EditorState, BufferId) {
|
||||
let s = editor();
|
||||
let id = active_id(&s);
|
||||
seed(&s, id, RAGGED);
|
||||
(s, id)
|
||||
}
|
||||
|
||||
/// Column 12 of hidden line 3.
|
||||
const DEEP: u64 = 17 + 12;
|
||||
|
||||
#[test]
|
||||
fn motion_from_a_hidden_cursor_normalizes_to_the_visible_head_first() {
|
||||
let (s, _id) = seeded();
|
||||
fold_active(&s, 2, 5);
|
||||
fn motion_from_a_hidden_cursor_normalizes_the_whole_position() {
|
||||
let (s, _id) = ragged();
|
||||
// Head = line 2 ("ef", ends at byte 16); hidden = lines 3..=4.
|
||||
fold_range(&s, 16, 38);
|
||||
let mut s = s;
|
||||
// A shared fold left the logical cursor deep inside the collapse.
|
||||
set_cursor(&s, end_of(4));
|
||||
|
||||
// A shared fold left the logical cursor at column 12 of hidden line
|
||||
// 3 — a column line 2 does not even have.
|
||||
set_cursor(&s, DEEP);
|
||||
press(&mut s, KeyCode::Down);
|
||||
assert_eq!(cursor_line(&s), 5, "normalize to head 2, then step down");
|
||||
assert_eq!(
|
||||
cursor_line(&s),
|
||||
6,
|
||||
"normalize to head 2, then step to the next visible line"
|
||||
cursor(&s),
|
||||
41,
|
||||
"the goal column came from the HEAD's end of content (2), not the \
|
||||
hidden line's raw column (12): line 5 is 8 wide, so a raw column \
|
||||
would have clamped to its end (47)"
|
||||
);
|
||||
|
||||
set_cursor(&s, end_of(4));
|
||||
set_cursor(&s, DEEP);
|
||||
press(&mut s, KeyCode::Up);
|
||||
assert_eq!(cursor_line(&s), 1, "normalize to head 2, then step up");
|
||||
assert_eq!(
|
||||
cursor(&s),
|
||||
5,
|
||||
"column 2 of line 1; a raw column 12 would have clamped to 13"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boundary_step_still_leaves_a_hidden_cursor_visible() {
|
||||
// Round-4 F2: with the fold headed on line 0 there is nowhere to step
|
||||
// up TO, but the normalization must still have happened — returning
|
||||
// early would leave the logical cursor hidden.
|
||||
let (s, _id) = ragged();
|
||||
// Head = line 0 ("ab", ends at byte 2); hidden = lines 1..=4.
|
||||
fold_range(&s, 2, 38);
|
||||
let mut s = s;
|
||||
set_cursor(&s, DEEP); // deep inside, on hidden line 3
|
||||
|
||||
press(&mut s, KeyCode::Up);
|
||||
assert_eq!(
|
||||
cursor_line(&s),
|
||||
1,
|
||||
"normalize to head 2, then step to the previous visible line"
|
||||
0,
|
||||
"no step available, but still normalized"
|
||||
);
|
||||
assert_eq!(cursor(&s), 2, "at the head's end of content");
|
||||
let folds = s.fold_registry.folds(active_id(&s));
|
||||
assert!(
|
||||
!folds
|
||||
.iter()
|
||||
.any(|f| f.start < cursor(&s) && cursor(&s) <= f.end),
|
||||
"the cursor is no longer inside any fold"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paging_from_a_hidden_cursor_normalizes_too() {
|
||||
// Same class: paging shares the normalization, so a page from a
|
||||
// hidden cursor also starts from the head's column.
|
||||
let (s, _id) = ragged();
|
||||
fold_range(&s, 16, 38);
|
||||
set_cursor(&s, DEEP);
|
||||
s.core.borrow_mut().move_page_up();
|
||||
assert_eq!(cursor_line(&s), 0);
|
||||
assert_eq!(cursor(&s), 2, "head column 2, not the hidden line's 12");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -865,29 +1129,54 @@ fn m_x(s: &mut EditorState, name: &str) {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn an_interactive_lua_mutator_unfolds_but_a_programmatic_one_does_not() {
|
||||
fn an_interactive_lua_mutator_unfolds_at_the_edit_site() {
|
||||
let (s, _id) = seeded();
|
||||
let mut s = s;
|
||||
fold_active(&s, 2, 5);
|
||||
set_cursor(&s, end_of(4)); // point inside the fold
|
||||
fold_active(&s, 2, 5); // head 2, hidden 3..=5; fold range (11, 23]
|
||||
|
||||
// A PROGRAMMATIC data-API edit: no interactive command in scope.
|
||||
// (Insert at 0 is before the fold, so it only translates it right.)
|
||||
exec(&s, "pmacs.window.buffer():insert(0, 'x')");
|
||||
// A PROGRAMMATIC data-API edit inside the fold: no interactive
|
||||
// command in scope, so it stays hidden (Stage 1's exemption).
|
||||
exec(
|
||||
&s,
|
||||
&format!("pmacs.window.buffer():insert({}, 'x')", end_of(4)),
|
||||
);
|
||||
assert_eq!(
|
||||
fold_count(&s),
|
||||
1,
|
||||
"a bare data-API mutation stays programmatic — no unfold"
|
||||
);
|
||||
|
||||
// The same call from INSIDE an interactive command unfolds.
|
||||
define(&s, "test.poke", "pmacs.window.buffer():insert(0, 'y')");
|
||||
set_cursor(&s, end_of(4) + 1); // still inside the shifted fold
|
||||
// The same edit from INSIDE an interactive command unfolds.
|
||||
define(
|
||||
&s,
|
||||
"test.poke",
|
||||
&format!("pmacs.window.buffer():insert({}, 'y')", end_of(4)),
|
||||
);
|
||||
set_cursor(&s, 0); // point OUTSIDE the fold — the edit site is what counts
|
||||
m_x(&mut s, "test.poke");
|
||||
assert_eq!(
|
||||
fold_count(&s),
|
||||
0,
|
||||
"an interactive command's edit at the point unfolds (Q#FD19)"
|
||||
"Q#FD19 keys on edit.range.start: a command editing INTO a fold \
|
||||
from an outside point must reveal what it wrote"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interactive_edit_outside_the_fold_leaves_it_closed() {
|
||||
// The other half of round-4 F1: keying on the POINT would open an
|
||||
// unrelated fold whenever a command edits somewhere else.
|
||||
let (s, _id) = seeded();
|
||||
let mut s = s;
|
||||
fold_active(&s, 2, 5);
|
||||
set_cursor(&s, end_of(4)); // point INSIDE the fold …
|
||||
define(&s, "test.elsewhere", "pmacs.window.buffer():insert(0, 'x')");
|
||||
m_x(&mut s, "test.elsewhere"); // … but the edit lands at byte 0
|
||||
assert_eq!(
|
||||
fold_count(&s),
|
||||
1,
|
||||
"an edit outside the fold must not open it just because the \
|
||||
cursor happens to sit inside"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -898,11 +1187,14 @@ fn a_bypass_intercept_interactive_edit_also_unfolds() {
|
|||
let (s, _id) = seeded();
|
||||
let mut s = s;
|
||||
fold_active(&s, 2, 5);
|
||||
set_cursor(&s, end_of(4));
|
||||
set_cursor(&s, 0);
|
||||
define(
|
||||
&s,
|
||||
"test.bypass",
|
||||
"pmacs.window.buffer():insert(0, 'z', { bypass_intercept = true })",
|
||||
&format!(
|
||||
"pmacs.window.buffer():insert({}, 'z', {{ bypass_intercept = true }})",
|
||||
end_of(4)
|
||||
),
|
||||
);
|
||||
m_x(&mut s, "test.bypass");
|
||||
assert_eq!(
|
||||
|
|
@ -912,6 +1204,53 @@ fn a_bypass_intercept_interactive_edit_also_unfolds() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_toggle_inside_a_fold_unfolds_it() {
|
||||
// The framing's named Q#FD19 case, through the REAL `M-;` path
|
||||
// (comment-toggle is a Lua mutator, so it exercises the
|
||||
// `run_buffer_edit` seam end to end rather than a synthetic stand-in).
|
||||
let dir = std::env::temp_dir().join(format!("pmacs-fold-s2-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("fold_comment.rs");
|
||||
let body = "fn a() {\n let x = 1;\n let y = 2;\n let z = 3;\n}\n";
|
||||
std::fs::write(&path, body).unwrap();
|
||||
|
||||
let s = editor();
|
||||
let mut s = s;
|
||||
exec(
|
||||
&s,
|
||||
&format!(
|
||||
"pmacs.buffer.find_or_open({:?})",
|
||||
path.display().to_string()
|
||||
),
|
||||
);
|
||||
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||
// Hide the three `let` lines under the `fn a() {` head.
|
||||
let head_end = body.find('\n').unwrap() as u64;
|
||||
let last_hidden_end = body.rfind("let z = 3;").unwrap() as u64 + "let z = 3;".len() as u64;
|
||||
fold_range(&s, head_end, last_hidden_end);
|
||||
assert_eq!(fold_count(&s), 1);
|
||||
|
||||
// Point on a hidden line, then M-; — the toggle rewrites that line.
|
||||
let y_line = body.find(" let y").unwrap() as u64;
|
||||
set_cursor(&s, y_line + 4);
|
||||
alt(&mut s, ';');
|
||||
assert_eq!(
|
||||
fold_count(&s),
|
||||
0,
|
||||
"comment-toggle's edit landed inside the fold, so it must reveal it"
|
||||
);
|
||||
let text: String = eval(
|
||||
&s,
|
||||
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
|
||||
);
|
||||
assert!(
|
||||
text.contains("// "),
|
||||
"the toggle actually commented a line: {text:?}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interactive_edit_to_an_inactive_buffer_does_not_unfold() {
|
||||
let (s, _id) = seeded();
|
||||
|
|
@ -1254,6 +1593,42 @@ fn an_unfolded_pane_beside_a_folded_one_is_byte_identical_to_its_baseline() {
|
|||
assert_eq!(s.core.borrow().windows[&b_win].view_top, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_presence_in_the_unfolded_pane_uses_that_windows_map() {
|
||||
// Framing acceptance 14's presence clause: the recipient window's
|
||||
// map, not the active (folded) buffer's — a peer in B must land on
|
||||
// B's RAW row even while A is collapsed.
|
||||
let (s, _id) = seeded();
|
||||
let (_a_win, b_win) = split_two_buffers(&s);
|
||||
fold_active(&s, 2, 5); // buffer A (active) folds; B does not
|
||||
let b_buffer = s.core.borrow().windows[&b_win].buffer_id;
|
||||
// B is "B0\nB1\n…B7\n" (3 bytes a line). Put the peer on B's line 6 —
|
||||
// BELOW where A's collapse sits, so the two maps disagree: B's map
|
||||
// (none) says row 6, while A's map — which hides lines 3..=5 — would
|
||||
// shift it up to row 3.
|
||||
let cells = paint_presence(&s, &[peer(b_buffer, 6 * 3, None)]);
|
||||
|
||||
let b_origin = COLS / 2;
|
||||
let peer_cell =
|
||||
|row: u32| (b_origin..COLS).any(|c| cells[(row * COLS + c) as usize].style.reverse);
|
||||
assert!(
|
||||
peer_cell(6),
|
||||
"B has no folds, so its peer stays on raw row 6"
|
||||
);
|
||||
assert!(
|
||||
!peer_cell(3),
|
||||
"the recipient window's map is B's — projecting through the \
|
||||
active (folded) buffer's map would have put it on row 3"
|
||||
);
|
||||
// And nothing painted into A's half for a peer in B's buffer.
|
||||
for row in 0..TEXT_ROWS {
|
||||
assert!(
|
||||
(0..b_origin).all(|c| !cells[(row * COLS + c) as usize].style.reverse),
|
||||
"a peer in buffer B must not paint into buffer A's pane (row {row})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_over_an_inactive_unfolded_pane_uses_that_windows_map() {
|
||||
let (s, _id) = seeded();
|
||||
|
|
|
|||
Loading…
Reference in New Issue