diff options
Diffstat (limited to 'src/panes.zig')
| -rw-r--r-- | src/panes.zig | 227 |
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"; |
