summaryrefslogtreecommitdiff
path: root/src/panes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-15 12:29:41 -0300
committerGabriel Schneider <[email protected]>2026-09-15 17:24:42 -0300
commit8b0bed0427064794725f95cb7b020d7d96956975 (patch)
treee44dad51729bafd8a46a386af4a20674fc36c9d8 /src/panes.zig
parenta624a56e289e4a02a411f83741f0f2c9fc0f0b2e (diff)
downloadpardes-8b0bed0427064794725f95cb7b020d7d96956975.tar.gz
pardes-8b0bed0427064794725f95cb7b020d7d96956975.zip
Preserve selections when navigating jump history
Diffstat (limited to 'src/panes.zig')
-rw-r--r--src/panes.zig227
1 files changed, 172 insertions, 55 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";