diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 180 |
1 files changed, 179 insertions, 1 deletions
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); +} |
