summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/panes.zig227
-rw-r--r--src/pardes.zig180
2 files changed, 351 insertions, 56 deletions
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);
+}