diff options
| -rw-r--r-- | docs/helix-keys.md | 6 | ||||
| -rw-r--r-- | src/panes.zig | 227 | ||||
| -rw-r--r-- | src/pardes.zig | 180 | ||||
| -rw-r--r-- | test/panes.zig | 65 | ||||
| -rw-r--r-- | test/pdf.zig | 12 |
5 files changed, 429 insertions, 61 deletions
diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 68eae0e1..c3724cd2 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -134,7 +134,7 @@ language-backend queries, and the shell pipe. | `gd` `gD` `gy` `gi` `gr` | LSP definition / declaration / type-definition / implementation / references. ONE answer jumps straight there; several fill `+Search`, where n/N walk and Enter opens | in-process ZLS (`src/lsp/lsp_zls.zig`), `.zig` only — on a file the backend does not speak these do nothing at all, with no error row. Ctrl+left-click is the mouse spelling of `gd` | out of corpus | | `]d` `[d` / `]D` `[D` | step the diagnostics list / go to its last or first; if no list is up, asking the backend for one is part of the press | | out of corpus | | `=` | `format_selections` — writes a `- old` / `+ new` diff into `+Lsp` | deliberate divergence: the seam returns ROWS, not edits, so this SHOWS the formatting instead of applying it. Not in the corpus, so there is no waiver to name — the query leaves the core as an effect the headless harness has no shell to perform | out of corpus | -| `Ctrl-o` / `Ctrl-i` | jumplist back / forward; raw tty forwards both to the child — the `Back` / `Forward` builtins, also on `SPC j o` / `SPC j i`, with `SPC j l` rendering the stack as a buffer | helix binds both keys (`jump_backward` / `jump_forward`) but to a POSITION jumplist; pardes' stack is over panes and focus, so the keys agree and the semantics do not. `Ctrl-i` and Tab are the same byte under the legacy encoding; there Tab keeps meaning execute, and the pair only separates where the host speaks the kitty keyboard protocol | pardes-specific | +| `Ctrl-o` / `Ctrl-i` | jumplist back / forward; selections remain unchanged (a selected cursor stays at its selection while the view visits the saved location); raw tty forwards both to the child — the `Back` / `Forward` builtins, also on `SPC j o` / `SPC j i`, with `SPC j l` rendering the stack as a buffer | helix binds both keys (`jump_backward` / `jump_forward`) but to a POSITION jumplist; pardes' stack is over panes and focus, so the keys agree and the semantics do not. `Ctrl-i` and Tab are the same byte under the legacy encoding; there Tab keeps meaning execute, and the pair only separates where the host speaks the kitty keyboard protocol | pardes-specific | | `\|` | pipe every selection through `/bin/sh -c`: its bytes in on stdin, its stdout replacing them, one undo across all cursors | helix's own key and meaning; the command is typed into the pane's tag after a bare `\|` marker rather than into a popup | out of corpus | | `A-\|` | the same, and the output is DISCARDED — the text is not touched at all | helix `shell_pipe_to`. For a command run for its effect. Marker `\|-` | out of corpus | | `!` | run with NO stdin, insert the output BEFORE each selection | helix `shell_insert_output`. Runs ONCE and every cursor gets that one answer, as helix does — ten cursors and `date` give ten identical stamps. Marker `!` | out of corpus | @@ -429,3 +429,7 @@ committed as its own undo revision. Build: Raw TTY input keeps Ctrl-b for `Togglettymode` and unmodified Escape at a detected shell prompt for `Last`. Other Ctrl/Alt chords, modified Escape, and clipboard shortcut keys go to the child. Use the `Togglettymode` tag to leave raw input in place. + +Completed mouse rectangles keep their source text when jump navigation scrolls +the pane. Wrapped pieces stay separate copied rows. Changing file content or +reflowing a terminal to a new width clears these rectangles. diff --git a/src/panes.zig b/src/panes.zig index d72e1611..a39afe01 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -29,12 +29,27 @@ pub const Pane = struct { /// r counts from the tag row (body starts at BOX_H). pub const Sel = struct { state: enum { none, dragging, done } = .none, + source_id: u64 = 0, c0: i32 = 0, c1: i32 = 0, r0: i32 = 0, r1: i32 = 0, }; + /// A rectangle keeps one source span per rendered row. Wrapped segments + /// remain separate rows when copied, even when they share a source line. + pub const PointerRow = struct { + row: i32, + lo: usize, + hi: usize, + prefix: [32]u8 = @splat(0), + prefix_len: u8 = 0, + }; + pub const PointerSelection = struct { + gesture: Sel, + rows: []PointerRow, + }; + /// A modal line selection (helix `x`): whole rows [r0, r1], absolute. pub const LineSel = struct { active: bool = false, @@ -149,12 +164,28 @@ pub const Pane = struct { wrap_col: [limits.wrap_rows]i32 = undefined, wrap_n: u16 = 0, sel: [3]Sel = @splat(.{}), + pointer_selections: [3]?PointerSelection = @splat(null), + next_pointer_selection: u64 = 0, + /// terminals only: the typed-text buffer standing in for shell rows ovl: ?Terminal.EditBuffer = null, tty_filter: bool = false, msg: [256]u8 = undefined, msg_len: u16 = 0, + pub fn clearPointerSelection(pane: *Pane, slot: usize) void { + if (pane.pointer_selections[slot]) |selection| pane.gpa.free(selection.rows); + pane.pointer_selections[slot] = null; + } + + pub fn pointerSelection(pane: *const Pane, gesture: Sel) ?[]PointerRow { + if (gesture.state != .done or gesture.source_id == 0) return null; + for (pane.pointer_selections) |selection| { + if (selection) |saved| if (std.meta.eql(saved.gesture, gesture)) return saved.rows; + } + return null; + } + pub fn tagSlice(p: *const Pane) []const u8 { return p.tag_tail[0..p.tag_tail_len]; } @@ -1489,6 +1520,14 @@ pub const File = struct { } pub fn setContent(p: *Pardes, f: *State, new: []u8) void { + for (p.panes) |slot| if (slot) |pane| { + if (pane.file) |*file| if (file == f) { + for (0..pane.sel.len) |button| { + pane.clearPointerSelection(button); + pane.sel[button].state = .none; + } + }; + }; reportEdit(p, f, new); if (f.mini) |*mini| mini.deinit(p.gpa); f.mini = null; @@ -3165,19 +3204,14 @@ pub const Pdf = struct { pub const Highlight = if (enabled) pdf.Highlight else void; pub const Highlights = if (enabled) struct { - items: []const Highlight, - /// Hover items occupy [0..active_start); search/selection follow them. - active_start: usize, - hover_page: ?usize, + const Page = struct { page: usize, items: []const Highlight }; + pages: [3]Page = undefined, + len: usize = 0, - pub fn forPage(highlights: @This(), page: usize, active_page: usize) []const Highlight { - const hover = highlights.items[0..highlights.active_start]; - if (page == active_page) - return if (highlights.hover_page == page) - highlights.items - else - highlights.items[highlights.active_start..]; - return if (highlights.hover_page == page) hover else &.{}; + pub fn forPage(highlights: @This(), page: usize) []const Highlight { + for (highlights.pages[0..highlights.len]) |group| + if (group.page == page) return group.items; + return &.{}; } } else void; @@ -3196,38 +3230,88 @@ pub const Pdf = struct { if (comptime !enabled) return; const search_len = if (state.search_results) |results| results.quads.len else 0; const selection_len = if (state.selection) |selection| selection.quads.len else 0; - const active_start = input.hover_quads.len; - const highlights = try arena.alloc(Highlight, active_start + search_len + selection_len); + const items = try arena.alloc(Highlight, input.hover_quads.len + search_len + selection_len); + var result: Highlights = .{}; var n: usize = 0; - for (input.hover_quads) |quad| { - highlights[n] = pdf.Highlight.init( - quad, - .{ input.hover_color[0], input.hover_color[1], input.hover_color[2], 0x2c }, - .custom, - ); - n += 1; - } - if (state.search_results) |results| { - for (results.quads) |item| { - highlights[n] = pdf.Highlight.init( - item.quad, - .{ 0xff, 0xd5, 0x4f, 0x70 }, - .search, - ); - n += 1; + // Hover, search and selection can belong to three different pages. + // Group them once so raster rendering can borrow a contiguous slice. + for ([_]?usize{ input.hover_page, state.page, state.selection_page }) |candidate| { + const page = candidate orelse continue; + var seen = false; + for (result.pages[0..result.len]) |group| { + if (group.page == page) seen = true; } - } - if (state.selection) |selection| { - for (selection.quads) |quad| { - highlights[n] = pdf.Highlight.init( - quad, - .{ input.selection_color[0], input.selection_color[1], input.selection_color[2], 0x78 }, - .selection, - ); + if (seen) continue; + const start = n; + if (input.hover_page == page) for (input.hover_quads) |quad| { + items[n] = pdf.Highlight.init(quad, .{ + input.hover_color[0], input.hover_color[1], input.hover_color[2], 0x2c, + }, .custom); n += 1; - } + }; + if (state.page == page) if (state.search_results) |results| { + for (results.quads) |item| { + items[n] = pdf.Highlight.init(item.quad, .{ 0xff, 0xd5, 0x4f, 0x70 }, .search); + n += 1; + } + }; + if (state.selection_page == page) if (state.selection) |selection| { + for (selection.quads) |quad| { + items[n] = pdf.Highlight.init(quad, .{ + input.selection_color[0], input.selection_color[1], input.selection_color[2], 0x78, + }, .selection); + n += 1; + } + }; + result.pages[result.len] = .{ .page = page, .items = items[start..n] }; + result.len += 1; } - return .{ .items = highlights, .active_start = active_start, .hover_page = input.hover_page }; + return result; + } + + test "PDF selections retain their owning page across history navigation" { + if (comptime !enabled) return; + const gpa = std.testing.allocator; + var state = try State.open(gpa, "docs/design.pdf", 1); + defer state.deinit(gpa); + try std.testing.expect(state.page_count > 1); + var found = try state.document.search(gpa, 0, "Pardes"); + defer found.deinit(gpa); + const quad = found.quads[0].quad; + const point: Point = .{ + .x = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4, + .y = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4, + }; + try std.testing.expectEqual(.changed, state.setSelection(gpa, point, point, true)); + const selected = try gpa.dupe(u8, state.selection_text); + defer gpa.free(selected); + try std.testing.expect(selected.len > 0); + try std.testing.expect(state.focusLocation(gpa, 2, 0)); + try std.testing.expectEqual(@as(?usize, 0), state.selection_page); + try std.testing.expectEqualStrings(selected, state.selection_text); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const highlights = try buildHighlights(&state, arena.allocator(), .{ + .hover_quads = &.{quad}, + .hover_page = 1, + .hover_color = .{ 1, 2, 3 }, + .selection_color = .{ 4, 5, 6 }, + }); + try std.testing.expectEqual(state.selection.?.quads.len, highlights.forPage(0).len); + try std.testing.expectEqual(@as(usize, 1), highlights.forPage(1).len); + try std.testing.expectEqual(@as(usize, 0), highlights.forPage(2).len); + try std.testing.expect(state.focusLocation(gpa, 1, 0)); + try std.testing.expectEqualStrings(selected, state.selection_text); + try std.testing.expectEqual(.unchanged, state.setSelection(gpa, point, point, true)); + try std.testing.expect(state.focusLocation(gpa, 2, 0)); + state.rasters[0] = .{ .valid = true, .page = 0, .tried = true, .decorated = true }; + state.rasters_len = 1; + try std.testing.expectEqual(.changed, state.setSelection(gpa, point, point, false)); + try std.testing.expectEqual(@as(?usize, 1), state.selection_page); + try std.testing.expect(!state.rasters[0].tried); + state.clearSelection(gpa); + try std.testing.expect(state.selection_page == null); + try std.testing.expectEqual(@as(usize, 0), state.selection_text.len); } pub const Raster = if (enabled) struct { @@ -3287,6 +3371,7 @@ pub const Pdf = struct { search_reveal_pending: bool = false, search_results: ?pdf.SearchResults = null, selection: ?pdf.Selection = null, + selection_page: ?usize = null, selection_text: []u8 = &.{}, selection_anchor: ?Point = null, selection_head: ?Point = null, @@ -3440,6 +3525,7 @@ pub const Pdf = struct { fn dropSelection(state: *@This(), gpa: std.mem.Allocator) void { if (state.selection) |*selection| selection.deinit(gpa); state.selection = null; + state.selection_page = null; if (state.selection_text.len > 0) gpa.free(state.selection_text); state.selection_text = &.{}; state.selection_anchor = null; @@ -3453,7 +3539,7 @@ pub const Pdf = struct { end: Point, invalidate_raster: bool, ) SelectionUpdate { - if (state.selection != null and + if (state.selection != null and state.selection_page == state.page and state.selection_anchor != null and state.selection_head != null and state.selection_anchor.?.x == start.x and state.selection_anchor.?.y == start.y and state.selection_head.?.x == end.x and state.selection_head.?.y == end.y) return .unchanged; @@ -3468,11 +3554,14 @@ pub const Pdf = struct { return .failed; }; + const old_page = state.selection_page; state.dropSelection(gpa); state.selection = selection; + state.selection_page = state.page; state.selection_text = text; state.selection_anchor = start; state.selection_head = end; + if (old_page) |page| if (page != state.page) state.invalidateRaster(page); if (invalidate_raster) state.invalidateRaster(state.page); return .changed; } @@ -3483,9 +3572,9 @@ pub const Pdf = struct { } pub fn clearSelection(state: *@This(), gpa: std.mem.Allocator) void { - const changed = state.selection != null or state.selection_text.len > 0; + const page = state.selection_page; state.dropSelection(gpa); - if (changed) state.invalidateRaster(state.page); + if (page) |selected_page| state.invalidateRaster(selected_page); } pub fn cancelChrome(state: *@This(), gpa: std.mem.Allocator) void { @@ -3498,7 +3587,6 @@ pub const Pdf = struct { fn invalidatePage(state: *@This(), gpa: std.mem.Allocator) void { state.dropSearchResults(gpa); - state.dropSelection(gpa); state.clearDrag(); if (state.text.len > 0) gpa.free(state.text); state.text = &.{}; @@ -3745,7 +3833,7 @@ pub const Pdf = struct { var found = try state.document.search(gpa, page, pattern); defer found.deinit(gpa); - const cursor_hit: ?usize = if (from_cursor and page == state.page) cursor: { + const cursor_hit: ?usize = if (from_cursor and page == state.page and state.selection_page == page) cursor: { const selection = state.selection orelse break :cursor null; for (found.quads) |item| { const q = item.quad; @@ -4126,7 +4214,7 @@ pub const Pdf = struct { raster = &state.rasters[state.rasters_len - 1]; } const slot = raster.?; - const page_highlights = highlights.forPage(page, state.page); + const page_highlights = highlights.forPage(page); const decorated = page_highlights.len > 0; const stale = !slot.tried or !slot.request_valid or !slot.request.eql(request) or slot.decorated != decorated or @@ -4201,11 +4289,7 @@ pub const Pdf = struct { if (comptime !enabled) return .{}; ensureLayout(state, viewport); state.resolveSearch(gpa); - const highlights = buildHighlights(state, arena, highlight_input) catch Highlights{ - .items = &.{}, - .active_start = 0, - .hover_page = null, - }; + const highlights = buildHighlights(state, arena, highlight_input) catch Highlights{}; const request = renderRequest(viewport, policy); var visible = visiblePages(state, viewport); reconcile(state, gpa, request, tint_key, highlights, visible, viewport); @@ -5149,7 +5233,7 @@ pub const Pdf = struct { state.drag_anchor = point; state.drag_head = point; if (!snap_word) return true; - if (state.selection) |selection| if (selection.contains(point)) return true; + if (state.selection_page == state.page) if (state.selection) |selection| if (selection.contains(point)) return true; if (state.setSelection(core.pdf_gpa, point, point, true) == .failed) { // Preserve the old selection transactionally, but never let an // outside click execute its stale text. @@ -5833,6 +5917,10 @@ pub const Terminal = struct { pub fn resizeGrid(pane: *Pane, gpa: std.mem.Allocator, cols: u16, rows: u16) void { if (comptime !enabled) return; const state = pane.terminal orelse return; + if (cols != pane.cols) for (0..pane.sel.len) |button| { + pane.clearPointerSelection(button); + pane.sel[button].state = .none; + }; state.vt.resize(gpa, .{ .cols = cols, .rows = rows }) catch {}; } @@ -6102,7 +6190,7 @@ pub const Terminal = struct { if (pane.terminal == null) return; const has_positions = pane.ovl != null or pane.cur_pinned or pane.vsel.active or pane.msel.active or pane.nsel > 0 or pane.append_at != null or pane.look_at != null or - pane.ed_undo_len > 0 or pane.ed_redo_len > 0; + pane.ed_undo_len > 0 or pane.ed_redo_len > 0 or pointerRowCount(pane) > 0; if (has_positions) ingestWithPositions(pane, bytes) else ingest(pane, bytes); p.shell_rows.markStale(pane); const body_mode = if (pane.tag_edit) pane.tag_mode else pane.mode; @@ -6158,6 +6246,14 @@ pub const Terminal = struct { return .{ .target = target, .row = trackRow(pages, grid, failed), .overlay = overlay }; } + fn pointerRowCount(pane: *const Pane) usize { + var count: usize = 0; + for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| { + count += rows.len; + }; + return count; + } + fn ingestWithPositions(pane: *Pane, bytes: []const u8) void { const screens = &pane.terminal.?.vt.screens; const key = screens.active_key; @@ -6228,10 +6324,27 @@ pub const Terminal = struct { positions[npositions] = trackPosition(pages, target, overlay, &failed); npositions += 1; } + const pointer_positions = pane.gpa.alloc(PositionPin, pointerRowCount(pane)) catch null; + defer if (pointer_positions) |pins| pane.gpa.free(pins); + if (pointer_positions) |pins| { + var index: usize = 0; + for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| { + for (rows) |*row| { + pins[index] = trackPosition(pages, &row.row, overlay, &failed); + index += 1; + } + }; + } else failed = true; ingest(pane, bytes); // RIS can destroy the alternate screen and every pin it owned. const alive = if (screens.generation(key) == generation) pages else null; var moved = false; + var pointer_rows_lost = alive == null or pointer_positions == null; + if (pointer_positions) |pins| for (pins) |position| { + if (alive != null) if (position.row.pin) |pin| { + if (pin.garbage) pointer_rows_lost = true; + }; + }; for (spans[0..nspans]) |span| { const start = pinnedRow(alive, span.start); const rows = @max(1, pinnedRow(alive, span.end) -| start +| 1); @@ -6243,7 +6356,7 @@ pub const Terminal = struct { if (span.end.pin) |pin| owner.untrackPin(pin); } } - for (positions[0..npositions]) |position| { + for ([_][]PositionPin{ positions[0..npositions], pointer_positions orelse &.{} }) |batch| for (batch) |position| { var row = pinnedRow(alive, position.row); if (position.overlay) |edit| { if (position.edit_row) |relative| { @@ -6258,7 +6371,11 @@ pub const Terminal = struct { moved = moved or row != position.target.*; position.target.* = row; if (alive) |owner| if (position.row.pin) |pin| owner.untrackPin(pin); - } + }; + if (pointer_rows_lost) for (0..pane.sel.len) |button| { + pane.clearPointerSelection(button); + pane.sel[button].state = .none; + }; if (moved) pane.nsel_snap = 0; // Saved regex-preview offsets name the old flat text. if (failed) { const message = "terminal edit position reset: out of memory"; diff --git a/src/pardes.zig b/src/pardes.zig index 5ab4dcb9..1dbe2e81 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -5680,6 +5680,7 @@ pub const Pardes = struct { jumps: [MAX_JUMPS]Loc = undefined, njumps: usize = 0, jcur: usize = 0, + jump_selection_cursor: ?struct { pane: usize, serial: u32, row: i32, col: i32 } = null, look_src: [MAX_PANES]u32 = undefined, n_look_src: usize = 0, look_walk_owner: ?u32 = null, @@ -6058,6 +6059,7 @@ pub const Pardes = struct { p.cancelLookHover(); } p.shell_rows.dropPane(pane); + for (0..pane.pointer_selections.len) |slot| pane.clearPointerSelection(slot); panes.Terminal.deinitPendingCommand(pane); if (pane.image) |*iv| { iv.deinit(p.image_gpa); @@ -7448,10 +7450,82 @@ pub const Pardes = struct { return std.fmt.allocPrint(arena, "{s}\n{s}", .{ try p.tagText(arena, pane), try p.bodyText(arena, pane) }); } + fn pointerSourceLine(pane: *Pane, terminal_lines: []const []const u8, row: i32) []const u8 { + if (row < 0) return ""; + if (pane.file != null) return panes.File.sourceLine(pane, row); + const index: usize = @intCast(row); + return if (index < terminal_lines.len) terminal_lines[index] else ""; + } + + fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void { + var gesture = pane.sel[slot]; + if (pane.hasPdf()) return; + if (gesture.state != .done or @min(gesture.r0, gesture.r1) < BOX_H) return; + if (pane.pointerSelection(gesture) != null) return; + pane.clearPointerSelection(slot); + // Refresh the source map before translating the rendered rectangle. + const body = try p.bodyText(p.scratch.allocator(), pane); + const terminal_lines = if (pane.file == null) try panes.Terminal.cursorLines(p, pane) else &.{}; + const first = @max(0, @min(gesture.r0, gesture.r1) - BOX_H); + const last = @min(@as(i32, pane.rows) - 1, @max(gesture.r0, gesture.r1) - BOX_H); + if (first > last) return; + const rows = try p.gpa.alloc(Pane.PointerRow, @intCast(last - first + 1)); + const prefix: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; + const left = @max(0, @min(gesture.c0, gesture.c1)); + const right = @max(0, @max(gesture.c0, gesture.c1)); + for (rows, 0..) |*row, index| { + const visible = first + @as(i32, @intCast(index)); + const source = pane.wrapAt(visible); + const line = pointerSourceLine(pane, terminal_lines, source.line); + const next = pane.wrapAt(visible + 1); + const end = if (next.line == source.line) @min(line.len, @as(usize, @intCast(@max(0, next.at)))) else line.len; + const start: usize = @intCast(@max(0, source.at)); + const lo = @min(end, panes.File.byteAtDisplayFrom(line, start, @intCast(@max(0, left - prefix)))); + const at = @min(end, panes.File.byteAtDisplayFrom(line, start, @intCast(@max(0, right - prefix)))); + row.* = .{ + .row = source.line, + .lo = lo, + .hi = if (right < prefix) lo else @min(end, modal.nextGrapheme(line, at)), + }; + const rendered = modal.lineSlice(body, @intCast(visible)); + const prefix_end = @min(rendered.len, @as(usize, @intCast(@min(prefix, right + 1)))); + const prefix_start = @min(prefix_end, @as(usize, @intCast(left))); + row.prefix_len = @intCast(prefix_end - prefix_start); + @memcpy(row.prefix[0..row.prefix_len], rendered[prefix_start..prefix_end]); + } + pane.next_pointer_selection += 1; + gesture.source_id = pane.next_pointer_selection; + pane.sel[slot] = gesture; + pane.pointer_selections[slot] = .{ .gesture = gesture, .rows = rows }; + } + /// block-selected text, newline-joined per row; reads the rendered screen /// so typed text and shell output select identically. Scratch-owned. fn selectionText(p: *Pardes, pane: *Pane, sl: Pane.Sel) ![]const u8 { const arena = p.scratch.allocator(); + if (pane.pointerSelection(sl)) |rows| { + const terminal_lines = if (pane.file == null) try panes.Terminal.cursorLines(p, pane) else &.{}; + var total: usize = rows.len -| 1; + for (rows) |row| { + const line = pointerSourceLine(pane, terminal_lines, row.row); + total += row.prefix_len + @min(row.hi, line.len) - @min(row.lo, line.len); + } + const result = try arena.alloc(u8, total); + var at: usize = 0; + for (rows, 0..) |row, index| { + if (index > 0) { + result[at] = '\n'; + at += 1; + } + @memcpy(result[at..][0..row.prefix_len], row.prefix[0..row.prefix_len]); + at += row.prefix_len; + const line = pointerSourceLine(pane, terminal_lines, row.row); + const bytes = line[@min(row.lo, line.len)..@min(row.hi, line.len)]; + @memcpy(result[at..][0..bytes.len], bytes); + at += bytes.len; + } + return result; + } const r0 = @min(sl.r0, sl.r1); const r1 = @max(sl.r0, sl.r1); const c0: usize = @intCast(@max(0, @min(sl.c0, sl.c1))); @@ -10867,6 +10941,7 @@ pub const Pardes = struct { exitTagEdit(pane); // clicking the body leaves tag editing const sc: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); const v: i32 = @as(i32, mrow) - @as(i32, body_y) + @as(i32, BOX_H); + pane.clearPointerSelection(sel_slot); pane.sel[sel_slot] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v }; p.drag = .{ .select = .{ .id = id, @@ -10920,6 +10995,7 @@ pub const Pardes = struct { pane.scrollBy(if (m.button == config.look_button) local else -local); } else if (p.pointerTextSelection(id, mcol, mrow)) |pointed| { if (m.button == config.look_button) p.active = id; + pane.clearPointerSelection(@intFromEnum(m.button)); pane.sel[@intFromEnum(m.button)] = pointed.sel; p.drag = .{ .select = .{ @@ -11156,6 +11232,7 @@ pub const Pardes = struct { const sl = pane.sel[sel_slot]; const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1; if (!dragged) pane.sel[sel_slot].state = .none; + if (dragged) p.capturePointerSelection(pane, sel_slot) catch {}; const body_vis = sl.r1 - @as(i32, BOX_H); if (body_vis >= 0 and pane.mode != .tty) { const w = pane.wrapAt(body_vis); @@ -12127,6 +12204,14 @@ pub const Pardes = struct { p.jcur = @min(cur, w -| 1); const pane = p.panes[p.active] orelse return; + // After a history arrival, an unchanged selection head still belongs + // to the selection, not to the location we just showed. + if (p.jump_selection_cursor) |saved| { + if (saved.pane == p.active and saved.serial == pane.serial and + saved.row == pane.cur_row and saved.col == pane.cur_col and + (pane.vsel.active or pane.msel.active or pane.nsel > 0)) return; + p.jump_selection_cursor = null; + } const now: Loc = .{ .pane = @intCast(p.active), .serial = pane.serial, @@ -12160,7 +12245,23 @@ pub const Pardes = struct { if (p.njumps == 0 or next < 0 or next >= p.njumps) return; p.jcur = @intCast(next); const j = p.jumps[p.jcur]; + const pane = p.panes[j.pane] orelse return; + if (pane.serial != j.serial) return; + // A modal selection stores its head in the cursor. History changes + // the viewed location, but must not stretch that selection to it. + for (0..pane.sel.len) |slot| p.capturePointerSelection(pane, slot) catch return; + const preserve_cursor = pane.vsel.active or pane.msel.active or pane.nsel > 0; + const row = pane.cur_row; + const col = pane.cur_col; + const pinned = pane.cur_pinned; p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col }, .center); + p.jump_selection_cursor = null; + if (preserve_cursor) { + p.jump_selection_cursor = .{ .pane = j.pane, .serial = pane.serial, .row = row, .col = col }; + pane.cur_row = row; + pane.cur_col = col; + pane.cur_pinned = pinned; + } } /// Recompute geometry, push grid-size changes to each emulator + pty, fire @@ -12634,6 +12735,36 @@ pub const Pardes = struct { return !cell.default and !(cell.len == 1 and cell.text[0] == ' '); } + fn paintSourceSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void { + const terminal_lines = if (pane.file == null) panes.Terminal.cursorLines(p, pane) catch return else &.{}; + const tx = r.x + config.GUTTER; + const width = r.w -| config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; + const prefix: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; + var visible: i32 = 0; + while (visible < @as(i32, r.h -| BOX_H)) : (visible += 1) { + const source = pane.wrapAt(visible); + const line = pointerSourceLine(pane, terminal_lines, source.line); + const start = @min(line.len, @as(usize, @intCast(@max(0, source.at)))); + const next = pane.wrapAt(visible + 1); + const end = if (next.line == source.line) @min(line.len, @as(usize, @intCast(@max(0, next.at)))) else line.len; + for (rows) |row| { + if (row.row != source.line) continue; + const lo = @max(start, row.lo); + const hi = @min(end, row.hi); + if (lo >= hi) continue; + var col = prefix + @as(i32, @intCast(panes.File.rawDisplayCol(line[start..], lo - start))); + const last = prefix + @as(i32, @intCast(panes.File.rawDisplayCol(line[start..], hi - start))); + while (col < last and col < width) : (col += 1) { + const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible))); + cell.default = false; + cell.style.bg = .{ .rgb = bg }; + if (fg) |ink| cell.style.fg = .{ .rgb = ink }; + } + } + } + } + fn paintPointerSelection( s: *Surface, pane: *const Pane, @@ -12843,7 +12974,10 @@ pub const Pardes = struct { for (pane.sel, 0..) |sl, b| { if (sl.state == .none) continue; if (sl.state == .done and b != 0) continue; - paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg); + if (pane.pointerSelection(sl)) |rows| + p.paintSourceSelection(pane, r, rows, sel_btn[b], th.sel_fg) + else + paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg); } // modal line selection (helix `x`): whole body rows, normal mode only if ((pane.mode == .normal or modal_hover) and pane.msel.active) { @@ -13770,3 +13904,47 @@ test "board heap: every allocation failure during boot is a clean OutOfMemory" { .{ board_cols, board_rows }, ); } + +test "jump history preserves modal and mouse selections" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("alpha beta\ngamma delta\nepsilon zeta\n"); + pane.setRange(pane.file.?.content, 0, .{ .anchor = 1, .head = 5 }, true); + pane.sel[0] = .{ .state = .done, .c0 = 2, .c1 = 5, .r0 = 1, .r1 = 1 }; + const selection = pane.primaryRange(pane.file.?.content, 0); + try p.capturePointerSelection(pane, 0); + const mouse = pane.sel; + p.njumps = 2; + p.jcur = 1; + p.jumps[0] = .{ .pane = 0, .serial = pane.serial, .line = 3, .col = 8 }; + p.jumps[1] = .{ .pane = 0, .serial = pane.serial, .line = 1, .col = 1 }; + p.update(.{ .key = .{ .cp = 'o', .ctrl = true } }); + try std.testing.expectEqual(@as(usize, 0), p.jcur); + try std.testing.expectEqual(@as(u32, 3), p.jumps[0].line); + try std.testing.expectEqualDeep(selection, pane.primaryRange(pane.file.?.content, 0)); + try std.testing.expectEqualDeep(mouse, pane.sel); + p.update(.{ .key = .{ .cp = 'i', .ctrl = true } }); + try std.testing.expectEqual(@as(usize, 1), p.jcur); + try std.testing.expectEqualDeep(selection, pane.primaryRange(pane.file.?.content, 0)); + try std.testing.expectEqualDeep(mouse, pane.sel); + pane.vsel.active = false; + p.jumpBy(-1); + try std.testing.expectEqual(@as(i32, 2), pane.cur_row); + try std.testing.expectEqual(@as(i32, 7), pane.cur_col); +} + +test "jump history terminal rectangle follows output and clears on reflow" { + if (comptime !terminal_panes) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12, .tty_only = true }); + defer p.deinit(); + const pane = p.panes[0].?; + p.update(.{ .output = .{ .pane = 0, .bytes = "alpha bravo\r\nsecond line\r\n" } }); + pane.sel[0] = .{ .state = .done, .c0 = 0, .c1 = 4, .r0 = BOX_H, .r1 = BOX_H }; + try p.capturePointerSelection(pane, 0); + try std.testing.expectEqualStrings("alpha", try p.selectionText(pane, pane.sel[0])); + for (0..40) |_| p.update(.{ .output = .{ .pane = 0, .bytes = "more output\r\n" } }); + try std.testing.expectEqualStrings("alpha", try p.selectionText(pane, pane.sel[0])); + panes.Terminal.resizeGrid(pane, p.gpa, pane.cols - 1, pane.rows); + try std.testing.expect(pane.pointerSelection(pane.sel[0]) == null); + try std.testing.expectEqual(.none, pane.sel[0].state); +} diff --git a/test/panes.zig b/test/panes.zig index a324d74a..ab7726e1 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -12,6 +12,70 @@ const image = pardes.image; const syntax = pardes.syntax; const filesystem = pardes.filesystem; +const JumpSelectionTests = struct { + fn drag(p: *Pardes, col0: u16, row0: u16, col1: u16, row1: u16) void { + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = col0, .row = row0 } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = col1, .row = row1 } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = col1, .row = row1 } }); + } + + fn exercise(prefix: []const u8, wrap: bool, reverse: bool, first_col: u16, last_col: u16, expected: ?[]const u8) !void { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 10 }); + defer p.deinit(); + p.settings.wrap = wrap; + const text = try std.fmt.allocPrint(p.gpa, "{s}{s}", .{ prefix, "unrelated destination text\n" ** 80 }); + defer p.gpa.free(text); + const pane = try p.setTestFile(text); + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + if (wrap) try std.testing.expectEqual(pane.wrap_line[0], pane.wrap_line[1]); + const rect = p.rects[0]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane); + const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + if (reverse) + drag(p, x + last_col, y + 1, x + first_col, y) + else + drag(p, x + first_col, y, x + last_col, y + 1); + + const selected = try p.gpa.dupe(u8, pardes.test_api.heldSelection(p, 0) orelse return error.MissingMouseSelection); + defer p.gpa.free(selected); + if (expected) |want| try std.testing.expectEqualStrings(want, selected); + try std.testing.expect(std.mem.indexOfScalar(u8, selected, '\n') != null); + if (wrap) try std.testing.expect(std.mem.indexOf(u8, selected, "界") != null); + + // Seed two actual locations after the drag, so both history directions + // must scroll while retaining the rectangle's original source text. + p.njumps = 2; + p.jcur = 1; + p.jumps[0] = .{ .pane = 0, .serial = pane.serial, .line = 60, .col = 1 }; + p.jumps[1] = .{ .pane = 0, .serial = pane.serial, .line = 1, .col = 1 }; + p.update(.{ .key = .{ .cp = 'o', .ctrl = true } }); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 0), p.jcur); + try std.testing.expect(pane.file.?.scroll > 20); + try std.testing.expectEqualStrings(selected, pardes.test_api.heldSelection(p, 0) orelse return error.LostMouseSelection); + + p.update(.{ .key = .{ .cp = 'i', .ctrl = true } }); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 1), p.jcur); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.scroll); + try std.testing.expectEqualStrings(selected, pardes.test_api.heldSelection(p, 0) orelse return error.LostMouseSelection); + } + + test "jump selection retains copied rectangle text after scrolling away and back" { + for ([_]bool{ false, true }) |reverse| + try exercise("alpha\nbravo\n", false, reverse, 1, 3, "lph\nrav"); + } + + test "jump selection retains wrapped Unicode and tab rectangle text" { + for ([_]bool{ false, true }) |reverse| + try exercise("界\tabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\n", true, reverse, 0, 8, null); + } +}; + test "pane filename edits stage commit and cancel without changing body or command tail" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 70, .rows = 12 }); defer p.deinit(); @@ -2627,6 +2691,7 @@ const TerminalTests = struct { }; test { + _ = JumpSelectionTests; _ = @import("output.zig"); _ = @import("pdf.zig"); _ = @import("fs_namespace.zig"); diff --git a/test/pdf.zig b/test/pdf.zig index a5720f3f..4c0daf34 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -1629,7 +1629,7 @@ test "PDF native mouse selection, Look, and highlights share page geometry" { p.scratch.allocator(), panes.Pdf.highlightInput(p, 0, pane), ); - const page_highlights = decorated.forPage(pv.page, pv.page); + const page_highlights = decorated.forPage(pv.page); try std.testing.expectEqual(pdf_impl.HighlightKind.custom, page_highlights[0].kind); try std.testing.expectEqual(pdf_impl.HighlightKind.selection, page_highlights[page_highlights.len - 1].kind); _ = frame.reset(.retain_capacity); @@ -1672,7 +1672,7 @@ test "PDF native mouse selection, Look, and highlights share page geometry" { pv, p.scratch.allocator(), panes.Pdf.highlightInput(p, 0, pane), - )).items; + )).forPage(pv.page); try std.testing.expect(highlights.len > 1); try std.testing.expectEqual(pdf_impl.HighlightKind.search, highlights[0].kind); try std.testing.expectEqual(pdf_impl.HighlightKind.selection, highlights[highlights.len - 1].kind); @@ -1814,9 +1814,13 @@ test "PDF native mouse selection, Look, and highlights share page geometry" { const saved_query = try gpa.dupe(u8, pv.search_query); defer gpa.free(saved_query); + const saved_selection = try gpa.dupe(u8, pv.selection_text); + defer gpa.free(saved_selection); + const selected_page = pv.selection_page; panes.Pdf.setPage(p, pane, 1); - try std.testing.expect(pv.selection == null); - try std.testing.expectEqual(@as(usize, 0), pv.selection_text.len); + try std.testing.expect(pv.selection != null); + try std.testing.expectEqual(selected_page, pv.selection_page); + try std.testing.expectEqualStrings(saved_selection, pv.selection_text); try std.testing.expectEqualStrings(saved_query, pv.search_query); } |
