summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig180
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);
+}