summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-27 22:16:35 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit9660151c94e988620c79b40a0326057b9854c081 (patch)
tree521248762c58ac4187b6a7e1179d963577c2680d /src
parentf1025c18a78c02ebf157f0e3a5d9552605c082e8 (diff)
downloadpardes-9660151c94e988620c79b40a0326057b9854c081.tar.gz
pardes-9660151c94e988620c79b40a0326057b9854c081.zip
Move the mouse out of pardes.zig into mouse.zig
Pure move, no behaviour change: handleMouse with dragUpdate, dragRelease, seamIdxAt, reportGesture, gestureRange, dispatchPointerBuiltin, mirrorTtySelection and chordCutPaste, the pointer hit helpers (bodyHitForPane, sameTagCell, tagColumn, sameBodyCell), chromeTarget with ChromeTarget, paneAt, the Drag state type with clampBorderCol/Row, the seam test fixtures and thirteen pointer and drag tests go verbatim to mouse.zig. The methods become free functions taking `p: *Pardes`; their 36 call sites change from `p.handleMouse(..)` to `mouse.handleMouse(p, ..)`, including web.zig's chromeTarget and macos.zig's paneAt. The Mouse event type stays in pardes.zig with the rest of the event vocabulary. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/edit.zig5
-rw-r--r--src/fs.zig1
-rw-r--r--src/macos.zig3
-rw-r--r--src/mouse.zig1428
-rw-r--r--src/pardes.zig1447
-rw-r--r--src/web.zig3
6 files changed, 1462 insertions, 1425 deletions
diff --git a/src/edit.zig b/src/edit.zig
index f829decc..04416b67 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -3,6 +3,7 @@
//! operations, replace, case, join, indent, comments, numbers, textobjects
//! and surround, undo, yank and paste, and the pointer selections as text.
const pardes = @import("pardes.zig");
+const mouse = @import("mouse.zig");
const normal = @import("normal.zig");
const std = @import("std");
const modal = @import("modal.zig");
@@ -1533,12 +1534,12 @@ pub fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16, bod
if (!on_tag and row >= body_y and row - body_y < p.panes[id].?.notices.span() and
p.panes[id].?.notices.covers(row - body_y, col)) return null;
if (!on_tag) if (body_hit) |supplied| {
- const mapped_hit = p.bodyHitForPane(id, supplied, false) orelse return null;
+ const mapped_hit = mouse.bodyHitForPane(p, id, supplied, false) orelse return null;
const c = @as(i32, mapped_hit.col) - config.GUTTER;
const v = @as(i32, mapped_hit.row) + BOX_H;
return .{ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }, .on_tag = false };
};
- const tag_col = if (on_tag) p.tagColumn(.pane, id, tag_hit, false) else null;
+ const tag_col = if (on_tag) mouse.tagColumn(p, .pane, id, tag_hit, false) else null;
const c: i32 = (if (tag_col) |value| @as(i32, value) else @as(i32, col) - @as(i32, r.x + (if (on_tag) TAG_TEXT_INSET else config.GUTTER))) +
(if (on_tag) @as(i32, p.panes[id].?.tag_scroll) else 0);
const v: i32 = if (on_tag) 0 else @as(i32, row) - @as(i32, body_y) + @as(i32, BOX_H);
diff --git a/src/fs.zig b/src/fs.zig
index 5fbd3cd8..13fce858 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -42,6 +42,7 @@ const source_files = [_]Source{
.{ .path = "src/body_layer.zig", .contents = @embedFile("body_layer.zig") },
.{ .path = "src/edit.zig", .contents = @embedFile("edit.zig") },
.{ .path = "src/normal.zig", .contents = @embedFile("normal.zig") },
+ .{ .path = "src/mouse.zig", .contents = @embedFile("mouse.zig") },
.{ .path = "src/File.zig", .contents = @embedFile("File.zig") },
.{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") },
.{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") },
diff --git a/src/macos.zig b/src/macos.zig
index cab8212a..f83fb8b4 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -6,6 +6,7 @@ const builtin = @import("builtin");
const posix = std.posix;
const libc = std.c;
const pardes = @import("pardes.zig");
+const mouse = @import("mouse.zig");
const colors = @import("colors.zig");
const look = @import("look.zig");
const message = pardes.Messages.Message;
@@ -2623,7 +2624,7 @@ const Spent = struct {
};
fn spendScroll(st: *State, delta_rows: f32, delta_cols: f32, col: u16, row: u16) Spent {
- const pane = if (st.core.paneAt(col, row)) |id| st.core.panes[id] else null;
+ const pane = if (mouse.paneAt(st.core, col, row)) |id| st.core.panes[id] else null;
var out: Spent = .{};
var down_left = takeScrollTicks(&st.scroll_lag, delta_rows);
while (down_left != 0) {
diff --git a/src/mouse.zig b/src/mouse.zig
new file mode 100644
index 00000000..7ce95302
--- /dev/null
+++ b/src/mouse.zig
@@ -0,0 +1,1428 @@
+//! The mouse: what a pointer is over, the three buttons' presses, drags and
+//! releases (acme's chords, border and seam drags, gestures), and the
+//! chrome targets a shell asks about.
+const pardes = @import("pardes.zig");
+const std = @import("std");
+const layout = @import("layout.zig");
+const modal = @import("modal.zig");
+const ctlfs = @import("ninep/tree.zig");
+const panes = @import("panes.zig");
+const edit = @import("edit.zig");
+const body_layer = @import("body_layer.zig");
+const PanelCellDiff = @import("surface.zig").PanelCellDiff;
+const builtins = @import("builtins.zig");
+const config = @import("config.zig");
+const pdf_enabled = panes.Pdf.enabled;
+const pdf = panes.Pdf.pdf;
+const platform = pardes.platform;
+const Pane = panes.Pane;
+const MAX_PANES = pardes.MAX_PANES;
+const TagHit = @import("tag_layer.zig").Hit;
+const TagKind = @import("tag_layer.zig").Kind;
+const TOPBAR_H = pardes.TOPBAR_H;
+const BOX_H = pardes.BOX_H;
+const TAG_GAP = pardes.TAG_GAP;
+const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET;
+const Builtin = pardes.Builtin;
+const wordAtCol = pardes.wordAtCol;
+const Mouse = pardes.Mouse;
+const sel_slot = pardes.sel_slot;
+const Pardes = pardes.Pardes;
+
+pub const Drag = union(enum) {
+ none,
+ border_v: struct { left_col: usize, cur_x: u16, corner: ?struct { col: usize, idx: usize } = null, cur_y: u16 = 0 },
+ border_h: struct { col: usize, top_idx: usize, cur_y: u16 },
+ move: struct { id: usize, cur_x: u16, cur_y: u16 },
+ /// `grab`: how far into the grip it was pressed, so the column's left
+ /// edge lands at `cur_x - grab`. A release still on the grip moves nothing.
+ column_move: struct { serial: u32, cur_x: u16, grab: u16 = 0 },
+ tag: struct { id: usize },
+ /// chorded: a 1-2/1-3 cut/paste chord fired during this left drag —
+ /// the drag's own release is then inert
+ select: struct {
+ id: usize,
+ button: Mouse.Button,
+ chorded: bool = false,
+ ctrl: bool = false,
+ /// This gesture began over a usable native PDF raster. The payload is
+ /// zero-bit when PDF support is absent.
+ pdf: panes.Pdf.PointerDrag = .{},
+ },
+
+ fn button(drag: Drag) ?Mouse.Button {
+ return switch (drag) {
+ .none => null,
+ .border_v, .border_h, .move, .column_move, .tag => config.select_button,
+ .select => |selection| selection.button,
+ };
+ }
+};
+
+fn clampBorderCol(lx: u16, lw: u16, rw: u16, mcol: u16) u16 {
+ const lo = lx + config.MINW;
+ const hi = lx +| lw +| rw -| config.MINW;
+ return if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol;
+}
+
+fn clampBorderRow(ay: u16, ah: u16, bh: u16, mrow: u16, tag_bottom: bool) u16 {
+ const d: u16 = if (tag_bottom) 1 else 0;
+ const lo = ay + BOX_H - 1 + d;
+ const hi = (ay +| ah +| bh -| (BOX_H + 1)) +| d;
+ return if (lo <= hi) std.math.clamp(mrow, lo, hi) else mrow;
+}
+
+fn seamRowOf(p: *const Pardes, c: usize, k: usize) u16 {
+ const r = p.rects[p.col_panes[c][k]];
+ return if (p.settings.tag_bottom) r.y +| r.h else r.y + r.h -| 1;
+}
+
+fn cornerFixture(gpa: std.mem.Allocator) !*Pardes {
+ const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30, .shells = 3 });
+ p.update(.{ .resize = .{ .cols = 100, .rows = 30 } });
+ p.active = p.col_panes[1][0];
+ p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell below it, same column
+ p.sync();
+ return p;
+}
+
+fn nudgeRightSeam(p: *Pardes, delta: i32) u16 {
+ const from = seamRowOf(p, 1, 0);
+ const inside = p.col_x[1] + p.col_w[1] / 2;
+ const to: u16 = @intCast(@as(i32, from) + delta);
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = inside, .row = from } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = inside, .row = to } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = inside, .row = to } });
+ p.sync();
+ return seamRowOf(p, 1, 0);
+}
+
+pub const ChromeTarget = struct { col: u16, row: u16 };
+
+pub fn chromeTarget(p: *const Pardes, col: u16, row: u16) ?ChromeTarget {
+ if (row < p.topBarHeight() + p.columnBarHeight()) return .{ .col = col, .row = row };
+ for (p.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const rect = p.rects[id];
+ const tag = if (p.settings.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
+ if (row == tag and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER)
+ return .{ .col = col, .row = tag };
+ }
+ for (0..p.ncol -| 1) |column| {
+ const handle = p.col_x[column] + p.col_w[column] -| 1;
+ if (col == handle) return .{ .col = handle, .row = row };
+ }
+ for (0..p.ncol) |column| {
+ if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue;
+ for (0..p.col_n[column] -| 1) |index| {
+ const rect = p.rects[p.col_panes[column][index]];
+ const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
+ if (row == handle) return .{ .col = col, .row = handle };
+ }
+ }
+ for (p.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const rect = p.rects[id];
+ const tag = if (p.settings.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
+ if (row == tag and col >= rect.x and col < rect.x + rect.w)
+ return .{ .col = col, .row = tag };
+ }
+ for (0..p.ncol -| 1) |column| {
+ const handle = p.col_x[column] + p.col_w[column] -| 1;
+ if (@max(col, handle) - @min(col, handle) == 1) return .{ .col = handle, .row = row };
+ }
+ for (0..p.ncol) |column| {
+ if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue;
+ for (0..p.col_n[column] -| 1) |index| {
+ const rect = p.rects[p.col_panes[column][index]];
+ const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
+ if (@max(row, handle) - @min(row, handle) == 1) return .{ .col = col, .row = handle };
+ }
+ }
+ return null;
+}
+
+pub fn paneAt(p: *const Pardes, col: u16, row: u16) ?usize {
+ for (p.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const r = p.rects[id];
+ if (col >= r.x and col < r.x + r.w and row >= r.y and row < r.y + r.h) return id;
+ }
+ return null;
+}
+
+pub fn bodyHitForPane(p: *const Pardes, id: usize, supplied: Mouse.BodyHit, clamp: bool) ?Mouse.BodyHit {
+ const pane = p.panes[id] orelse return null;
+ if (pane.file == null or (!clamp and supplied.serial != pane.serial)) return null;
+ if (!std.math.isFinite(supplied.pixel_x) or !std.math.isFinite(supplied.pixel_y) or
+ supplied.metrics.body_w == 0 or supplied.metrics.body_h == 0 or
+ supplied.metrics.tagline_w == 0 or supplied.metrics.tagline_h == 0) return null;
+ var mapped_hit = supplied;
+ if (clamp) {
+ mapped_hit.serial = pane.serial;
+ const layer = for (&p.surface.body_layers) |*layer| {
+ if (layer.rows > 0 and layer.serial == pane.serial) break layer;
+ } else return null;
+ const bw: f32 = @floatFromInt(mapped_hit.metrics.body_w);
+ const bh: f32 = @floatFromInt(mapped_hit.metrics.body_h);
+ const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * bw;
+ const y0 = @as(f32, @floatFromInt(layer.viewport.y)) * bh;
+ const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * bw;
+ const y1 = y0 + @as(f32, @floatFromInt(layer.viewport.h)) * bh;
+ mapped_hit.pixel_x = std.math.clamp(mapped_hit.pixel_x, x0, @max(x0, x1 - 0.001));
+ mapped_hit.pixel_y = std.math.clamp(mapped_hit.pixel_y, y0, @max(y0, y1 - 0.001));
+ }
+ return p.reprojectBodyHit(mapped_hit);
+}
+
+pub fn sameTagCell(a: ?TagHit, b: ?TagHit) bool {
+ const first = a orelse return b == null;
+ const second = b orelse return false;
+ return first.kind == second.kind and first.id == second.id and first.serial == second.serial and first.col == second.col;
+}
+
+pub fn tagColumn(p: *const Pardes, kind: TagKind, id: usize, supplied: ?TagHit, clamp: bool) ?u16 {
+ var point = supplied orelse return null;
+ if (point.kind != kind or point.id != id) return null;
+ if (clamp) for (p.surface.tagLayers()) |*layer| {
+ if (layer.cols == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial) continue;
+ const bw: f32 = @floatFromInt(point.metrics.body_w);
+ const bh: f32 = @floatFromInt(point.metrics.body_h);
+ const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw;
+ const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw;
+ point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001));
+ point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * bh;
+ break;
+ };
+ return if (p.reprojectTagHit(point)) |mapped| mapped.col else null;
+}
+
+pub fn sameBodyCell(a: ?Mouse.BodyHit, b: ?Mouse.BodyHit) bool {
+ const first = a orelse return b == null;
+ const second = b orelse return false;
+ return first.serial == second.serial and first.row == second.row and first.col == second.col;
+}
+
+pub fn handleMouse(p: *Pardes, m: Mouse) void {
+ // A click goes somewhere else: the question it leaves unanswered is
+ // taken back, and the pane stays. A wheel only looks around.
+ if (m.kind == .press and switch (m.button) {
+ .left, .middle, .right => true,
+ else => false,
+ }) for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ if (pane.prompt == .del_side) pane.prompt = .none;
+ };
+ if (m.button == .back or m.button == .forward) {
+ if (m.kind == .press) p.jumpBy(if (m.button == .back) -1 else 1);
+ return;
+ }
+ const raw_col = @min(m.col, p.screen_w -| 1);
+ const raw_row = @min(m.row, p.screen_h -| 1);
+ p.pointer_raw_col = raw_col;
+ p.pointer_raw_row = raw_row;
+ const previous_hit = p.pointer_body_hit;
+ p.pointer_body_hit = m.body_hit;
+ const previous_tag_hit = p.pointer_tag_hit;
+ p.pointer_tag_hit = if (m.tag_hit) |point| p.reprojectTagHit(point) else null;
+ if (m.tag_hit != null and p.pointer_tag_hit == null and m.kind == .press) return;
+ if (m.tag_hit == null and (m.kind == .drag or m.kind == .release) and (p.drag == .tag or p.drag == .select or p.header_drag)) {
+ if (previous_tag_hit) |previous| {
+ var continued = previous;
+ continued.pixel_x = (@as(f32, @floatFromInt(raw_col)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_w));
+ continued.pixel_y = (@as(f32, @floatFromInt(raw_row)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_h));
+ p.pointer_tag_hit = continued;
+ }
+ }
+ if ((m.kind == .drag or m.kind == .release) and (p.drag == .tag or p.drag == .select or p.header_drag)) {
+ if (previous_tag_hit) |previous| if (p.pointer_tag_hit) |*current| {
+ current.kind = previous.kind;
+ current.id = previous.id;
+ current.serial = previous.serial;
+ };
+ }
+ // A drag can leave the compact text viewport. Preserve its metrics so
+ // the endpoint still clamps to that body's last visible logical row.
+ if (m.body_hit == null and (m.kind == .drag or m.kind == .release) and p.drag == .select) {
+ if (previous_hit) |previous| {
+ var continued = previous;
+ continued.pixel_x = (@as(f32, @floatFromInt(raw_col)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_w));
+ continued.pixel_y = (@as(f32, @floatFromInt(raw_row)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_h));
+ p.pointer_body_hit = continued;
+ }
+ }
+ p.pointer_inside = true;
+ if (m.button == .none and m.kind == .motion) {
+ p.raw_hover_intent = true;
+ p.refreshLookHoverFromRaw();
+ return;
+ }
+ p.raw_hover_intent = false;
+ const mapped = p.presentation.pointer(p.screen_w, p.screen_h, raw_col, raw_row) orelse {
+ p.cancelLookHover();
+ if (m.kind == .release) dragRelease(p, m.button);
+ return;
+ };
+ const mcol = mapped.col;
+ const mrow = mapped.row;
+ p.hover_col = mcol;
+ p.hover_row = mrow;
+
+ const hovered = paneAt(p, mcol, mrow);
+
+ // any press that is NOT the execute+select chord abandons a pending
+ // chord argument
+ if (m.kind == .press and
+ !(m.button == config.select_button and p.drag == .select and p.drag.select.button == config.exec_button))
+ {
+ if (p.chord_arg) |a| {
+ p.gpa.free(a);
+ p.chord_arg = null;
+ }
+ }
+ if (mrow >= p.topBarHeight() and mrow < p.topBarHeight() + p.columnBarHeight() and m.kind == .press) {
+ for (0..p.ncol) |column| {
+ if (mcol < p.col_x[column] or mcol >= p.col_x[column] + @min(config.GUTTER, p.col_w[column])) continue;
+ if (m.button == config.select_button) {
+ p.header_drag = false;
+ p.drag = .{ .column_move = .{ .serial = p.col_serial[column], .cur_x = mcol, .grab = mcol - p.col_x[column] } };
+ }
+ return;
+ }
+ }
+ if (p.header_drag and m.button == config.select_button and (m.kind == .drag or m.kind == .release)) {
+ const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0;
+ const bar = p.headerText();
+ p.topbar_col = @intCast(@min(bar.len, panes.File.rawAtDisplay(bar, (tagColumn(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + p.header_scroll)));
+ if (m.kind == .release) p.header_drag = false;
+ return;
+ }
+ if (mrow < p.topBarHeight() + p.columnBarHeight() and m.kind == .press and
+ (m.button == config.select_button or m.button == config.exec_button or m.button == config.look_button))
+ {
+ if (m.button == config.select_button) if (p.panes[p.active]) |pane| {
+ if (pane.tag_edit) Pardes.exitTagEdit(pane);
+ };
+ var column: ?usize = null;
+ var x: u16 = 0;
+ if (mrow >= p.topBarHeight()) {
+ for (0..p.ncol) |c| if (mcol >= p.col_x[c] and mcol < p.col_x[c] + p.col_w[c]) {
+ column = c;
+ x = p.col_x[c] + TAG_TEXT_INSET;
+ if (layout.findPane(p, p.active)) |active_col| {
+ if (active_col.col != c and p.col_n[c] > 0) p.active = p.col_panes[c][0];
+ }
+ break;
+ };
+ if (column == null or mcol < x) return;
+ }
+ if (p.header_column != column) {
+ p.rememberHeader();
+ p.topbar_col = null;
+ p.header_anchor = null;
+ p.header_scroll = 0;
+ }
+ p.header_column = column;
+ const bar = p.headerText();
+ const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tagColumn(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + p.header_scroll));
+ if (m.button == config.select_button) {
+ p.topbar_col = @intCast(at);
+ p.header_anchor = @intCast(at);
+ p.header_insert = true;
+ p.header_drag = true;
+ } else {
+ const selected = if (p.topbar_col) |cur| blk: {
+ const a = p.header_anchor orelse cur;
+ break :blk if (at >= @min(a, cur) and at < @max(a, cur)) bar[@min(a, cur)..@max(a, cur)] else wordAtCol(bar, at);
+ } else wordAtCol(bar, at);
+ const word = p.scratch.allocator().dupe(u8, selected) catch return;
+ p.exitHeader();
+ if (word.len > 0) {
+ if (m.button == config.exec_button) {
+ const named = std.meta.stringToEnum(Builtin, word);
+ const held = if (named) |b| (if (builtins.registry.takesArg(b)) p.heldSelection(p.active) else null) else p.heldSelection(p.active);
+ _ = p.execute(p.active, p.withArg(word, held));
+ } else p.runBuiltin(config.look_cmd, p.active, "", word);
+ }
+ }
+ return;
+ }
+ if (m.kind == .press) {
+ p.exitHeader();
+ }
+
+ switch (m.button) {
+ .none, .back, .forward => {}, // hover and navigation handled above
+ .wheel_up, .wheel_down => {
+ if (m.kind != .press) return;
+ // re-arms the drift guard below; vertical always scrolls, so
+ // the answer is never in doubt here
+ _ = config.wheelTick(&p.wheel_guard, true);
+ const id = hovered orelse return;
+ const pane = p.panes[id].?;
+ if (pane.hasPdf())
+ panes.Pdf.verticalWheel(p, pane, if (m.button == .wheel_up) -1 else 1)
+ else
+ pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows);
+ },
+ .wheel_left, .wheel_right => {
+ if (m.kind != .press) return;
+ if (!config.wheelTick(&p.wheel_guard, false)) return;
+ const id = hovered orelse return;
+ const pane = p.panes[id].?;
+ if (pane.hasPdf()) {
+ panes.Pdf.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1);
+ // ponytail: no right clamp — overscroll shows blank and the
+ // next cursor move or left wheel pulls it back
+ } else if (pane.file != null and !p.settings.wrap) {
+ // wrapped there is nothing off to the right to reach
+ const line = panes.File.sourceLine(pane, pane.cur_row);
+ const visual = panes.File.rawDisplayCol(line, @intCast(@max(0, pane.hscroll)));
+ const next: usize = if (m.button == .wheel_right)
+ visual +| @as(usize, @intCast(config.wheel_cols))
+ else
+ visual -| @as(usize, @intCast(config.wheel_cols));
+ pane.hscroll = @intCast(panes.File.rawAtDisplay(line, next));
+ }
+ },
+ config.select_button => switch (m.kind) {
+ .press => {
+ if (p.drag == .select and p.drag.select.button == config.exec_button) {
+ if (p.heldSelection(p.drag.select.id)) |tx| {
+ if (p.chord_arg) |old| p.gpa.free(old);
+ p.chord_arg = p.gpa.dupe(u8, tx) catch null;
+ }
+ return;
+ }
+ // acme 3-1: a select tap during a LOOK drag cancels the
+ // look; the rest of the gesture is inert
+ if (p.drag == .select and p.drag.select.button == config.look_button) {
+ if (p.panes[p.drag.select.id]) |t| {
+ t.sel[@intFromEnum(config.look_button)].state = .none;
+ if (comptime pdf_enabled)
+ panes.Pdf.pointerCancel(t, p.drag.select.pdf);
+ }
+ p.drag = .none;
+ return;
+ }
+ if (hovered) |mid| {
+ const mr = p.rects[mid];
+ const mtag = if (p.settings.tag_bottom) mr.y + mr.h -| BOX_H else mr.y;
+ if (mcol < mr.x + config.GUTTER and mrow >= mtag and mrow < mtag + BOX_H) {
+ p.drag = .{ .move = .{ .id = mid, .cur_x = mcol, .cur_y = mrow } };
+ return;
+ }
+ }
+ for (0..p.ncol -| 1) |c| {
+ if (mcol == p.col_x[c] + p.col_w[c] -| 1) {
+ const corner: @FieldType(@FieldType(Drag, "border_v"), "corner") = if (seamIdxAt(p, c, mrow)) |k|
+ .{ .col = c, .idx = k }
+ else if (seamIdxAt(p, c + 1, mrow)) |k|
+ .{ .col = c + 1, .idx = k }
+ else
+ null;
+ p.drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol, .corner = corner, .cur_y = mrow } };
+ return;
+ }
+ }
+ for (0..p.ncol) |cc| {
+ if (mcol < p.col_x[cc] or mcol >= p.col_x[cc] + p.col_w[cc]) continue;
+ if (seamIdxAt(p, cc, mrow)) |k| {
+ p.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } };
+ return;
+ }
+ }
+ const id = hovered orelse return;
+ const r = p.rects[id];
+ const pane = p.panes[id] orelse return;
+ if (id != p.active) if (p.panes[p.active]) |previous| {
+ if (previous.tag_edit) Pardes.exitTagEdit(previous);
+ };
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ const body_y = p.bodyTop(r);
+ if (mcol < r.x + config.GUTTER) {
+ // gutter scrollbar: a left click scrolls UP to that row
+ p.active = id;
+ pane.scrollBy(-(@as(i32, mrow) - @as(i32, body_y)));
+ } else if (mrow >= tag_y and mrow < tag_y + BOX_H) {
+ if (mcol < r.x + TAG_TEXT_INSET) return;
+ p.active = id;
+ p.enterTagEdit(pane, if (tagColumn(p, .pane, id, p.pointer_tag_hit, false)) |value| @as(i32, value) else @as(i32, mcol) - @as(i32, r.x + TAG_TEXT_INSET));
+ pane.tag_anchor = pane.tag_col;
+ p.drag = .{ .tag = .{ .id = id } };
+ } else {
+ p.active = id;
+ if (pane.tag_edit) Pardes.exitTagEdit(pane);
+ const pointed = edit.pointerTextSelection(p, id, mcol, mrow, m.body_hit, p.pointer_tag_hit) orelse return;
+ pane.clearPointerSelection(sel_slot);
+ pane.sel[sel_slot] = pointed.sel;
+ p.drag = .{ .select = .{
+ .id = id,
+ .button = config.select_button,
+ .ctrl = m.ctrl,
+ .pdf = panes.Pdf.pointerStart(
+ p,
+ pane,
+ config.select_button,
+ mcol,
+ mrow,
+ false,
+ ),
+ } };
+ }
+ },
+ .drag => dragUpdate(p, mcol, mrow, p.pointer_body_hit),
+ .release => {
+ if ((p.pointer_body_hit != null or p.pointer_tag_hit != null or p.drag == .column_move) and p.drag.button() == m.button) dragUpdate(p, mcol, mrow, p.pointer_body_hit);
+ dragRelease(p, config.select_button);
+ },
+ .motion => {},
+ },
+ config.exec_button, config.look_button => switch (m.kind) {
+ .press => {
+ // acme 1-2 / 1-3: the execute (Cut) or look (Paste) button
+ // tapped while the select button holds a selection drag
+ if (p.drag == .select and p.drag.select.button == config.select_button) {
+ chordCutPaste(p, m.button == config.exec_button);
+ return;
+ }
+ // acme 2-3 / 3-2: tapping the OTHER of execute/look during
+ // an execute or look drag cancels it (textselect23's mask)
+ if (p.drag == .select) {
+ if (p.panes[p.drag.select.id]) |t| {
+ t.sel[@intFromEnum(p.drag.select.button)].state = .none;
+ if (comptime pdf_enabled)
+ panes.Pdf.pointerCancel(t, p.drag.select.pdf);
+ }
+ p.drag = .none;
+ return;
+ }
+ const id = hovered orelse return;
+ const r = p.rects[id];
+ const pane = p.panes[id] orelse return;
+ // the tag row and the body's first, exactly as the select
+ // arm above derives them
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ const body_y = p.bodyTop(r);
+ const on_tag = mrow >= tag_y and mrow < tag_y + BOX_H;
+ if (mcol < r.x + config.GUTTER and !on_tag) {
+ const local = @as(i32, mrow) - @as(i32, body_y);
+ if (m.button == config.look_button) p.active = id;
+ pane.scrollBy(if (m.button == config.look_button) local else -local);
+ } else if (edit.pointerTextSelection(p, id, mcol, mrow, m.body_hit, p.pointer_tag_hit)) |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 = .{
+ .id = id,
+ .button = m.button,
+ // A native body gesture stays native even when its
+ // point later misses a letterbox or selection.
+ .pdf = panes.Pdf.pointerStart(
+ p,
+ pane,
+ m.button,
+ mcol,
+ mrow,
+ pointed.on_tag,
+ ),
+ },
+ };
+ }
+ },
+ .drag => dragUpdate(p, mcol, mrow, p.pointer_body_hit),
+ .release => {
+ if ((p.pointer_body_hit != null or p.pointer_tag_hit != null or p.drag == .column_move) and p.drag.button() == m.button) dragUpdate(p, mcol, mrow, p.pointer_body_hit);
+ dragRelease(p, m.button);
+ },
+ .motion => {},
+ },
+ }
+}
+
+/// Index of the pane pair in column `c` whose seam (see seamRowOf) is screen
+/// row `mrow`. Out-of-range columns simply have no seam.
+pub fn seamIdxAt(p: *const Pardes, c: usize, mrow: u16) ?usize {
+ if (c >= p.ncol) return null;
+ for (0..p.col_n[c] -| 1) |k| if (mrow == seamRowOf(p, c, k)) return k;
+ return null;
+}
+
+pub fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) void {
+ switch (p.drag) {
+ .border_v => |*d| {
+ const c = d.left_col;
+ d.cur_x = if (c + 1 < p.ncol)
+ clampBorderCol(p.col_x[c], p.col_w[c], p.col_w[c + 1], mcol)
+ else
+ mcol;
+ if (d.corner) |k| if (k.idx + 1 < p.col_n[k.col]) {
+ const a = p.rects[p.col_panes[k.col][k.idx]];
+ const b = p.rects[p.col_panes[k.col][k.idx + 1]];
+ d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom);
+ } else {
+ d.cur_y = mrow;
+ };
+ },
+ .border_h => |*d| {
+ const cc = d.col;
+ const k = d.top_idx;
+ if (k + 1 < p.col_n[cc]) {
+ const a = p.rects[p.col_panes[cc][k]];
+ const b = p.rects[p.col_panes[cc][k + 1]];
+ d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom);
+ } else d.cur_y = mrow;
+ },
+ .column_move => |*d| d.cur_x = mcol,
+ .move => |*d| {
+ d.cur_x = mcol;
+ d.cur_y = mrow;
+ },
+ .select => |*s| {
+ if (p.panes[s.id]) |pane| {
+ const r = p.rects[s.id];
+ const b = @intFromEnum(s.button);
+ if (pane.body_rows > 0 and pane.sel[b].r0 >= BOX_H) if (body_hit) |supplied| {
+ const mapped_hit = bodyHitForPane(p, s.id, supplied, true) orelse return;
+ pane.sel[b].c1 = @as(i32, mapped_hit.col) - config.GUTTER;
+ pane.sel[b].r1 = @as(i32, mapped_hit.row) + BOX_H;
+ return;
+ };
+ if (pane.sel[b].r0 < BOX_H) if (tagColumn(p, .pane, s.id, p.pointer_tag_hit, true)) |value| {
+ pane.sel[b].c1 = @as(i32, value) + pane.tag_scroll;
+ pane.sel[b].r1 = 0;
+ return;
+ };
+ pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
+ // Sel row BOX_H is the body's FIRST row, the same thing
+ // pointerTextSelection means by it -- so the drag has to
+ // measure from the body's top, notice bands included, or a
+ // selection made while a message is up lands rows away
+ // from the pointer.
+ const body_y = p.bodyTop(r);
+ const body_h = r.h -| BOX_H;
+ pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0)
+ 0
+ else
+ @as(i32, @min(@max(mrow, body_y), body_y + body_h - 1)) - @as(i32, body_y) + @as(i32, BOX_H);
+ if (pane.sel[b].r1 < BOX_H) pane.sel[b].c1 += @as(i32, pane.tag_scroll) - TAG_GAP;
+ if (comptime pdf_enabled)
+ panes.Pdf.pointerUpdate(&s.pdf, p, pane, mcol, mrow);
+ }
+ },
+ .tag => |d| {
+ const pane = p.panes[d.id] orelse return;
+ const c = if (tagColumn(p, .pane, d.id, p.pointer_tag_hit, true)) |value| @as(i32, value) else @as(i32, mcol) - @as(i32, p.rects[d.id].x + TAG_TEXT_INSET);
+ const text = p.tagText(p.scratch.allocator(), pane) catch return;
+ pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @as(usize, @intCast(@max(0, c))) + pane.tag_scroll)));
+ pane.tag_sel = pane.tag_col != pane.tag_anchor;
+ },
+ .none => {},
+ }
+}
+
+fn reportGesture(
+ p: *Pardes,
+ id: usize,
+ cmd: Builtin,
+ text: []const u8,
+ on_tag: bool,
+ operand: Pardes.PointerOperand,
+ chorded: bool,
+) bool {
+ const scripted = p.panes[id] orelse return false;
+ if (scripted.fs.readers == 0) return false;
+ const is_look = cmd == config.look_cmd;
+ const action: ctlfs.events.Action = if (is_look)
+ (if (on_tag) .tag_look else .body_look)
+ else
+ (if (on_tag) .tag_exec else .body_exec);
+ const named = std.meta.stringToEnum(Builtin, Pardes.commandText(text)) != null;
+ const range = gestureRange(p, id, text, on_tag, operand);
+ var flag: u32 = if (named) ctlfs.events.flag_builtin else 0;
+ if (range.q0 == range.q1 and text.len > 0) flag |= ctlfs.events.flag_expansion;
+ if (is_look) {
+ if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= ctlfs.events.flag_filename;
+ } else if (chorded) flag |= ctlfs.events.flag_chorded;
+ return ctlfs.events.noteAction(p, id, action, range.q0, range.q1, flag, text);
+}
+
+fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: Pardes.PointerOperand) ctlfs.pane.State.Range {
+ const pane = p.panes[id] orelse return .{};
+ if (on_tag) {
+ const tag = p.tagText(p.scratch.allocator(), pane) catch return .{};
+ const sel = operand.expanded orelse operand.preview orelse return .{};
+ const lead = @min(sel.c0, sel.c1);
+ const at = panes.File.rawAtDisplay(tag, @intCast(@max(0, lead)));
+ const q0: u32 = @intCast(@min(at, tag.len));
+ return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, tag.len)) };
+ }
+ const f = if (pane.file) |*file| file else return .{};
+ if (operand.preview) |selection| if (pane.pointerSelection(selection)) |rows| {
+ if (rows.len == 0) return .{};
+ const first = rows[0];
+ const last = rows[rows.len - 1];
+ const q0 = panes.File.lineStart(p.gpa, f, @intCast(@max(0, first.row))) +
+ @min(first.lo, panes.File.sourceLine(pane, first.row).len);
+ const q1 = panes.File.lineStart(p.gpa, f, @intCast(@max(0, last.row))) +
+ @min(last.hi, panes.File.sourceLine(pane, last.row).len);
+ // A rectangle may contain disjoint pieces. Such an operand uses
+ // the event's expansion text instead of inventing a source range.
+ if (q1 < q0 or !std.mem.eql(u8, f.content[q0..q1], text)) return .{};
+ return .{ .q0 = @intCast(q0), .q1 = @intCast(q1) };
+ };
+ const start: ?modal.Cursor = if (operand.file_word) |w|
+ .{ .row = @intCast(@max(0, w.row)), .col = @intCast(@max(0, w.lo)) }
+ else if (operand.expanded orelse operand.preview) |sel| lead: {
+ // A selection's leading corner in reading order, converted the way
+ // `pointerOperand` converts the click itself.
+ const top = @min(sel.r0, sel.r1) - @as(i32, BOX_H);
+ if (top < 0) break :lead null;
+ const w = pane.wrapAt(top);
+ const col = edit.paneByteAtDisplay(p, pane, w.line, w.at, @min(sel.c0, sel.c1) - panes.File.gutterWidth(pane));
+ break :lead .{
+ .row = @intCast(@max(0, w.line)),
+ .col = @intCast(@max(0, col)),
+ };
+ } else null;
+ const cursor = start orelse return .{};
+ const q0: u32 = @intCast(@min(modal.offsetAt(f.content, cursor), f.content.len));
+ return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, f.content.len)) };
+}
+
+fn dispatchPointerBuiltin(
+ p: *Pardes,
+ id: usize,
+ cmd: Builtin,
+ text: ?[]const u8,
+ gesture: ?struct { on_tag: bool, operand: Pardes.PointerOperand },
+) void {
+ const arg = p.chord_arg;
+ p.chord_arg = null;
+ defer if (arg) |a| p.gpa.free(a);
+ const operand = text orelse {
+ if (gesture) |g| _ = reportGesture(p, id, cmd, "", g.on_tag, g.operand, arg != null);
+ return;
+ };
+ if (gesture) |g| if (reportGesture(p, id, cmd, operand, g.on_tag, g.operand, arg != null)) return;
+ p.runBuiltin(cmd, id, "", p.withArg(operand, arg));
+}
+
+fn mirrorTtySelection(p: *Pardes, pane: *Pane) void {
+ const gesture = pane.sel[sel_slot];
+ const rows = pane.pointerSelection(gesture) orelse return;
+ const text = pane.rawPointerText(gesture) orelse return;
+ if (rows.len == 0) return;
+ const endpoint = rows[if (gesture.r1 >= gesture.r0) rows.len - 1 else 0];
+ const body = body_layer.bodyText(p, p.scratch.allocator(), pane) catch return;
+ const line = modal.lineSlice(body, @intCast(@max(0, endpoint.row - panes.Terminal.gridOffset(pane))));
+ const byte = if (gesture.c1 >= gesture.c0) modal.prevGrapheme(line, @min(endpoint.hi, line.len)) else endpoint.lo;
+ pane.cur_row = pane.surfRow(endpoint.row);
+ var shown = std.mem.trimEnd(u8, line[@min(endpoint.prompt_bytes, line.len)..], " \t");
+ if (pane.ovl) |overlay| {
+ const relative = pane.cur_row - overlay.row;
+ if (relative >= 0 and relative < modal.lineCount(overlay.text))
+ shown = modal.lineSlice(overlay.text, @intCast(relative));
+ }
+ pane.cur_col = @intCast(modal.graphemeStart(shown, @min(shown.len, byte -| endpoint.prompt_bytes)));
+ pane.cur_pinned = true;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.nsel = 0;
+ pane.select = false;
+ pane.append_at = null;
+ pane.sticky_col = -1;
+ pane.normal.clear();
+ edit.setYank(p, text);
+}
+
+fn dragRelease(p: *Pardes, button: Mouse.Button) void {
+ // A different button's release belongs to a chord or another host
+ // stream; only the button which began this gesture may end it.
+ if (p.drag.button() != button) return;
+ defer p.drag = .none;
+ switch (p.drag) {
+ .border_v => |d| {
+ const c = d.left_col;
+ const old_seam = p.col_x[c] + p.col_w[c] -| 1;
+ // the handle is the left pane's LAST column (inclusive), so
+ // the new width includes it — a no-drag click changes nothing
+ if (c + 1 < p.ncol and d.cur_x != old_seam)
+ layout.setColumnPairWidth(p, c, (d.cur_x + 1) -| p.col_x[c]);
+ if (d.corner) |k| {
+ if (d.cur_y != seamRowOf(p, k.col, k.idx))
+ layout.applyRowSplit(p, k.col, k.idx, d.cur_y);
+ }
+ },
+ .border_h => |d| if (d.cur_y != seamRowOf(p, d.col, d.top_idx))
+ layout.applyRowSplit(p, d.col, d.top_idx, d.cur_y),
+ .column_move => |d| if (layout.columnBySerial(p, d.serial)) |column| switch (layout.columnDrop(p, column, d.cur_x, d.grab)) {
+ .stay => {},
+ .reorder => |target| layout.reorderColumn(p, column, target),
+ .edge => |x| layout.setColumnPairWidth(p, column - 1, x - p.col_x[column - 1]),
+ },
+ .move => |d| {
+ layout.movePane(p, d.id, d.cur_x, d.cur_y);
+ if (p.panes[d.id]) |mp| p.stillborn_joiner = mp.serial;
+ // a file moved into the left column evicts a lone unused shell
+ if (p.panes[d.id]) |mt| if (mt.file != null) {
+ if (layout.findPane(p, d.id)) |f| if (f.col == 0)
+ p.evictLonePristineTty(0, d.id);
+ };
+ },
+ .select => |s| {
+ const pane = p.panes[s.id] orelse return;
+ if (comptime pdf_enabled) {
+ if (s.pdf.native) {
+ var release = panes.Pdf.pointerRelease(
+ p,
+ pane,
+ s.pdf,
+ s.button,
+ s.chorded,
+ );
+ defer release.deinit(p.pdf_gpa);
+ if (release.probe) |*probe| if (probe.link != null) {
+ p.followPdfLink(s.id, probe);
+ return;
+ };
+ // A native PDF page has no byte offsets of its own to
+ // report, so a scripted pane cannot intercept this one.
+ if (release.action) |action| dispatchPointerBuiltin(
+ p,
+ s.id,
+ if (action == .look) config.look_cmd else config.exec_cmd,
+ release.text,
+ null,
+ );
+ return;
+ }
+ }
+ if (s.chorded) {
+ // a cut/paste chord consumed this drag; the release is inert
+ pane.sel[@intFromEnum(s.button)].state = .none;
+ return;
+ }
+ const b = @intFromEnum(s.button);
+ pane.sel[b].state = .done;
+ if (s.button == config.select_button) {
+ 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) edit.capturePointerSelection(p, pane, sel_slot) catch {};
+ if (dragged and pane.mode == .tty) mirrorTtySelection(p, pane);
+ const body_vis = sl.r1 - @as(i32, BOX_H);
+ if (body_vis >= 0 and pane.mode != .tty) {
+ const w = pane.wrapAt(body_vis);
+ pane.cur_row = w.line;
+ pane.cur_col = edit.paneByteAtDisplay(
+ p,
+ pane,
+ w.line,
+ w.at,
+ sl.c1 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0),
+ );
+ pane.cur_pinned = true;
+ if (edit.clampFileCursor(pane)) pane.ensureCursorVisible();
+ pane.msel.active = false;
+ pane.normal.clear();
+ pane.vsel.active = false;
+ pane.nsel = 0; // a click says WHERE the one cursor is
+ pane.select = false;
+ pane.append_at = null;
+ pane.sticky_col = -1;
+ if (s.ctrl) p.lspRequest(s.id, .definition, "");
+ }
+ } else {
+ const clk = pane.sel[b];
+ // A right-click on a line NUMBER looks at that line of
+ // this file -- a sticky context header's number included,
+ // since the row map holds each header's own source line.
+ if (s.button == config.look_button and clk.r0 >= BOX_H and clk.r0 == clk.r1 and
+ clk.c0 == clk.c1 and clk.c0 >= 0 and pane.mode != .tty)
+ if (pane.file) |f| if (f.output == null and clk.c0 < panes.File.gutterWidth(pane)) {
+ const line = pane.wrapAt(clk.r0 - BOX_H).line;
+ if (line >= 0) {
+ pane.sel[b].state = .none;
+ var buf: [48]u8 = undefined;
+ const target = std.fmt.bufPrint(&buf, "@p{d}:{d}", .{ s.id, @as(usize, @intCast(line)) + 1 }) catch return;
+ p.runBuiltin(config.look_cmd, s.id, "", target);
+ return;
+ }
+ };
+ var operand = p.pointerOperand(pane, clk);
+ if (operand.expanded) |expanded|
+ pane.sel[b] = expanded
+ else if (operand.text == null)
+ pane.sel[b].state = .none;
+ if (s.button == config.look_button and clk.r0 >= BOX_H and pane.mode != .tty) {
+ pane.cur_row = operand.row;
+ pane.cur_col = operand.col;
+ if (edit.clampFileCursor(pane)) pane.ensureCursorVisible();
+ }
+ const txt = operand.text;
+ const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd;
+ if (clk.r0 < BOX_H and s.button == config.exec_button and pane.tag_name_len != null) {
+ const prefix_width = panes.File.displayWidth(p.tagPrefix(pane) catch return);
+ const on_name = @as(usize, @intCast(@max(0, clk.c0))) < prefix_width;
+ if (!p.commitTagName(pane)) return;
+ // Executing a staged filename commits it; executing its
+ // command tail uses the new identity (especially Save).
+ if (on_name) return;
+ const new_width = panes.File.displayWidth(p.tagPrefix(pane) catch return);
+ const shift = @as(i32, @intCast(new_width)) - @as(i32, @intCast(prefix_width));
+ if (operand.expanded) |*sel| {
+ sel.c0 += shift;
+ sel.c1 += shift;
+ }
+ if (operand.preview) |*sel| {
+ sel.c0 += shift;
+ sel.c1 += shift;
+ }
+ pane.sel[b].c0 += shift;
+ pane.sel[b].c1 += shift;
+ }
+ dispatchPointerBuiltin(p, s.id, cmd, txt, .{
+ .on_tag = clk.r0 < BOX_H,
+ .operand = operand,
+ });
+ }
+ },
+ .tag => {}, // dragUpdate already left the tag cursor + selection set
+ .none => {},
+ }
+}
+
+fn chordCutPaste(p: *Pardes, cut: bool) void {
+ const s = &p.drag.select;
+ const pane = p.panes[s.id] orelse return;
+ if (comptime pdf_enabled) if (pane.pdf) |pv| {
+ if (!s.chorded) {
+ s.chorded = true;
+ pane.sel[sel_slot].state = .none;
+ }
+ // A PDF is immutable: its Cut-side chord is Snarf (copy), while
+ // Paste has no document mutation to perform.
+ if (cut and pv.selection_text.len > 0) edit.setYank(p, pv.selection_text);
+ return;
+ };
+ if (pane.mode == .tty) {
+ if (!s.chorded) {
+ s.chorded = true;
+ pane.sel[sel_slot].state = .none; // drop the sweep highlight
+ }
+ if (cut) return;
+ const y = p.yank orelse return;
+ if (y.len == 0) return;
+ if (panes.Terminal.reportsMouse(pane)) {
+ const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999));
+ const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, BOX_H) + 1, 1, 9999));
+ var mb: [32]u8 = undefined;
+ if (panes.Terminal.mouseFormatSgr(pane)) {
+ p.emitWrite(s.id, std.fmt.bufPrint(&mb, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ col, row, col, row }) catch return);
+ } else {
+ // ponytail: legacy X10 bytes; add utf8/urxvt formats if an app ever wants them
+ const cb: u8 = @intCast(32 + @min(col, 222));
+ const rb: u8 = @intCast(32 + @min(row, 222));
+ p.emitWrite(s.id, &[_]u8{ 0x1b, '[', 'M', 32, cb, rb, 0x1b, '[', 'M', 35, cb, rb });
+ }
+ }
+ edit.typeToTty(p, s.id, pane, y);
+ return;
+ }
+ if (!s.chorded) {
+ s.chorded = true;
+ const sl = pane.sel[sel_slot];
+ pane.sel[sel_slot].state = .none;
+ const pfx: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0;
+ // both ends of the sweep through the same last-frame map, so a
+ // chord over wrapped rows cuts the text that was under it
+ const w0 = pane.wrapAt(@max(0, sl.r0 - @as(i32, BOX_H)));
+ const w1 = pane.wrapAt(@max(0, sl.r1 - @as(i32, BOX_H)));
+ const row0 = w0.line;
+ const row1 = w1.line;
+ const col0 = edit.paneByteAtDisplay(p, pane, w0.line, w0.at, sl.c0 - pfx);
+ const col1 = edit.paneByteAtDisplay(p, pane, w1.line, w1.at, sl.c1 - pfx);
+ pane.cur_row = row1;
+ pane.cur_col = col1;
+ pane.cur_pinned = true;
+ pane.msel.active = false;
+ pane.normal.clear();
+ if (!pane.isTerminal()) pane.mode = .normal;
+ pane.vsel = .{ .active = row0 != row1 or col0 != col1, .row = row0, .col = col0, .explicit = false };
+ pane.nsel = 0;
+ }
+ if (cut) {
+ if (pane.vsel.active) edit.normalDelete(p, pane, true);
+ } else if (pane.vsel.active) {
+ edit.normalReplaceYank(p, pane);
+ } else if (pane.file) |*f| {
+ // bare cursor: splice the register in literally (no linewise
+ // magic) so a paste right after a cut restores the text exactly
+ const y = p.yank orelse return;
+ if (y.len == 0) return;
+ const at = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
+ edit.pushUndo(p, pane);
+ const new = modal.insertAt(p.gpa, f.content, at, y) catch return;
+ panes.File.setContent(p, f, new);
+ pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @intCast(at.row), .col = @intCast(at.col), .explicit = false };
+ const end = modal.advanceBy(at, y);
+ if (end.col > 0) {
+ pane.cur_row = @intCast(end.row);
+ pane.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(f.content, end.row), end.col));
+ } else {
+ // the register ended in '\n': the cursor lands ON that newline
+ pane.cur_row = @intCast(end.row -| 1);
+ pane.cur_col = @intCast(modal.lineSlice(f.content, end.row -| 1).len);
+ }
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.ensureCursorVisible();
+ } else edit.pasteText(p, pane, p.yank orelse return, true, 1);
+}
+
+test "plain left click clears explicit modal selection" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 40, .rows = 10 });
+ defer p.deinit();
+ const pane = try p.setTestFile("abcdef\n");
+ pane.cur_row = 0;
+ pane.cur_col = 5;
+ pane.vsel = .{ .active = true, .row = 0, .col = 1, .explicit = true };
+ pane.msel = .{ .active = true, .r0 = 0, .r1 = 0 };
+
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const rect = p.rects[0];
+ const click_x = rect.x + config.GUTTER + config.PREFIX_W + 3;
+ const body_y = rect.y + BOX_H;
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = click_x, .row = body_y } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = click_x, .row = body_y } });
+
+ try std.testing.expectEqual(@as(i32, 3), pane.cur_col);
+ try std.testing.expect(!pane.vsel.active);
+ try std.testing.expect(!pane.msel.active);
+ try std.testing.expectEqual(.none, pane.sel[@intFromEnum(config.select_button)].state);
+}
+
+test "selection drag and queued release do not enter panel pending state" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 10, .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("alpha beta\n");
+ p.acknowledgePanelPresentation(&.{});
+ const rect = p.rects[0];
+ const x = rect.x + config.GUTTER + config.PREFIX_W + 1;
+ const y = rect.y + BOX_H;
+
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = x, .row = y } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = x + 2, .row = y } });
+ try std.testing.expect(!p.presentation.pending);
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = x + 2, .row = y } });
+ try std.testing.expect(p.drag == .none);
+ try std.testing.expectEqual(.done, pane.sel[sel_slot].state);
+}
+
+test "a corner drag's two axes clamp independently" {
+ // a 100-wide window split 50/50, and a 30-row column split 15/14 under the
+ // topbar: the shape windowops.snap drives
+ const lx: u16 = 0;
+ const lw: u16 = 50;
+ const rw: u16 = 50;
+ const ay: u16 = TOPBAR_H;
+ const ah: u16 = 15;
+ const bh: u16 = 14;
+ const row_lo: u16 = ay + BOX_H - 1;
+ const row_hi: u16 = ay + ah + bh - (BOX_H + 1);
+
+ // each axis alone, at both walls
+ try std.testing.expectEqual(@as(u16, config.MINW), clampBorderCol(lx, lw, rw, 0));
+ try std.testing.expectEqual(@as(u16, 100 - config.MINW), clampBorderCol(lx, lw, rw, 999));
+ try std.testing.expectEqual(row_lo, clampBorderRow(ay, ah, bh, 0, false));
+ try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999, false));
+
+ // THE corner property: drag off the right edge while staying mid-height —
+ // x sits at its wall, y still tracks the mouse row exactly
+ try std.testing.expectEqual(@as(u16, 100 - config.MINW), clampBorderCol(lx, lw, rw, 999));
+ try std.testing.expectEqual(@as(u16, 12), clampBorderRow(ay, ah, bh, 12, false));
+ // and the mirror: pinned at the bottom, x still free
+ try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999, false));
+ try std.testing.expectEqual(@as(u16, 40), clampBorderCol(lx, lw, rw, 40));
+ // both pinned at once is just both walls, not a stuck gesture
+ try std.testing.expectEqual(@as(u16, config.MINW), clampBorderCol(lx, lw, rw, 0));
+ try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999, false));
+
+ try std.testing.expectEqual(row_lo + 1, clampBorderRow(ay, ah, bh, 0, true));
+ try std.testing.expectEqual(row_hi + 1, clampBorderRow(ay, ah, bh, 999, true));
+ try std.testing.expectEqual(@as(u16, 12), clampBorderRow(ay, ah, bh, 12, true));
+
+ // degenerate pairs pass through untouched, each on its own terms: a pane
+ // pair with no room stays draggable even when the column pair has room
+ try std.testing.expectEqual(@as(u16, 7), clampBorderCol(0, 5, 5, 7));
+ try std.testing.expectEqual(@as(u16, 40), clampBorderCol(lx, lw, rw, 40));
+ try std.testing.expectEqual(@as(u16, 9), clampBorderRow(TOPBAR_H, 1, 0, 9, false));
+}
+
+test "a v-handle press at the RIGHT column's seam drags both boundaries" {
+ if (platform == .web) return;
+ const p = try cornerFixture(std.testing.allocator);
+ defer p.deinit();
+ try std.testing.expectEqual(@as(usize, 2), p.col_n[1]);
+
+ const handle = p.col_x[0] + p.col_w[0] - 1;
+ const right_seam = nudgeRightSeam(p, 3);
+ try std.testing.expect(right_seam != seamRowOf(p, 0, 0));
+
+ const w0 = p.col_weight[0];
+ const v_left = p.panes[p.col_panes[0][0]].?.vweight;
+ const v_right = p.panes[p.col_panes[1][0]].?.vweight;
+
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = right_seam } });
+ try std.testing.expect(p.drag == .border_v);
+ const corner = p.drag.border_v.corner orelse return error.NoCorner;
+ try std.testing.expectEqual(@as(usize, 1), corner.col);
+ try std.testing.expectEqual(@as(usize, 0), corner.idx);
+
+ p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle + 8, .row = right_seam - 4 } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle + 8, .row = right_seam - 4 } });
+ p.sync();
+
+ // both halves committed: the column pair widened, and the RIGHT column's
+ // pane pair reweighted — while the left column's panes stayed put
+ try std.testing.expect(p.col_weight[0] > w0);
+ try std.testing.expect(p.panes[p.col_panes[1][0]].?.vweight != v_right);
+ try std.testing.expectEqual(v_left, p.panes[p.col_panes[0][0]].?.vweight);
+}
+
+test "a tie row still moves the LEFT column's pane pair only" {
+ if (platform == .web) return;
+ const p = try cornerFixture(std.testing.allocator);
+ defer p.deinit();
+
+ // put column 1's seam exactly on column 0's, the row the eye reads as one
+ // line across the whole window
+ const left_seam = seamRowOf(p, 0, 0);
+ _ = nudgeRightSeam(p, @as(i32, left_seam) - @as(i32, seamRowOf(p, 1, 0)));
+ try std.testing.expectEqual(left_seam, seamRowOf(p, 1, 0));
+
+ const handle = p.col_x[0] + p.col_w[0] - 1;
+ const v_left = p.panes[p.col_panes[0][0]].?.vweight;
+ const v_right = p.panes[p.col_panes[1][0]].?.vweight;
+
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = left_seam } });
+ const corner = p.drag.border_v.corner orelse return error.NoCorner;
+ try std.testing.expectEqual(@as(usize, 0), corner.col);
+
+ p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle, .row = left_seam - 4 } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = left_seam - 4 } });
+ p.sync();
+ try std.testing.expect(p.panes[p.col_panes[0][0]].?.vweight != v_left);
+ try std.testing.expectEqual(v_right, p.panes[p.col_panes[1][0]].?.vweight);
+}
+
+test "a v-handle press at nobody's seam is still a plain edge drag" {
+ if (platform == .web) return;
+ const p = try cornerFixture(std.testing.allocator);
+ defer p.deinit();
+ const right_seam = nudgeRightSeam(p, 3);
+ const left_seam = seamRowOf(p, 0, 0);
+
+ const handle = p.col_x[0] + p.col_w[0] - 1;
+ var row: u16 = TOPBAR_H + 1;
+ while (row == left_seam or row == right_seam) row += 1;
+
+ const w0 = p.col_weight[0];
+ const v_left = p.panes[p.col_panes[0][0]].?.vweight;
+ const v_right = p.panes[p.col_panes[1][0]].?.vweight;
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = row } });
+ try std.testing.expect(p.drag == .border_v);
+ try std.testing.expect(p.drag.border_v.corner == null);
+ p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle + 8, .row = row + 5 } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle + 8, .row = row + 5 } });
+ p.sync();
+ // exactly ONE boundary moved
+ try std.testing.expect(p.col_weight[0] > w0);
+ try std.testing.expectEqual(v_left, p.panes[p.col_panes[0][0]].?.vweight);
+ try std.testing.expectEqual(v_right, p.panes[p.col_panes[1][0]].?.vweight);
+}
+
+test "a RIGHT-column corner's two axes clamp independently" {
+ if (platform == .web) return;
+ const p = try cornerFixture(std.testing.allocator);
+ defer p.deinit();
+ const right_seam = nudgeRightSeam(p, 3);
+ const handle = p.col_x[0] + p.col_w[0] - 1;
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = right_seam } });
+ try std.testing.expectEqual(@as(usize, 1), (p.drag.border_v.corner orelse return error.NoCorner).col);
+
+ // off the right edge at mid-height: x parks at its MINW wall, y is the mouse
+ p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = 999, .row = right_seam } });
+ try std.testing.expectEqual(@as(u16, 100 - config.MINW), p.drag.border_v.cur_x);
+ try std.testing.expectEqual(right_seam, p.drag.border_v.cur_y);
+
+ // and the mirror: below the bottom at mid-width. y parks, x tracks again
+ p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle, .row = 999 } });
+ try std.testing.expectEqual(handle, p.drag.border_v.cur_x);
+ const a = p.rects[p.col_panes[1][0]];
+ const b = p.rects[p.col_panes[1][1]];
+ try std.testing.expectEqual(clampBorderRow(a.y, a.h, b.h, 999, p.settings.tag_bottom), p.drag.border_v.cur_y);
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = 999 } });
+}
+
+test "pointer coordinates follow presented panel geometry" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+
+ const moving: layout.Track = .{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .x = 20, .y = 5, .w = 40, .h = 10 },
+ .to = .{ .x = 0, .y = 1, .w = 80, .h = 18 },
+ };
+ p.acknowledgePanelPresentation(&.{moving});
+ // One shared physical-to-logical map feeds all pointer gestures. The
+ // selected sample is 26.25% across and 35% down the presented rectangle.
+ try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 21, .row = 7 }, p.presentation.pointer(p.screen_w, p.screen_h, 30, 8).?);
+ // Canonical cells covered only by the not-yet-arrived target are inert.
+ try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 2, 2) == null);
+ // Unrelated screen space remains in the ordinary grid coordinate system.
+ try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 100, .row = 10 }, p.presentation.pointer(p.screen_w, p.screen_h, 100, 10).?);
+
+ p.acknowledgePanelPresentation(&.{layout.Track{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .opening,
+ .effect = .zoom,
+ .from = .{ .x = 40, .y = 10, .w = 0, .h = 0 },
+ .to = .{ .x = 0, .y = 1, .w = 80, .h = 18 },
+ }});
+ try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 40, 10) == null);
+
+ // Tracks paint in pane-slot order inside a phase. The later slot is the
+ // top quad and therefore owns an overlap, even though both are moving.
+ const overlap = [_]layout.Track{ .{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .x = 20, .y = 2, .w = 20, .h = 4 },
+ .to = .{ .x = 0, .y = 2, .w = 20, .h = 4 },
+ }, .{
+ .serial = pane.serial + 1,
+ .pane = 1,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .x = 20, .y = 2, .w = 20, .h = 4 },
+ .to = .{ .x = 60, .y = 2, .w = 20, .h = 4 },
+ } };
+ p.presentation.shown_tracks = @splat(null);
+ p.presentation.shown_tracks[0] = overlap[0];
+ p.presentation.shown_tracks[1] = overlap[1];
+ try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 65, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 25, 3).?);
+}
+
+test "queued pointer input is inert until a changed layout is presented" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true });
+ defer p.deinit();
+ // Establish that a real host has presented the boot layout.
+ p.acknowledgePanelPresentation(&.{});
+ p.settings.panel_transition = .slide;
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(layout.splitColumn(p, 0, 1, false));
+ p.sync();
+ try std.testing.expect(p.presentation.pending);
+ try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null);
+
+ var tracks: [MAX_PANES]layout.Track = undefined;
+ var len: usize = 0;
+ for (p.presentation.tracks) |maybe| if (maybe) |track| {
+ tracks[len] = track;
+ len += 1;
+ };
+ p.acknowledgePanelPresentation(tracks[0..len]);
+ try std.testing.expect(!p.presentation.pending);
+ // Whether this particular opening sample exposes the chosen cell is the
+ // transition's concern; it is no longer rejected merely as speculative.
+ try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 99, 4) != null);
+}
+
+test "pointer rejects panel content cells which have not materialized" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12, .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const target: layout.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 };
+ p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h);
+ @memset(p.presentation.diffs, .unchanged);
+ for (2..6) |row| {
+ for (4..36) |col| {
+ // Distances on both sides of frame five prove that finished ASCII
+ // bytes are clickable while bytes still walking are not.
+ p.presentation.diffs[row * p.screen_w + col] = .{ .ascii = .{
+ .from = ' ',
+ .to = @intCast(' ' + (col - 4) % 12 + 1),
+ } };
+ }
+ }
+ p.presentation.diff_state = .ready;
+
+ for ([_]layout.Transition{ .dissolve, .ascii }) |effect| {
+ const frame: u16 = if (effect == .dissolve) 3 else 5;
+ const track: layout.Track = .{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .opening,
+ .effect = effect,
+ .frame = frame,
+ .from = target,
+ .to = target,
+ };
+ p.acknowledgePanelPresentation(&.{track});
+
+ var saw_visible = false;
+ var saw_hidden = false;
+ for (0..4) |relative_row| for (0..32) |relative_col| {
+ const rcol: u16 = @intCast(relative_col);
+ const rrow: u16 = @intCast(relative_row);
+ const col: u16 = @intCast(4 + relative_col);
+ const row: u16 = @intCast(2 + relative_row);
+ const visible = switch (effect) {
+ .dissolve => layout.dissolveRevealed(
+ pane.serial,
+ rcol,
+ rrow,
+ track.amount(),
+ ),
+ .ascii => switch (p.presentation.cellDiff(p.screen_w, p.screen_h, col, row)) {
+ .ascii => |diff| diff.complete(track.frame),
+ .unchanged, .visual => true,
+ },
+ else => unreachable,
+ };
+ const mapped = p.presentation.pointer(p.screen_w, p.screen_h, col, row);
+ try std.testing.expectEqual(visible, mapped != null);
+ if (mapped) |point|
+ try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = col, .row = row }, point);
+ saw_visible = saw_visible or visible;
+ saw_hidden = saw_hidden or !visible;
+ };
+ try std.testing.expect(saw_visible);
+ try std.testing.expect(saw_hidden);
+ }
+}
+
+test "unchanged content cells remain clickable through data effects" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 40, .rows = 10, .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h);
+ @memset(p.presentation.diffs, .unchanged);
+ p.presentation.diff_state = .ready;
+ const box: layout.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 };
+
+ for ([_]layout.Transition{ .dissolve, .ascii }) |effect| {
+ p.acknowledgePanelPresentation(&.{.{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .opening,
+ .effect = effect,
+ .from = box,
+ .to = box,
+ }});
+ try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 8, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 8, 3).?);
+ }
+}
+
+test "held drag follows acknowledged panels and balances an invisible release" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const target = layout.panelBox(p.rects[0]);
+ var track: layout.Track = .{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .x = 50, .y = 5, .w = 20, .h = 8 },
+ .to = target,
+ };
+ const raw_col: u16 = 55;
+ const raw_row: u16 = 7;
+ p.acknowledgePanelPresentation(&.{track});
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .press,
+ .col = raw_col,
+ .row = raw_row,
+ } });
+ try std.testing.expect(p.drag == .select);
+ const first_col = pane.sel[sel_slot].c1;
+ const first_row = pane.sel[sel_slot].r1;
+
+ // Advancing producer state alone cannot move input. Only the sample the
+ // host says it actually drew may remap the stationary held endpoint.
+ track.frame = track.effect.frames() - 1;
+ p.presentation.tracks[0] = track;
+ try std.testing.expectEqual(first_col, pane.sel[sel_slot].c1);
+ try std.testing.expectEqual(first_row, pane.sel[sel_slot].r1);
+ p.acknowledgePanelPresentation(&.{track});
+ try std.testing.expect(first_col != pane.sel[sel_slot].c1 or
+ first_row != pane.sel[sel_slot].r1);
+
+ const last_col = pane.sel[sel_slot].c1;
+ const last_row = pane.sel[sel_slot].r1;
+ track.phase = .opening;
+ track.effect = .zoom;
+ track.frame = 0;
+ track.from = .{
+ .x = target.x + target.w * 0.5,
+ .y = target.y + target.h * 0.5,
+ };
+ p.acknowledgePanelPresentation(&.{track});
+ try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, raw_col, raw_row) == null);
+ try std.testing.expectEqual(last_col, pane.sel[sel_slot].c1);
+ try std.testing.expectEqual(last_row, pane.sel[sel_slot].r1);
+
+ // An unrelated release cannot consume the left drag. Its own release is
+ // nevertheless balanced at the retained endpoint while the cell is dark.
+ p.update(.{ .mouse = .{
+ .button = config.exec_button,
+ .kind = .release,
+ .col = raw_col,
+ .row = raw_row,
+ } });
+ try std.testing.expect(p.drag == .select);
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .release,
+ .col = raw_col,
+ .row = raw_row,
+ } });
+ try std.testing.expect(p.drag == .none);
+ try std.testing.expectEqual(.done, pane.sel[sel_slot].state);
+}
+
+test "mouse thumb buttons navigate once and clear selection gestures" {
+ 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);
+ p.njumps = 3;
+ p.jcur = 2;
+ for (0..3) |i| p.jumps[i] = .{ .pane = 0, .serial = pane.serial, .line = @intCast(i + 1), .col = 1 };
+ p.drag = .{ .select = .{ .id = 0, .button = config.select_button } };
+ p.update(.{ .mouse = .{ .button = .back, .kind = .press, .col = 0, .row = 0 } });
+ try std.testing.expectEqual(@as(usize, 1), p.jcur);
+ for ([_]Mouse.Kind{ .drag, .release, .motion }) |kind|
+ p.update(.{ .mouse = .{ .button = .back, .kind = kind, .col = 0, .row = 0 } });
+ try std.testing.expectEqual(@as(usize, 1), p.jcur);
+ p.update(.{ .mouse = .{ .button = .forward, .kind = .press, .col = 0, .row = 0 } });
+ p.update(.{ .mouse = .{ .button = .forward, .kind = .release, .col = 0, .row = 0 } });
+ try std.testing.expectEqual(@as(usize, 2), p.jcur);
+ try std.testing.expect(!pane.vsel.active);
+ try std.testing.expectEqual(.none, p.drag);
+}
diff --git a/src/pardes.zig b/src/pardes.zig
index e001aa41..7a451b3a 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -11,6 +11,7 @@ pub const syntax = @import("syntax.zig");
pub const locations_config = @import("locations_config.zig");
const tracy = @import("tracy.zig");
pub const panes = @import("panes.zig");
+pub const mouse = @import("mouse.zig");
pub const normal = @import("normal.zig");
pub const edit = @import("edit.zig");
pub const body_layer = @import("body_layer.zig");
@@ -434,6 +435,7 @@ pub const Loc = struct {
};
test {
+ _ = @import("mouse.zig");
_ = @import("normal.zig");
_ = @import("edit.zig");
_ = @import("Messages.zig");
@@ -479,7 +481,7 @@ const pre_close_last_file_pane_tail = " Save Tty Del Collapse";
const pre_close_last_pdf_pane_tail = " Tty Del PdfSections PdfTint Collapse";
const pre_close_last_terminal_pane_tail = " Tty Save Del Mode Filter Collapse";
-const Builtin = builtins.registry.Builtin();
+pub const Builtin = builtins.registry.Builtin();
pub const test_api = if (@import("builtin").is_test) struct {
pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 {
@@ -2566,7 +2568,7 @@ fn wordBoundsAtCol(str: []const u8, col: usize) ?WordBounds {
}
/// the whitespace-delimited word covering `col` in `str` (topbar dispatch)
-fn wordAtCol(str: []const u8, col: usize) []const u8 {
+pub fn wordAtCol(str: []const u8, col: usize) []const u8 {
const bounds = wordBoundsAtCol(str, col) orelse return "";
return str[bounds.lo..bounds.hi];
}
@@ -2857,48 +2859,6 @@ test "cwd changes cancel only the affected pane's Look hover" {
try std.testing.expect(p.look_hover_preview == null);
}
-test "plain left click clears explicit modal selection" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .cols = 40, .rows = 10 });
- defer p.deinit();
- const pane = try p.setTestFile("abcdef\n");
- pane.cur_row = 0;
- pane.cur_col = 5;
- pane.vsel = .{ .active = true, .row = 0, .col = 1, .explicit = true };
- pane.msel = .{ .active = true, .r0 = 0, .r1 = 0 };
-
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
- _ = try p.render(frame.allocator());
- const rect = p.rects[0];
- const click_x = rect.x + config.GUTTER + config.PREFIX_W + 3;
- const body_y = rect.y + BOX_H;
- p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = click_x, .row = body_y } });
- p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = click_x, .row = body_y } });
-
- try std.testing.expectEqual(@as(i32, 3), pane.cur_col);
- try std.testing.expect(!pane.vsel.active);
- try std.testing.expect(!pane.msel.active);
- try std.testing.expectEqual(.none, pane.sel[@intFromEnum(config.select_button)].state);
-}
-
-test "selection drag and queued release do not enter panel pending state" {
- const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 10, .tty_only = true });
- defer p.deinit();
- const pane = try p.setTestFile("alpha beta\n");
- p.acknowledgePanelPresentation(&.{});
- const rect = p.rects[0];
- const x = rect.x + config.GUTTER + config.PREFIX_W + 1;
- const y = rect.y + BOX_H;
-
- p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = x, .row = y } });
- p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = x + 2, .row = y } });
- try std.testing.expect(!p.presentation.pending);
- p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = x + 2, .row = y } });
- try std.testing.expect(p.drag == .none);
- try std.testing.expectEqual(.done, pane.sel[sel_slot].state);
-}
-
test "editable workspace and column tags replace selections and persist" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20, .tty_only = true });
defer p.deinit();
@@ -3294,220 +3254,6 @@ const PdfWordPreview = if (pdf_enabled) struct {
}
} else void;
-const Drag = union(enum) {
- none,
- border_v: struct { left_col: usize, cur_x: u16, corner: ?struct { col: usize, idx: usize } = null, cur_y: u16 = 0 },
- border_h: struct { col: usize, top_idx: usize, cur_y: u16 },
- move: struct { id: usize, cur_x: u16, cur_y: u16 },
- /// `grab`: how far into the grip it was pressed, so the column's left
- /// edge lands at `cur_x - grab`. A release still on the grip moves nothing.
- column_move: struct { serial: u32, cur_x: u16, grab: u16 = 0 },
- tag: struct { id: usize },
- /// chorded: a 1-2/1-3 cut/paste chord fired during this left drag —
- /// the drag's own release is then inert
- select: struct {
- id: usize,
- button: Mouse.Button,
- chorded: bool = false,
- ctrl: bool = false,
- /// This gesture began over a usable native PDF raster. The payload is
- /// zero-bit when PDF support is absent.
- pdf: panes.Pdf.PointerDrag = .{},
- },
-
- fn button(drag: Drag) ?Mouse.Button {
- return switch (drag) {
- .none => null,
- .border_v, .border_h, .move, .column_move, .tag => config.select_button,
- .select => |selection| selection.button,
- };
- }
-};
-
-fn clampBorderCol(lx: u16, lw: u16, rw: u16, mcol: u16) u16 {
- const lo = lx + config.MINW;
- const hi = lx +| lw +| rw -| config.MINW;
- return if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol;
-}
-
-fn clampBorderRow(ay: u16, ah: u16, bh: u16, mrow: u16, tag_bottom: bool) u16 {
- const d: u16 = if (tag_bottom) 1 else 0;
- const lo = ay + BOX_H - 1 + d;
- const hi = (ay +| ah +| bh -| (BOX_H + 1)) +| d;
- return if (lo <= hi) std.math.clamp(mrow, lo, hi) else mrow;
-}
-
-test "a corner drag's two axes clamp independently" {
- // a 100-wide window split 50/50, and a 30-row column split 15/14 under the
- // topbar: the shape windowops.snap drives
- const lx: u16 = 0;
- const lw: u16 = 50;
- const rw: u16 = 50;
- const ay: u16 = TOPBAR_H;
- const ah: u16 = 15;
- const bh: u16 = 14;
- const row_lo: u16 = ay + BOX_H - 1;
- const row_hi: u16 = ay + ah + bh - (BOX_H + 1);
-
- // each axis alone, at both walls
- try std.testing.expectEqual(@as(u16, config.MINW), clampBorderCol(lx, lw, rw, 0));
- try std.testing.expectEqual(@as(u16, 100 - config.MINW), clampBorderCol(lx, lw, rw, 999));
- try std.testing.expectEqual(row_lo, clampBorderRow(ay, ah, bh, 0, false));
- try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999, false));
-
- // THE corner property: drag off the right edge while staying mid-height —
- // x sits at its wall, y still tracks the mouse row exactly
- try std.testing.expectEqual(@as(u16, 100 - config.MINW), clampBorderCol(lx, lw, rw, 999));
- try std.testing.expectEqual(@as(u16, 12), clampBorderRow(ay, ah, bh, 12, false));
- // and the mirror: pinned at the bottom, x still free
- try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999, false));
- try std.testing.expectEqual(@as(u16, 40), clampBorderCol(lx, lw, rw, 40));
- // both pinned at once is just both walls, not a stuck gesture
- try std.testing.expectEqual(@as(u16, config.MINW), clampBorderCol(lx, lw, rw, 0));
- try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999, false));
-
- try std.testing.expectEqual(row_lo + 1, clampBorderRow(ay, ah, bh, 0, true));
- try std.testing.expectEqual(row_hi + 1, clampBorderRow(ay, ah, bh, 999, true));
- try std.testing.expectEqual(@as(u16, 12), clampBorderRow(ay, ah, bh, 12, true));
-
- // degenerate pairs pass through untouched, each on its own terms: a pane
- // pair with no room stays draggable even when the column pair has room
- try std.testing.expectEqual(@as(u16, 7), clampBorderCol(0, 5, 5, 7));
- try std.testing.expectEqual(@as(u16, 40), clampBorderCol(lx, lw, rw, 40));
- try std.testing.expectEqual(@as(u16, 9), clampBorderRow(TOPBAR_H, 1, 0, 9, false));
-}
-
-fn seamRowOf(p: *const Pardes, c: usize, k: usize) u16 {
- const r = p.rects[p.col_panes[c][k]];
- return if (p.settings.tag_bottom) r.y +| r.h else r.y + r.h -| 1;
-}
-
-fn cornerFixture(gpa: std.mem.Allocator) !*Pardes {
- const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30, .shells = 3 });
- p.update(.{ .resize = .{ .cols = 100, .rows = 30 } });
- p.active = p.col_panes[1][0];
- p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell below it, same column
- p.sync();
- return p;
-}
-
-fn nudgeRightSeam(p: *Pardes, delta: i32) u16 {
- const from = seamRowOf(p, 1, 0);
- const inside = p.col_x[1] + p.col_w[1] / 2;
- const to: u16 = @intCast(@as(i32, from) + delta);
- p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = inside, .row = from } });
- p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = inside, .row = to } });
- p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = inside, .row = to } });
- p.sync();
- return seamRowOf(p, 1, 0);
-}
-
-test "a v-handle press at the RIGHT column's seam drags both boundaries" {
- if (platform == .web) return;
- const p = try cornerFixture(std.testing.allocator);
- defer p.deinit();
- try std.testing.expectEqual(@as(usize, 2), p.col_n[1]);
-
- const handle = p.col_x[0] + p.col_w[0] - 1;
- const right_seam = nudgeRightSeam(p, 3);
- try std.testing.expect(right_seam != seamRowOf(p, 0, 0));
-
- const w0 = p.col_weight[0];
- const v_left = p.panes[p.col_panes[0][0]].?.vweight;
- const v_right = p.panes[p.col_panes[1][0]].?.vweight;
-
- p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = right_seam } });
- try std.testing.expect(p.drag == .border_v);
- const corner = p.drag.border_v.corner orelse return error.NoCorner;
- try std.testing.expectEqual(@as(usize, 1), corner.col);
- try std.testing.expectEqual(@as(usize, 0), corner.idx);
-
- p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle + 8, .row = right_seam - 4 } });
- p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle + 8, .row = right_seam - 4 } });
- p.sync();
-
- // both halves committed: the column pair widened, and the RIGHT column's
- // pane pair reweighted — while the left column's panes stayed put
- try std.testing.expect(p.col_weight[0] > w0);
- try std.testing.expect(p.panes[p.col_panes[1][0]].?.vweight != v_right);
- try std.testing.expectEqual(v_left, p.panes[p.col_panes[0][0]].?.vweight);
-}
-
-test "a tie row still moves the LEFT column's pane pair only" {
- if (platform == .web) return;
- const p = try cornerFixture(std.testing.allocator);
- defer p.deinit();
-
- // put column 1's seam exactly on column 0's, the row the eye reads as one
- // line across the whole window
- const left_seam = seamRowOf(p, 0, 0);
- _ = nudgeRightSeam(p, @as(i32, left_seam) - @as(i32, seamRowOf(p, 1, 0)));
- try std.testing.expectEqual(left_seam, seamRowOf(p, 1, 0));
-
- const handle = p.col_x[0] + p.col_w[0] - 1;
- const v_left = p.panes[p.col_panes[0][0]].?.vweight;
- const v_right = p.panes[p.col_panes[1][0]].?.vweight;
-
- p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = left_seam } });
- const corner = p.drag.border_v.corner orelse return error.NoCorner;
- try std.testing.expectEqual(@as(usize, 0), corner.col);
-
- p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle, .row = left_seam - 4 } });
- p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = left_seam - 4 } });
- p.sync();
- try std.testing.expect(p.panes[p.col_panes[0][0]].?.vweight != v_left);
- try std.testing.expectEqual(v_right, p.panes[p.col_panes[1][0]].?.vweight);
-}
-
-test "a v-handle press at nobody's seam is still a plain edge drag" {
- if (platform == .web) return;
- const p = try cornerFixture(std.testing.allocator);
- defer p.deinit();
- const right_seam = nudgeRightSeam(p, 3);
- const left_seam = seamRowOf(p, 0, 0);
-
- const handle = p.col_x[0] + p.col_w[0] - 1;
- var row: u16 = TOPBAR_H + 1;
- while (row == left_seam or row == right_seam) row += 1;
-
- const w0 = p.col_weight[0];
- const v_left = p.panes[p.col_panes[0][0]].?.vweight;
- const v_right = p.panes[p.col_panes[1][0]].?.vweight;
- p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = row } });
- try std.testing.expect(p.drag == .border_v);
- try std.testing.expect(p.drag.border_v.corner == null);
- p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle + 8, .row = row + 5 } });
- p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle + 8, .row = row + 5 } });
- p.sync();
- // exactly ONE boundary moved
- try std.testing.expect(p.col_weight[0] > w0);
- try std.testing.expectEqual(v_left, p.panes[p.col_panes[0][0]].?.vweight);
- try std.testing.expectEqual(v_right, p.panes[p.col_panes[1][0]].?.vweight);
-}
-
-test "a RIGHT-column corner's two axes clamp independently" {
- if (platform == .web) return;
- const p = try cornerFixture(std.testing.allocator);
- defer p.deinit();
- const right_seam = nudgeRightSeam(p, 3);
- const handle = p.col_x[0] + p.col_w[0] - 1;
- p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = right_seam } });
- try std.testing.expectEqual(@as(usize, 1), (p.drag.border_v.corner orelse return error.NoCorner).col);
-
- // off the right edge at mid-height: x parks at its MINW wall, y is the mouse
- p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = 999, .row = right_seam } });
- try std.testing.expectEqual(@as(u16, 100 - config.MINW), p.drag.border_v.cur_x);
- try std.testing.expectEqual(right_seam, p.drag.border_v.cur_y);
-
- // and the mirror: below the bottom at mid-width. y parks, x tracks again
- p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle, .row = 999 } });
- try std.testing.expectEqual(handle, p.drag.border_v.cur_x);
- const a = p.rects[p.col_panes[1][0]];
- const b = p.rects[p.col_panes[1][1]];
- try std.testing.expectEqual(clampBorderRow(a.y, a.h, b.h, 999, p.settings.tag_bottom), p.drag.border_v.cur_y);
- p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = 999 } });
-}
-
test "a new column takes width only from the column that created it" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .cols = 124, .rows = 24 });
@@ -3820,87 +3566,6 @@ test "canonical fallback and transition toggle retire unpresented tracks" {
for (p.presentation.tracks) |track| try std.testing.expect(track == null);
}
-test "pointer coordinates follow presented panel geometry" {
- const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
- defer p.deinit();
- const pane = p.panes[0].?;
-
- const moving: layout.Track = .{
- .serial = pane.serial,
- .pane = 0,
- .phase = .moving,
- .effect = .slide,
- .from = .{ .x = 20, .y = 5, .w = 40, .h = 10 },
- .to = .{ .x = 0, .y = 1, .w = 80, .h = 18 },
- };
- p.acknowledgePanelPresentation(&.{moving});
- // One shared physical-to-logical map feeds all pointer gestures. The
- // selected sample is 26.25% across and 35% down the presented rectangle.
- try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 21, .row = 7 }, p.presentation.pointer(p.screen_w, p.screen_h, 30, 8).?);
- // Canonical cells covered only by the not-yet-arrived target are inert.
- try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 2, 2) == null);
- // Unrelated screen space remains in the ordinary grid coordinate system.
- try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 100, .row = 10 }, p.presentation.pointer(p.screen_w, p.screen_h, 100, 10).?);
-
- p.acknowledgePanelPresentation(&.{layout.Track{
- .serial = pane.serial,
- .pane = 0,
- .phase = .opening,
- .effect = .zoom,
- .from = .{ .x = 40, .y = 10, .w = 0, .h = 0 },
- .to = .{ .x = 0, .y = 1, .w = 80, .h = 18 },
- }});
- try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 40, 10) == null);
-
- // Tracks paint in pane-slot order inside a phase. The later slot is the
- // top quad and therefore owns an overlap, even though both are moving.
- const overlap = [_]layout.Track{ .{
- .serial = pane.serial,
- .pane = 0,
- .phase = .moving,
- .effect = .slide,
- .from = .{ .x = 20, .y = 2, .w = 20, .h = 4 },
- .to = .{ .x = 0, .y = 2, .w = 20, .h = 4 },
- }, .{
- .serial = pane.serial + 1,
- .pane = 1,
- .phase = .moving,
- .effect = .slide,
- .from = .{ .x = 20, .y = 2, .w = 20, .h = 4 },
- .to = .{ .x = 60, .y = 2, .w = 20, .h = 4 },
- } };
- p.presentation.shown_tracks = @splat(null);
- p.presentation.shown_tracks[0] = overlap[0];
- p.presentation.shown_tracks[1] = overlap[1];
- try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 65, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 25, 3).?);
-}
-
-test "queued pointer input is inert until a changed layout is presented" {
- const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true });
- defer p.deinit();
- // Establish that a real host has presented the boot layout.
- p.acknowledgePanelPresentation(&.{});
- p.settings.panel_transition = .slide;
-
- _ = try p.newShell(1, "");
- try std.testing.expect(layout.splitColumn(p, 0, 1, false));
- p.sync();
- try std.testing.expect(p.presentation.pending);
- try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null);
-
- var tracks: [MAX_PANES]layout.Track = undefined;
- var len: usize = 0;
- for (p.presentation.tracks) |maybe| if (maybe) |track| {
- tracks[len] = track;
- len += 1;
- };
- p.acknowledgePanelPresentation(tracks[0..len]);
- try std.testing.expect(!p.presentation.pending);
- // Whether this particular opening sample exposes the chosen cell is the
- // transition's concern; it is no longer rejected merely as speculative.
- try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 99, 4) != null);
-}
-
test "back-to-back layout commits retarget from the last presented boxes" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
defer p.deinit();
@@ -4047,92 +3712,6 @@ test "core composes character motion out of the new grid, not a fade" {
try std.testing.expect(try p.composeAsciiTransitions(arena.allocator(), &surface) == &surface);
}
-test "pointer rejects panel content cells which have not materialized" {
- const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12, .tty_only = true });
- defer p.deinit();
- const pane = p.panes[0].?;
- const target: layout.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 };
- p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h);
- @memset(p.presentation.diffs, .unchanged);
- for (2..6) |row| {
- for (4..36) |col| {
- // Distances on both sides of frame five prove that finished ASCII
- // bytes are clickable while bytes still walking are not.
- p.presentation.diffs[row * p.screen_w + col] = .{ .ascii = .{
- .from = ' ',
- .to = @intCast(' ' + (col - 4) % 12 + 1),
- } };
- }
- }
- p.presentation.diff_state = .ready;
-
- for ([_]layout.Transition{ .dissolve, .ascii }) |effect| {
- const frame: u16 = if (effect == .dissolve) 3 else 5;
- const track: layout.Track = .{
- .serial = pane.serial,
- .pane = 0,
- .phase = .opening,
- .effect = effect,
- .frame = frame,
- .from = target,
- .to = target,
- };
- p.acknowledgePanelPresentation(&.{track});
-
- var saw_visible = false;
- var saw_hidden = false;
- for (0..4) |relative_row| for (0..32) |relative_col| {
- const rcol: u16 = @intCast(relative_col);
- const rrow: u16 = @intCast(relative_row);
- const col: u16 = @intCast(4 + relative_col);
- const row: u16 = @intCast(2 + relative_row);
- const visible = switch (effect) {
- .dissolve => layout.dissolveRevealed(
- pane.serial,
- rcol,
- rrow,
- track.amount(),
- ),
- .ascii => switch (p.presentation.cellDiff(p.screen_w, p.screen_h, col, row)) {
- .ascii => |diff| diff.complete(track.frame),
- .unchanged, .visual => true,
- },
- else => unreachable,
- };
- const mapped = p.presentation.pointer(p.screen_w, p.screen_h, col, row);
- try std.testing.expectEqual(visible, mapped != null);
- if (mapped) |point|
- try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = col, .row = row }, point);
- saw_visible = saw_visible or visible;
- saw_hidden = saw_hidden or !visible;
- };
- try std.testing.expect(saw_visible);
- try std.testing.expect(saw_hidden);
- }
-}
-
-test "unchanged content cells remain clickable through data effects" {
- const p = try Pardes.init(std.testing.allocator, .{ .cols = 40, .rows = 10, .tty_only = true });
- defer p.deinit();
- const pane = p.panes[0].?;
- p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h);
- @memset(p.presentation.diffs, .unchanged);
- p.presentation.diff_state = .ready;
- const box: layout.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 };
-
- for ([_]layout.Transition{ .dissolve, .ascii }) |effect| {
- p.acknowledgePanelPresentation(&.{.{
- .serial = pane.serial,
- .pane = 0,
- .phase = .opening,
- .effect = effect,
- .from = box,
- .to = box,
- }});
- try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 8, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 8, 3).?);
- }
-}
-
test "stationary Look hover follows only acknowledged panel samples" {
_ = config.look_preview_delay_frames orelse return;
const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
@@ -4195,75 +3774,6 @@ test "stationary Look hover follows only acknowledged panel samples" {
try std.testing.expect(p.look_hover_wait != null);
}
-test "held drag follows acknowledged panels and balances an invisible release" {
- const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
- defer p.deinit();
- const pane = p.panes[0].?;
- const target = layout.panelBox(p.rects[0]);
- var track: layout.Track = .{
- .serial = pane.serial,
- .pane = 0,
- .phase = .moving,
- .effect = .slide,
- .from = .{ .x = 50, .y = 5, .w = 20, .h = 8 },
- .to = target,
- };
- const raw_col: u16 = 55;
- const raw_row: u16 = 7;
- p.acknowledgePanelPresentation(&.{track});
- p.update(.{ .mouse = .{
- .button = config.select_button,
- .kind = .press,
- .col = raw_col,
- .row = raw_row,
- } });
- try std.testing.expect(p.drag == .select);
- const first_col = pane.sel[sel_slot].c1;
- const first_row = pane.sel[sel_slot].r1;
-
- // Advancing producer state alone cannot move input. Only the sample the
- // host says it actually drew may remap the stationary held endpoint.
- track.frame = track.effect.frames() - 1;
- p.presentation.tracks[0] = track;
- try std.testing.expectEqual(first_col, pane.sel[sel_slot].c1);
- try std.testing.expectEqual(first_row, pane.sel[sel_slot].r1);
- p.acknowledgePanelPresentation(&.{track});
- try std.testing.expect(first_col != pane.sel[sel_slot].c1 or
- first_row != pane.sel[sel_slot].r1);
-
- const last_col = pane.sel[sel_slot].c1;
- const last_row = pane.sel[sel_slot].r1;
- track.phase = .opening;
- track.effect = .zoom;
- track.frame = 0;
- track.from = .{
- .x = target.x + target.w * 0.5,
- .y = target.y + target.h * 0.5,
- };
- p.acknowledgePanelPresentation(&.{track});
- try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, raw_col, raw_row) == null);
- try std.testing.expectEqual(last_col, pane.sel[sel_slot].c1);
- try std.testing.expectEqual(last_row, pane.sel[sel_slot].r1);
-
- // An unrelated release cannot consume the left drag. Its own release is
- // nevertheless balanced at the retained endpoint while the cell is dark.
- p.update(.{ .mouse = .{
- .button = config.exec_button,
- .kind = .release,
- .col = raw_col,
- .row = raw_row,
- } });
- try std.testing.expect(p.drag == .select);
- p.update(.{ .mouse = .{
- .button = config.select_button,
- .kind = .release,
- .col = raw_col,
- .row = raw_row,
- } });
- try std.testing.expect(p.drag == .none);
- try std.testing.expectEqual(.done, pane.sel[sel_slot].state);
-}
-
test "repeated non-dyadic column splits preserve every unrelated boundary" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .cols = 979, .rows = 20 });
@@ -4445,7 +3955,7 @@ pub const Pardes = struct {
native_image_patches: bool = false,
quit: bool = false,
haptic: HapticSlot = if (platform == .macos) .none else {},
- drag: Drag = .none,
+ drag: mouse.Drag = .none,
hover_col: u16 = 0,
hover_row: u16 = 0,
pointer_raw_col: u16 = 0,
@@ -4769,7 +4279,7 @@ pub const Pardes = struct {
return p.col_tags[c].slice("New Tty Find Grep Joincol Delcol");
}
- fn headerText(p: *Pardes) []const u8 {
+ pub fn headerText(p: *Pardes) []const u8 {
if (p.header_column) |c| return if (c < p.ncol) p.columnTag(c) else "";
var buf: [1200]u8 = undefined;
return p.scratch.allocator().dupe(u8, p.topbar(&buf)) catch "";
@@ -5490,7 +5000,7 @@ pub const Pardes = struct {
.border_v, .border_h, .move, .column_move => p.presentation.snap_once = true,
else => {},
};
- p.handleMouse(m);
+ mouse.handleMouse(p, m);
},
.pdf_scroll => |scroll| {
if (comptime pdf_enabled) {
@@ -5656,7 +5166,7 @@ pub const Pardes = struct {
}
}
- fn exitTagEdit(pane: *Pane) void {
+ pub fn exitTagEdit(pane: *Pane) void {
if (pane.tag_edit and pane.prompt == .none) pane.tag_saved_col = pane.tag_col;
if (pane.isTerminal()) {
if (pane.tag_edit) pane.mode = pane.tag_mode;
@@ -5923,7 +5433,7 @@ pub const Pardes = struct {
return modal.moveWord(text, .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) }, 1, t);
}
- fn rememberHeader(p: *Pardes) void {
+ pub fn rememberHeader(p: *Pardes) void {
const col = p.topbar_col orelse return;
const line = if (p.header_column) |c| &p.col_tags[c] else &p.global_tag;
line.saved_col = col;
@@ -6140,7 +5650,7 @@ pub const Pardes = struct {
return true;
}
- const PointerOperand = struct {
+ pub const PointerOperand = struct {
/// Absolute body position corresponding to the pointed screen cell.
row: i32,
col: i32,
@@ -6156,7 +5666,7 @@ pub const Pardes = struct {
expanded: ?Pane.Sel = null,
};
- fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand {
+ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand {
const visible = clicked.r0 - @as(i32, BOX_H);
const wrapped = pane.wrapAt(visible);
const raw = pane.isTerminal() and pane.mode == .tty and clicked.r0 >= BOX_H;
@@ -6249,7 +5759,7 @@ pub const Pardes = struct {
return result;
}
- fn heldSelection(p: *Pardes, first: usize) ?[]const u8 {
+ pub fn heldSelection(p: *Pardes, first: usize) ?[]const u8 {
var k: usize = 0;
while (k < p.panes.len + 2) : (k += 1) {
const i = switch (k) {
@@ -6272,7 +5782,7 @@ pub const Pardes = struct {
return null;
}
- fn withArg(p: *Pardes, txt: []const u8, arg: ?[]const u8) []const u8 {
+ pub fn withArg(p: *Pardes, txt: []const u8, arg: ?[]const u8) []const u8 {
const a = std.mem.trim(u8, arg orelse return txt, " \t\r\n");
if (a.len == 0) return txt;
const t = std.mem.trim(u8, txt, " \t\r\n");
@@ -7188,85 +6698,7 @@ pub const Pardes = struct {
// ---- dumb undo/redo: whole-state snapshots, one per edit op ----
- pub const ChromeTarget = struct { col: u16, row: u16 };
-
- pub fn chromeTarget(p: *const Pardes, col: u16, row: u16) ?ChromeTarget {
- if (row < p.topBarHeight() + p.columnBarHeight()) return .{ .col = col, .row = row };
- for (p.panes, 0..) |slot, id| {
- if (slot == null) continue;
- const rect = p.rects[id];
- const tag = if (p.settings.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
- if (row == tag and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER)
- return .{ .col = col, .row = tag };
- }
- for (0..p.ncol -| 1) |column| {
- const handle = p.col_x[column] + p.col_w[column] -| 1;
- if (col == handle) return .{ .col = handle, .row = row };
- }
- for (0..p.ncol) |column| {
- if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue;
- for (0..p.col_n[column] -| 1) |index| {
- const rect = p.rects[p.col_panes[column][index]];
- const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
- if (row == handle) return .{ .col = col, .row = handle };
- }
- }
- for (p.panes, 0..) |slot, id| {
- if (slot == null) continue;
- const rect = p.rects[id];
- const tag = if (p.settings.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
- if (row == tag and col >= rect.x and col < rect.x + rect.w)
- return .{ .col = col, .row = tag };
- }
- for (0..p.ncol -| 1) |column| {
- const handle = p.col_x[column] + p.col_w[column] -| 1;
- if (@max(col, handle) - @min(col, handle) == 1) return .{ .col = handle, .row = row };
- }
- for (0..p.ncol) |column| {
- if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue;
- for (0..p.col_n[column] -| 1) |index| {
- const rect = p.rects[p.col_panes[column][index]];
- const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
- if (@max(row, handle) - @min(row, handle) == 1) return .{ .col = col, .row = handle };
- }
- }
- return null;
- }
-
- pub fn paneAt(p: *const Pardes, col: u16, row: u16) ?usize {
- for (p.panes, 0..) |slot, id| {
- if (slot == null) continue;
- const r = p.rects[id];
- if (col >= r.x and col < r.x + r.w and row >= r.y and row < r.y + r.h) return id;
- }
- return null;
- }
-
- pub fn bodyHitForPane(p: *const Pardes, id: usize, supplied: Mouse.BodyHit, clamp: bool) ?Mouse.BodyHit {
- const pane = p.panes[id] orelse return null;
- if (pane.file == null or (!clamp and supplied.serial != pane.serial)) return null;
- if (!std.math.isFinite(supplied.pixel_x) or !std.math.isFinite(supplied.pixel_y) or
- supplied.metrics.body_w == 0 or supplied.metrics.body_h == 0 or
- supplied.metrics.tagline_w == 0 or supplied.metrics.tagline_h == 0) return null;
- var mapped_hit = supplied;
- if (clamp) {
- mapped_hit.serial = pane.serial;
- const layer = for (&p.surface.body_layers) |*layer| {
- if (layer.rows > 0 and layer.serial == pane.serial) break layer;
- } else return null;
- const bw: f32 = @floatFromInt(mapped_hit.metrics.body_w);
- const bh: f32 = @floatFromInt(mapped_hit.metrics.body_h);
- const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * bw;
- const y0 = @as(f32, @floatFromInt(layer.viewport.y)) * bh;
- const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * bw;
- const y1 = y0 + @as(f32, @floatFromInt(layer.viewport.h)) * bh;
- mapped_hit.pixel_x = std.math.clamp(mapped_hit.pixel_x, x0, @max(x0, x1 - 0.001));
- mapped_hit.pixel_y = std.math.clamp(mapped_hit.pixel_y, y0, @max(y0, y1 - 0.001));
- }
- return p.reprojectBodyHit(mapped_hit);
- }
-
- fn cancelLookHover(p: *Pardes) void {
+ pub fn cancelLookHover(p: *Pardes) void {
if (comptime pdf_enabled) if (p.pdf_hover_preview) |*preview| {
if (preview.pane < p.panes.len) if (p.panes[preview.pane]) |pane| {
if (pane.serial == preview.serial) if (pane.pdf) |*pv|
@@ -7286,60 +6718,32 @@ pub const Pardes = struct {
return null;
}
- fn sameTagCell(a: ?TagHit, b: ?TagHit) bool {
- const first = a orelse return b == null;
- const second = b orelse return false;
- return first.kind == second.kind and first.id == second.id and first.serial == second.serial and first.col == second.col;
- }
-
- pub fn tagColumn(p: *const Pardes, kind: TagKind, id: usize, supplied: ?TagHit, clamp: bool) ?u16 {
- var point = supplied orelse return null;
- if (point.kind != kind or point.id != id) return null;
- if (clamp) for (p.surface.tagLayers()) |*layer| {
- if (layer.cols == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial) continue;
- const bw: f32 = @floatFromInt(point.metrics.body_w);
- const bh: f32 = @floatFromInt(point.metrics.body_h);
- const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw;
- const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw;
- point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001));
- point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * bh;
- break;
- };
- return if (p.reprojectTagHit(point)) |mapped| mapped.col else null;
- }
-
- fn sameBodyCell(a: ?Mouse.BodyHit, b: ?Mouse.BodyHit) bool {
- const first = a orelse return b == null;
- const second = b orelse return false;
- return first.serial == second.serial and first.row == second.row and first.col == second.col;
- }
-
fn noteLookHover(p: *Pardes, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_hit: ?TagHit) void {
const delay = config.look_preview_delay_frames orelse return;
_ = delay;
- const id = p.paneAt(col, row) orelse return p.cancelLookHover();
+ const id = mouse.paneAt(p, col, row) orelse return p.cancelLookHover();
const pane = p.panes[id] orelse return p.cancelLookHover();
_ = edit.pointerTextSelection(p, id, col, row, body_hit, tag_hit) orelse return p.cancelLookHover();
if (comptime pdf_enabled) if (p.pdf_hover_preview) |shown|
if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return;
if (p.look_hover_preview) |shown|
- if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and sameBodyCell(shown.body_hit, body_hit) and sameTagCell(shown.tag_hit, tag_hit)) return;
+ if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and mouse.sameBodyCell(shown.body_hit, body_hit) and mouse.sameTagCell(shown.tag_hit, tag_hit)) return;
if (p.look_hover_wait) |waiting|
- if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and sameBodyCell(waiting.body_hit, body_hit) and sameTagCell(waiting.tag_hit, tag_hit)) return;
+ if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and mouse.sameBodyCell(waiting.body_hit, body_hit) and mouse.sameTagCell(waiting.tag_hit, tag_hit)) return;
p.cancelLookHover();
p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial, .body_hit = body_hit, .tag_hit = tag_hit };
}
- fn refreshLookHoverFromRaw(p: *Pardes) void {
+ pub fn refreshLookHoverFromRaw(p: *Pardes) void {
if (!p.pointer_inside or !p.raw_hover_intent) return;
const mapped = p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row) orelse
return p.cancelLookHover();
p.hover_col = mapped.col;
p.hover_row = mapped.row;
const body_hit: ?Mouse.BodyHit = if (p.pointer_body_hit) |mapped_hit|
- p.bodyHitForPane(p.paneAt(mapped.col, mapped.row) orelse return p.cancelLookHover(), mapped_hit, false) orelse return p.cancelLookHover()
+ mouse.bodyHitForPane(p, mouse.paneAt(p, mapped.col, mapped.row) orelse return p.cancelLookHover(), mapped_hit, false) orelse return p.cancelLookHover()
else
null;
p.noteLookHover(mapped.col, mapped.row, body_hit, p.pointer_tag_hit);
@@ -7392,7 +6796,7 @@ pub const Pardes = struct {
if (p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row)) |mapped| {
p.hover_col = mapped.col;
p.hover_row = mapped.row;
- p.dragUpdate(mapped.col, mapped.row, p.pointer_body_hit);
+ mouse.dragUpdate(p, mapped.col, mapped.row, p.pointer_body_hit);
}
}
p.refreshLookHoverFromRaw();
@@ -7409,784 +6813,6 @@ pub const Pardes = struct {
p.settings.scene_effects = .{};
}
- fn handleMouse(p: *Pardes, m: Mouse) void {
- // A click goes somewhere else: the question it leaves unanswered is
- // taken back, and the pane stays. A wheel only looks around.
- if (m.kind == .press and switch (m.button) {
- .left, .middle, .right => true,
- else => false,
- }) for (p.panes) |slot| {
- const pane = slot orelse continue;
- if (pane.prompt == .del_side) pane.prompt = .none;
- };
- if (m.button == .back or m.button == .forward) {
- if (m.kind == .press) p.jumpBy(if (m.button == .back) -1 else 1);
- return;
- }
- const raw_col = @min(m.col, p.screen_w -| 1);
- const raw_row = @min(m.row, p.screen_h -| 1);
- p.pointer_raw_col = raw_col;
- p.pointer_raw_row = raw_row;
- const previous_hit = p.pointer_body_hit;
- p.pointer_body_hit = m.body_hit;
- const previous_tag_hit = p.pointer_tag_hit;
- p.pointer_tag_hit = if (m.tag_hit) |point| p.reprojectTagHit(point) else null;
- if (m.tag_hit != null and p.pointer_tag_hit == null and m.kind == .press) return;
- if (m.tag_hit == null and (m.kind == .drag or m.kind == .release) and (p.drag == .tag or p.drag == .select or p.header_drag)) {
- if (previous_tag_hit) |previous| {
- var continued = previous;
- continued.pixel_x = (@as(f32, @floatFromInt(raw_col)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_w));
- continued.pixel_y = (@as(f32, @floatFromInt(raw_row)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_h));
- p.pointer_tag_hit = continued;
- }
- }
- if ((m.kind == .drag or m.kind == .release) and (p.drag == .tag or p.drag == .select or p.header_drag)) {
- if (previous_tag_hit) |previous| if (p.pointer_tag_hit) |*current| {
- current.kind = previous.kind;
- current.id = previous.id;
- current.serial = previous.serial;
- };
- }
- // A drag can leave the compact text viewport. Preserve its metrics so
- // the endpoint still clamps to that body's last visible logical row.
- if (m.body_hit == null and (m.kind == .drag or m.kind == .release) and p.drag == .select) {
- if (previous_hit) |previous| {
- var continued = previous;
- continued.pixel_x = (@as(f32, @floatFromInt(raw_col)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_w));
- continued.pixel_y = (@as(f32, @floatFromInt(raw_row)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_h));
- p.pointer_body_hit = continued;
- }
- }
- p.pointer_inside = true;
- if (m.button == .none and m.kind == .motion) {
- p.raw_hover_intent = true;
- p.refreshLookHoverFromRaw();
- return;
- }
- p.raw_hover_intent = false;
- const mapped = p.presentation.pointer(p.screen_w, p.screen_h, raw_col, raw_row) orelse {
- p.cancelLookHover();
- if (m.kind == .release) p.dragRelease(m.button);
- return;
- };
- const mcol = mapped.col;
- const mrow = mapped.row;
- p.hover_col = mcol;
- p.hover_row = mrow;
-
- const hovered = p.paneAt(mcol, mrow);
-
- // any press that is NOT the execute+select chord abandons a pending
- // chord argument
- if (m.kind == .press and
- !(m.button == config.select_button and p.drag == .select and p.drag.select.button == config.exec_button))
- {
- if (p.chord_arg) |a| {
- p.gpa.free(a);
- p.chord_arg = null;
- }
- }
- if (mrow >= p.topBarHeight() and mrow < p.topBarHeight() + p.columnBarHeight() and m.kind == .press) {
- for (0..p.ncol) |column| {
- if (mcol < p.col_x[column] or mcol >= p.col_x[column] + @min(config.GUTTER, p.col_w[column])) continue;
- if (m.button == config.select_button) {
- p.header_drag = false;
- p.drag = .{ .column_move = .{ .serial = p.col_serial[column], .cur_x = mcol, .grab = mcol - p.col_x[column] } };
- }
- return;
- }
- }
- if (p.header_drag and m.button == config.select_button and (m.kind == .drag or m.kind == .release)) {
- const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0;
- const bar = p.headerText();
- p.topbar_col = @intCast(@min(bar.len, panes.File.rawAtDisplay(bar, (p.tagColumn(if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + p.header_scroll)));
- if (m.kind == .release) p.header_drag = false;
- return;
- }
- if (mrow < p.topBarHeight() + p.columnBarHeight() and m.kind == .press and
- (m.button == config.select_button or m.button == config.exec_button or m.button == config.look_button))
- {
- if (m.button == config.select_button) if (p.panes[p.active]) |pane| {
- if (pane.tag_edit) exitTagEdit(pane);
- };
- var column: ?usize = null;
- var x: u16 = 0;
- if (mrow >= p.topBarHeight()) {
- for (0..p.ncol) |c| if (mcol >= p.col_x[c] and mcol < p.col_x[c] + p.col_w[c]) {
- column = c;
- x = p.col_x[c] + TAG_TEXT_INSET;
- if (layout.findPane(p, p.active)) |active_col| {
- if (active_col.col != c and p.col_n[c] > 0) p.active = p.col_panes[c][0];
- }
- break;
- };
- if (column == null or mcol < x) return;
- }
- if (p.header_column != column) {
- p.rememberHeader();
- p.topbar_col = null;
- p.header_anchor = null;
- p.header_scroll = 0;
- }
- p.header_column = column;
- const bar = p.headerText();
- const at = @min(bar.len, panes.File.rawAtDisplay(bar, (p.tagColumn(if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + p.header_scroll));
- if (m.button == config.select_button) {
- p.topbar_col = @intCast(at);
- p.header_anchor = @intCast(at);
- p.header_insert = true;
- p.header_drag = true;
- } else {
- const selected = if (p.topbar_col) |cur| blk: {
- const a = p.header_anchor orelse cur;
- break :blk if (at >= @min(a, cur) and at < @max(a, cur)) bar[@min(a, cur)..@max(a, cur)] else wordAtCol(bar, at);
- } else wordAtCol(bar, at);
- const word = p.scratch.allocator().dupe(u8, selected) catch return;
- p.exitHeader();
- if (word.len > 0) {
- if (m.button == config.exec_button) {
- const named = std.meta.stringToEnum(Builtin, word);
- const held = if (named) |b| (if (builtins.registry.takesArg(b)) p.heldSelection(p.active) else null) else p.heldSelection(p.active);
- _ = p.execute(p.active, p.withArg(word, held));
- } else p.runBuiltin(config.look_cmd, p.active, "", word);
- }
- }
- return;
- }
- if (m.kind == .press) {
- p.exitHeader();
- }
-
- switch (m.button) {
- .none, .back, .forward => {}, // hover and navigation handled above
- .wheel_up, .wheel_down => {
- if (m.kind != .press) return;
- // re-arms the drift guard below; vertical always scrolls, so
- // the answer is never in doubt here
- _ = config.wheelTick(&p.wheel_guard, true);
- const id = hovered orelse return;
- const pane = p.panes[id].?;
- if (pane.hasPdf())
- panes.Pdf.verticalWheel(p, pane, if (m.button == .wheel_up) -1 else 1)
- else
- pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows);
- },
- .wheel_left, .wheel_right => {
- if (m.kind != .press) return;
- if (!config.wheelTick(&p.wheel_guard, false)) return;
- const id = hovered orelse return;
- const pane = p.panes[id].?;
- if (pane.hasPdf()) {
- panes.Pdf.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1);
- // ponytail: no right clamp — overscroll shows blank and the
- // next cursor move or left wheel pulls it back
- } else if (pane.file != null and !p.settings.wrap) {
- // wrapped there is nothing off to the right to reach
- const line = panes.File.sourceLine(pane, pane.cur_row);
- const visual = panes.File.rawDisplayCol(line, @intCast(@max(0, pane.hscroll)));
- const next: usize = if (m.button == .wheel_right)
- visual +| @as(usize, @intCast(config.wheel_cols))
- else
- visual -| @as(usize, @intCast(config.wheel_cols));
- pane.hscroll = @intCast(panes.File.rawAtDisplay(line, next));
- }
- },
- config.select_button => switch (m.kind) {
- .press => {
- if (p.drag == .select and p.drag.select.button == config.exec_button) {
- if (p.heldSelection(p.drag.select.id)) |tx| {
- if (p.chord_arg) |old| p.gpa.free(old);
- p.chord_arg = p.gpa.dupe(u8, tx) catch null;
- }
- return;
- }
- // acme 3-1: a select tap during a LOOK drag cancels the
- // look; the rest of the gesture is inert
- if (p.drag == .select and p.drag.select.button == config.look_button) {
- if (p.panes[p.drag.select.id]) |t| {
- t.sel[@intFromEnum(config.look_button)].state = .none;
- if (comptime pdf_enabled)
- panes.Pdf.pointerCancel(t, p.drag.select.pdf);
- }
- p.drag = .none;
- return;
- }
- if (hovered) |mid| {
- const mr = p.rects[mid];
- const mtag = if (p.settings.tag_bottom) mr.y + mr.h -| BOX_H else mr.y;
- if (mcol < mr.x + config.GUTTER and mrow >= mtag and mrow < mtag + BOX_H) {
- p.drag = .{ .move = .{ .id = mid, .cur_x = mcol, .cur_y = mrow } };
- return;
- }
- }
- for (0..p.ncol -| 1) |c| {
- if (mcol == p.col_x[c] + p.col_w[c] -| 1) {
- const corner: @FieldType(@FieldType(Drag, "border_v"), "corner") = if (p.seamIdxAt(c, mrow)) |k|
- .{ .col = c, .idx = k }
- else if (p.seamIdxAt(c + 1, mrow)) |k|
- .{ .col = c + 1, .idx = k }
- else
- null;
- p.drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol, .corner = corner, .cur_y = mrow } };
- return;
- }
- }
- for (0..p.ncol) |cc| {
- if (mcol < p.col_x[cc] or mcol >= p.col_x[cc] + p.col_w[cc]) continue;
- if (p.seamIdxAt(cc, mrow)) |k| {
- p.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } };
- return;
- }
- }
- const id = hovered orelse return;
- const r = p.rects[id];
- const pane = p.panes[id] orelse return;
- if (id != p.active) if (p.panes[p.active]) |previous| {
- if (previous.tag_edit) exitTagEdit(previous);
- };
- const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
- const body_y = p.bodyTop(r);
- if (mcol < r.x + config.GUTTER) {
- // gutter scrollbar: a left click scrolls UP to that row
- p.active = id;
- pane.scrollBy(-(@as(i32, mrow) - @as(i32, body_y)));
- } else if (mrow >= tag_y and mrow < tag_y + BOX_H) {
- if (mcol < r.x + TAG_TEXT_INSET) return;
- p.active = id;
- p.enterTagEdit(pane, if (p.tagColumn(.pane, id, p.pointer_tag_hit, false)) |value| @as(i32, value) else @as(i32, mcol) - @as(i32, r.x + TAG_TEXT_INSET));
- pane.tag_anchor = pane.tag_col;
- p.drag = .{ .tag = .{ .id = id } };
- } else {
- p.active = id;
- if (pane.tag_edit) exitTagEdit(pane);
- const pointed = edit.pointerTextSelection(p, id, mcol, mrow, m.body_hit, p.pointer_tag_hit) orelse return;
- pane.clearPointerSelection(sel_slot);
- pane.sel[sel_slot] = pointed.sel;
- p.drag = .{ .select = .{
- .id = id,
- .button = config.select_button,
- .ctrl = m.ctrl,
- .pdf = panes.Pdf.pointerStart(
- p,
- pane,
- config.select_button,
- mcol,
- mrow,
- false,
- ),
- } };
- }
- },
- .drag => p.dragUpdate(mcol, mrow, p.pointer_body_hit),
- .release => {
- if ((p.pointer_body_hit != null or p.pointer_tag_hit != null or p.drag == .column_move) and p.drag.button() == m.button) p.dragUpdate(mcol, mrow, p.pointer_body_hit);
- p.dragRelease(config.select_button);
- },
- .motion => {},
- },
- config.exec_button, config.look_button => switch (m.kind) {
- .press => {
- // acme 1-2 / 1-3: the execute (Cut) or look (Paste) button
- // tapped while the select button holds a selection drag
- if (p.drag == .select and p.drag.select.button == config.select_button) {
- p.chordCutPaste(m.button == config.exec_button);
- return;
- }
- // acme 2-3 / 3-2: tapping the OTHER of execute/look during
- // an execute or look drag cancels it (textselect23's mask)
- if (p.drag == .select) {
- if (p.panes[p.drag.select.id]) |t| {
- t.sel[@intFromEnum(p.drag.select.button)].state = .none;
- if (comptime pdf_enabled)
- panes.Pdf.pointerCancel(t, p.drag.select.pdf);
- }
- p.drag = .none;
- return;
- }
- const id = hovered orelse return;
- const r = p.rects[id];
- const pane = p.panes[id] orelse return;
- // the tag row and the body's first, exactly as the select
- // arm above derives them
- const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
- const body_y = p.bodyTop(r);
- const on_tag = mrow >= tag_y and mrow < tag_y + BOX_H;
- if (mcol < r.x + config.GUTTER and !on_tag) {
- const local = @as(i32, mrow) - @as(i32, body_y);
- if (m.button == config.look_button) p.active = id;
- pane.scrollBy(if (m.button == config.look_button) local else -local);
- } else if (edit.pointerTextSelection(p, id, mcol, mrow, m.body_hit, p.pointer_tag_hit)) |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 = .{
- .id = id,
- .button = m.button,
- // A native body gesture stays native even when its
- // point later misses a letterbox or selection.
- .pdf = panes.Pdf.pointerStart(
- p,
- pane,
- m.button,
- mcol,
- mrow,
- pointed.on_tag,
- ),
- },
- };
- }
- },
- .drag => p.dragUpdate(mcol, mrow, p.pointer_body_hit),
- .release => {
- if ((p.pointer_body_hit != null or p.pointer_tag_hit != null or p.drag == .column_move) and p.drag.button() == m.button) p.dragUpdate(mcol, mrow, p.pointer_body_hit);
- p.dragRelease(m.button);
- },
- .motion => {},
- },
- }
- }
-
- /// Index of the pane pair in column `c` whose seam (see seamRowOf) is screen
- /// row `mrow`. Out-of-range columns simply have no seam.
- fn seamIdxAt(p: *const Pardes, c: usize, mrow: u16) ?usize {
- if (c >= p.ncol) return null;
- for (0..p.col_n[c] -| 1) |k| if (mrow == seamRowOf(p, c, k)) return k;
- return null;
- }
-
- fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) void {
- switch (p.drag) {
- .border_v => |*d| {
- const c = d.left_col;
- d.cur_x = if (c + 1 < p.ncol)
- clampBorderCol(p.col_x[c], p.col_w[c], p.col_w[c + 1], mcol)
- else
- mcol;
- if (d.corner) |k| if (k.idx + 1 < p.col_n[k.col]) {
- const a = p.rects[p.col_panes[k.col][k.idx]];
- const b = p.rects[p.col_panes[k.col][k.idx + 1]];
- d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom);
- } else {
- d.cur_y = mrow;
- };
- },
- .border_h => |*d| {
- const cc = d.col;
- const k = d.top_idx;
- if (k + 1 < p.col_n[cc]) {
- const a = p.rects[p.col_panes[cc][k]];
- const b = p.rects[p.col_panes[cc][k + 1]];
- d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom);
- } else d.cur_y = mrow;
- },
- .column_move => |*d| d.cur_x = mcol,
- .move => |*d| {
- d.cur_x = mcol;
- d.cur_y = mrow;
- },
- .select => |*s| {
- if (p.panes[s.id]) |pane| {
- const r = p.rects[s.id];
- const b = @intFromEnum(s.button);
- if (pane.body_rows > 0 and pane.sel[b].r0 >= BOX_H) if (body_hit) |supplied| {
- const mapped_hit = p.bodyHitForPane(s.id, supplied, true) orelse return;
- pane.sel[b].c1 = @as(i32, mapped_hit.col) - config.GUTTER;
- pane.sel[b].r1 = @as(i32, mapped_hit.row) + BOX_H;
- return;
- };
- if (pane.sel[b].r0 < BOX_H) if (p.tagColumn(.pane, s.id, p.pointer_tag_hit, true)) |value| {
- pane.sel[b].c1 = @as(i32, value) + pane.tag_scroll;
- pane.sel[b].r1 = 0;
- return;
- };
- pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
- // Sel row BOX_H is the body's FIRST row, the same thing
- // pointerTextSelection means by it -- so the drag has to
- // measure from the body's top, notice bands included, or a
- // selection made while a message is up lands rows away
- // from the pointer.
- const body_y = p.bodyTop(r);
- const body_h = r.h -| BOX_H;
- pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0)
- 0
- else
- @as(i32, @min(@max(mrow, body_y), body_y + body_h - 1)) - @as(i32, body_y) + @as(i32, BOX_H);
- if (pane.sel[b].r1 < BOX_H) pane.sel[b].c1 += @as(i32, pane.tag_scroll) - TAG_GAP;
- if (comptime pdf_enabled)
- panes.Pdf.pointerUpdate(&s.pdf, p, pane, mcol, mrow);
- }
- },
- .tag => |d| {
- const pane = p.panes[d.id] orelse return;
- const c = if (p.tagColumn(.pane, d.id, p.pointer_tag_hit, true)) |value| @as(i32, value) else @as(i32, mcol) - @as(i32, p.rects[d.id].x + TAG_TEXT_INSET);
- const text = p.tagText(p.scratch.allocator(), pane) catch return;
- pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @as(usize, @intCast(@max(0, c))) + pane.tag_scroll)));
- pane.tag_sel = pane.tag_col != pane.tag_anchor;
- },
- .none => {},
- }
- }
-
- fn reportGesture(
- p: *Pardes,
- id: usize,
- cmd: Builtin,
- text: []const u8,
- on_tag: bool,
- operand: PointerOperand,
- chorded: bool,
- ) bool {
- const scripted = p.panes[id] orelse return false;
- if (scripted.fs.readers == 0) return false;
- const is_look = cmd == config.look_cmd;
- const action: ctlfs.events.Action = if (is_look)
- (if (on_tag) .tag_look else .body_look)
- else
- (if (on_tag) .tag_exec else .body_exec);
- const named = std.meta.stringToEnum(Builtin, commandText(text)) != null;
- const range = p.gestureRange(id, text, on_tag, operand);
- var flag: u32 = if (named) ctlfs.events.flag_builtin else 0;
- if (range.q0 == range.q1 and text.len > 0) flag |= ctlfs.events.flag_expansion;
- if (is_look) {
- if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= ctlfs.events.flag_filename;
- } else if (chorded) flag |= ctlfs.events.flag_chorded;
- return ctlfs.events.noteAction(p, id, action, range.q0, range.q1, flag, text);
- }
-
- fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: PointerOperand) ctlfs.pane.State.Range {
- const pane = p.panes[id] orelse return .{};
- if (on_tag) {
- const tag = p.tagText(p.scratch.allocator(), pane) catch return .{};
- const sel = operand.expanded orelse operand.preview orelse return .{};
- const lead = @min(sel.c0, sel.c1);
- const at = panes.File.rawAtDisplay(tag, @intCast(@max(0, lead)));
- const q0: u32 = @intCast(@min(at, tag.len));
- return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, tag.len)) };
- }
- const f = if (pane.file) |*file| file else return .{};
- if (operand.preview) |selection| if (pane.pointerSelection(selection)) |rows| {
- if (rows.len == 0) return .{};
- const first = rows[0];
- const last = rows[rows.len - 1];
- const q0 = panes.File.lineStart(p.gpa, f, @intCast(@max(0, first.row))) +
- @min(first.lo, panes.File.sourceLine(pane, first.row).len);
- const q1 = panes.File.lineStart(p.gpa, f, @intCast(@max(0, last.row))) +
- @min(last.hi, panes.File.sourceLine(pane, last.row).len);
- // A rectangle may contain disjoint pieces. Such an operand uses
- // the event's expansion text instead of inventing a source range.
- if (q1 < q0 or !std.mem.eql(u8, f.content[q0..q1], text)) return .{};
- return .{ .q0 = @intCast(q0), .q1 = @intCast(q1) };
- };
- const start: ?modal.Cursor = if (operand.file_word) |w|
- .{ .row = @intCast(@max(0, w.row)), .col = @intCast(@max(0, w.lo)) }
- else if (operand.expanded orelse operand.preview) |sel| lead: {
- // A selection's leading corner in reading order, converted the way
- // `pointerOperand` converts the click itself.
- const top = @min(sel.r0, sel.r1) - @as(i32, BOX_H);
- if (top < 0) break :lead null;
- const w = pane.wrapAt(top);
- const col = edit.paneByteAtDisplay(p, pane, w.line, w.at, @min(sel.c0, sel.c1) - panes.File.gutterWidth(pane));
- break :lead .{
- .row = @intCast(@max(0, w.line)),
- .col = @intCast(@max(0, col)),
- };
- } else null;
- const cursor = start orelse return .{};
- const q0: u32 = @intCast(@min(modal.offsetAt(f.content, cursor), f.content.len));
- return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, f.content.len)) };
- }
-
- fn dispatchPointerBuiltin(
- p: *Pardes,
- id: usize,
- cmd: Builtin,
- text: ?[]const u8,
- gesture: ?struct { on_tag: bool, operand: PointerOperand },
- ) void {
- const arg = p.chord_arg;
- p.chord_arg = null;
- defer if (arg) |a| p.gpa.free(a);
- const operand = text orelse {
- if (gesture) |g| _ = p.reportGesture(id, cmd, "", g.on_tag, g.operand, arg != null);
- return;
- };
- if (gesture) |g| if (p.reportGesture(id, cmd, operand, g.on_tag, g.operand, arg != null)) return;
- p.runBuiltin(cmd, id, "", p.withArg(operand, arg));
- }
-
- fn mirrorTtySelection(p: *Pardes, pane: *Pane) void {
- const gesture = pane.sel[sel_slot];
- const rows = pane.pointerSelection(gesture) orelse return;
- const text = pane.rawPointerText(gesture) orelse return;
- if (rows.len == 0) return;
- const endpoint = rows[if (gesture.r1 >= gesture.r0) rows.len - 1 else 0];
- const body = body_layer.bodyText(p, p.scratch.allocator(), pane) catch return;
- const line = modal.lineSlice(body, @intCast(@max(0, endpoint.row - panes.Terminal.gridOffset(pane))));
- const byte = if (gesture.c1 >= gesture.c0) modal.prevGrapheme(line, @min(endpoint.hi, line.len)) else endpoint.lo;
- pane.cur_row = pane.surfRow(endpoint.row);
- var shown = std.mem.trimEnd(u8, line[@min(endpoint.prompt_bytes, line.len)..], " \t");
- if (pane.ovl) |overlay| {
- const relative = pane.cur_row - overlay.row;
- if (relative >= 0 and relative < modal.lineCount(overlay.text))
- shown = modal.lineSlice(overlay.text, @intCast(relative));
- }
- pane.cur_col = @intCast(modal.graphemeStart(shown, @min(shown.len, byte -| endpoint.prompt_bytes)));
- pane.cur_pinned = true;
- pane.msel.active = false;
- pane.vsel.active = false;
- pane.nsel = 0;
- pane.select = false;
- pane.append_at = null;
- pane.sticky_col = -1;
- pane.normal.clear();
- edit.setYank(p, text);
- }
-
- fn dragRelease(p: *Pardes, button: Mouse.Button) void {
- // A different button's release belongs to a chord or another host
- // stream; only the button which began this gesture may end it.
- if (p.drag.button() != button) return;
- defer p.drag = .none;
- switch (p.drag) {
- .border_v => |d| {
- const c = d.left_col;
- const old_seam = p.col_x[c] + p.col_w[c] -| 1;
- // the handle is the left pane's LAST column (inclusive), so
- // the new width includes it — a no-drag click changes nothing
- if (c + 1 < p.ncol and d.cur_x != old_seam)
- layout.setColumnPairWidth(p, c, (d.cur_x + 1) -| p.col_x[c]);
- if (d.corner) |k| {
- if (d.cur_y != seamRowOf(p, k.col, k.idx))
- layout.applyRowSplit(p, k.col, k.idx, d.cur_y);
- }
- },
- .border_h => |d| if (d.cur_y != seamRowOf(p, d.col, d.top_idx))
- layout.applyRowSplit(p, d.col, d.top_idx, d.cur_y),
- .column_move => |d| if (layout.columnBySerial(p, d.serial)) |column| switch (layout.columnDrop(p, column, d.cur_x, d.grab)) {
- .stay => {},
- .reorder => |target| layout.reorderColumn(p, column, target),
- .edge => |x| layout.setColumnPairWidth(p, column - 1, x - p.col_x[column - 1]),
- },
- .move => |d| {
- layout.movePane(p, d.id, d.cur_x, d.cur_y);
- if (p.panes[d.id]) |mp| p.stillborn_joiner = mp.serial;
- // a file moved into the left column evicts a lone unused shell
- if (p.panes[d.id]) |mt| if (mt.file != null) {
- if (layout.findPane(p, d.id)) |f| if (f.col == 0)
- p.evictLonePristineTty(0, d.id);
- };
- },
- .select => |s| {
- const pane = p.panes[s.id] orelse return;
- if (comptime pdf_enabled) {
- if (s.pdf.native) {
- var release = panes.Pdf.pointerRelease(
- p,
- pane,
- s.pdf,
- s.button,
- s.chorded,
- );
- defer release.deinit(p.pdf_gpa);
- if (release.probe) |*probe| if (probe.link != null) {
- p.followPdfLink(s.id, probe);
- return;
- };
- // A native PDF page has no byte offsets of its own to
- // report, so a scripted pane cannot intercept this one.
- if (release.action) |action| p.dispatchPointerBuiltin(
- s.id,
- if (action == .look) config.look_cmd else config.exec_cmd,
- release.text,
- null,
- );
- return;
- }
- }
- if (s.chorded) {
- // a cut/paste chord consumed this drag; the release is inert
- pane.sel[@intFromEnum(s.button)].state = .none;
- return;
- }
- const b = @intFromEnum(s.button);
- pane.sel[b].state = .done;
- if (s.button == config.select_button) {
- 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) edit.capturePointerSelection(p, pane, sel_slot) catch {};
- if (dragged and pane.mode == .tty) p.mirrorTtySelection(pane);
- const body_vis = sl.r1 - @as(i32, BOX_H);
- if (body_vis >= 0 and pane.mode != .tty) {
- const w = pane.wrapAt(body_vis);
- pane.cur_row = w.line;
- pane.cur_col = edit.paneByteAtDisplay(
- p,
- pane,
- w.line,
- w.at,
- sl.c1 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0),
- );
- pane.cur_pinned = true;
- if (edit.clampFileCursor(pane)) pane.ensureCursorVisible();
- pane.msel.active = false;
- pane.normal.clear();
- pane.vsel.active = false;
- pane.nsel = 0; // a click says WHERE the one cursor is
- pane.select = false;
- pane.append_at = null;
- pane.sticky_col = -1;
- if (s.ctrl) p.lspRequest(s.id, .definition, "");
- }
- } else {
- const clk = pane.sel[b];
- // A right-click on a line NUMBER looks at that line of
- // this file -- a sticky context header's number included,
- // since the row map holds each header's own source line.
- if (s.button == config.look_button and clk.r0 >= BOX_H and clk.r0 == clk.r1 and
- clk.c0 == clk.c1 and clk.c0 >= 0 and pane.mode != .tty)
- if (pane.file) |f| if (f.output == null and clk.c0 < panes.File.gutterWidth(pane)) {
- const line = pane.wrapAt(clk.r0 - BOX_H).line;
- if (line >= 0) {
- pane.sel[b].state = .none;
- var buf: [48]u8 = undefined;
- const target = std.fmt.bufPrint(&buf, "@p{d}:{d}", .{ s.id, @as(usize, @intCast(line)) + 1 }) catch return;
- p.runBuiltin(config.look_cmd, s.id, "", target);
- return;
- }
- };
- var operand = p.pointerOperand(pane, clk);
- if (operand.expanded) |expanded|
- pane.sel[b] = expanded
- else if (operand.text == null)
- pane.sel[b].state = .none;
- if (s.button == config.look_button and clk.r0 >= BOX_H and pane.mode != .tty) {
- pane.cur_row = operand.row;
- pane.cur_col = operand.col;
- if (edit.clampFileCursor(pane)) pane.ensureCursorVisible();
- }
- const txt = operand.text;
- const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd;
- if (clk.r0 < BOX_H and s.button == config.exec_button and pane.tag_name_len != null) {
- const prefix_width = panes.File.displayWidth(p.tagPrefix(pane) catch return);
- const on_name = @as(usize, @intCast(@max(0, clk.c0))) < prefix_width;
- if (!p.commitTagName(pane)) return;
- // Executing a staged filename commits it; executing its
- // command tail uses the new identity (especially Save).
- if (on_name) return;
- const new_width = panes.File.displayWidth(p.tagPrefix(pane) catch return);
- const shift = @as(i32, @intCast(new_width)) - @as(i32, @intCast(prefix_width));
- if (operand.expanded) |*sel| {
- sel.c0 += shift;
- sel.c1 += shift;
- }
- if (operand.preview) |*sel| {
- sel.c0 += shift;
- sel.c1 += shift;
- }
- pane.sel[b].c0 += shift;
- pane.sel[b].c1 += shift;
- }
- p.dispatchPointerBuiltin(s.id, cmd, txt, .{
- .on_tag = clk.r0 < BOX_H,
- .operand = operand,
- });
- }
- },
- .tag => {}, // dragUpdate already left the tag cursor + selection set
- .none => {},
- }
- }
-
- fn chordCutPaste(p: *Pardes, cut: bool) void {
- const s = &p.drag.select;
- const pane = p.panes[s.id] orelse return;
- if (comptime pdf_enabled) if (pane.pdf) |pv| {
- if (!s.chorded) {
- s.chorded = true;
- pane.sel[sel_slot].state = .none;
- }
- // A PDF is immutable: its Cut-side chord is Snarf (copy), while
- // Paste has no document mutation to perform.
- if (cut and pv.selection_text.len > 0) edit.setYank(p, pv.selection_text);
- return;
- };
- if (pane.mode == .tty) {
- if (!s.chorded) {
- s.chorded = true;
- pane.sel[sel_slot].state = .none; // drop the sweep highlight
- }
- if (cut) return;
- const y = p.yank orelse return;
- if (y.len == 0) return;
- if (panes.Terminal.reportsMouse(pane)) {
- const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999));
- const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, BOX_H) + 1, 1, 9999));
- var mb: [32]u8 = undefined;
- if (panes.Terminal.mouseFormatSgr(pane)) {
- p.emitWrite(s.id, std.fmt.bufPrint(&mb, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ col, row, col, row }) catch return);
- } else {
- // ponytail: legacy X10 bytes; add utf8/urxvt formats if an app ever wants them
- const cb: u8 = @intCast(32 + @min(col, 222));
- const rb: u8 = @intCast(32 + @min(row, 222));
- p.emitWrite(s.id, &[_]u8{ 0x1b, '[', 'M', 32, cb, rb, 0x1b, '[', 'M', 35, cb, rb });
- }
- }
- edit.typeToTty(p, s.id, pane, y);
- return;
- }
- if (!s.chorded) {
- s.chorded = true;
- const sl = pane.sel[sel_slot];
- pane.sel[sel_slot].state = .none;
- const pfx: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0;
- // both ends of the sweep through the same last-frame map, so a
- // chord over wrapped rows cuts the text that was under it
- const w0 = pane.wrapAt(@max(0, sl.r0 - @as(i32, BOX_H)));
- const w1 = pane.wrapAt(@max(0, sl.r1 - @as(i32, BOX_H)));
- const row0 = w0.line;
- const row1 = w1.line;
- const col0 = edit.paneByteAtDisplay(p, pane, w0.line, w0.at, sl.c0 - pfx);
- const col1 = edit.paneByteAtDisplay(p, pane, w1.line, w1.at, sl.c1 - pfx);
- pane.cur_row = row1;
- pane.cur_col = col1;
- pane.cur_pinned = true;
- pane.msel.active = false;
- pane.normal.clear();
- if (!pane.isTerminal()) pane.mode = .normal;
- pane.vsel = .{ .active = row0 != row1 or col0 != col1, .row = row0, .col = col0, .explicit = false };
- pane.nsel = 0;
- }
- if (cut) {
- if (pane.vsel.active) edit.normalDelete(p, pane, true);
- } else if (pane.vsel.active) {
- edit.normalReplaceYank(p, pane);
- } else if (pane.file) |*f| {
- // bare cursor: splice the register in literally (no linewise
- // magic) so a paste right after a cut restores the text exactly
- const y = p.yank orelse return;
- if (y.len == 0) return;
- const at = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
- edit.pushUndo(p, pane);
- const new = modal.insertAt(p.gpa, f.content, at, y) catch return;
- panes.File.setContent(p, f, new);
- pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @intCast(at.row), .col = @intCast(at.col), .explicit = false };
- const end = modal.advanceBy(at, y);
- if (end.col > 0) {
- pane.cur_row = @intCast(end.row);
- pane.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(f.content, end.row), end.col));
- } else {
- // the register ended in '\n': the cursor lands ON that newline
- pane.cur_row = @intCast(end.row -| 1);
- pane.cur_col = @intCast(modal.lineSlice(f.content, end.row -| 1).len);
- }
- pane.cur_pinned = true;
- pane.sticky_col = -1;
- pane.ensureCursorVisible();
- } else edit.pasteText(p, pane, p.yank orelse return, true, 1);
- }
-
/// Tty: a shell in the caller's directory, raw from the first frame,
/// stacked below the caller like Alt-n's new shell.
pub fn spawnTty(p: *Pardes, from: usize) void {
@@ -8218,7 +6844,7 @@ pub const Pardes = struct {
/// when a doc lands in `col`, a lone pristine shell there is clutter — drop
/// it; absorbVWeight hands its space to the doc
- fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void {
+ pub fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void {
var n_tty: usize = 0;
var tty_id: usize = 0;
for (0..p.col_n[col]) |k| {
@@ -8485,7 +7111,7 @@ pub const Pardes = struct {
};
}
- fn followPdfLink(p: *Pardes, id: usize, probe: *const panes.Pdf.WordProbe) void {
+ pub fn followPdfLink(p: *Pardes, id: usize, probe: *const panes.Pdf.WordProbe) void {
if (comptime !pdf_enabled) return;
const pane = p.panes[id] orelse return;
const link = probe.link orelse return;
@@ -8628,7 +7254,7 @@ pub const Pardes = struct {
/// Trim and unwrap clickable command-word notation once for every command
/// consumer, so startup lines and interactive Exec speak the same syntax.
- fn commandText(txt: []const u8) []const u8 {
+ pub fn commandText(txt: []const u8) []const u8 {
var cmd = std.mem.trim(u8, txt, " \t\r\n");
while (config.commandWord(cmd)) |inner| cmd = std.mem.trim(u8, inner, " \t\r\n");
return cmd;
@@ -9630,15 +8256,15 @@ pub const Pardes = struct {
for (0..p.ncol) |cc| {
if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue;
// the same seam the h-handle hit test picks (see there)
- if (p.seamIdxAt(cc, p.hover_row) != null) {
+ if (mouse.seamIdxAt(p, cc, p.hover_row) != null) {
var col = p.col_x[cc];
while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
}
}
for (0..p.ncol -| 1) |cn| {
if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue;
- if (p.seamIdxAt(cn, p.hover_row) != null) continue;
- if (p.seamIdxAt(cn + 1, p.hover_row) == null) continue;
+ if (mouse.seamIdxAt(p, cn, p.hover_row) != null) continue;
+ if (mouse.seamIdxAt(p, cn + 1, p.hover_row) == null) continue;
var col = p.col_x[cn + 1];
while (col < p.col_x[cn + 1] + p.col_w[cn + 1]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
}
@@ -11185,24 +9811,3 @@ test "jump history terminal rectangle follows output and clears on reflow" {
try std.testing.expect(pane.pointerSelection(pane.sel[0]) == null);
try std.testing.expectEqual(.none, pane.sel[0].state);
}
-
-test "mouse thumb buttons navigate once and clear selection gestures" {
- 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);
- p.njumps = 3;
- p.jcur = 2;
- for (0..3) |i| p.jumps[i] = .{ .pane = 0, .serial = pane.serial, .line = @intCast(i + 1), .col = 1 };
- p.drag = .{ .select = .{ .id = 0, .button = config.select_button } };
- p.update(.{ .mouse = .{ .button = .back, .kind = .press, .col = 0, .row = 0 } });
- try std.testing.expectEqual(@as(usize, 1), p.jcur);
- for ([_]Mouse.Kind{ .drag, .release, .motion }) |kind|
- p.update(.{ .mouse = .{ .button = .back, .kind = kind, .col = 0, .row = 0 } });
- try std.testing.expectEqual(@as(usize, 1), p.jcur);
- p.update(.{ .mouse = .{ .button = .forward, .kind = .press, .col = 0, .row = 0 } });
- p.update(.{ .mouse = .{ .button = .forward, .kind = .release, .col = 0, .row = 0 } });
- try std.testing.expectEqual(@as(usize, 2), p.jcur);
- try std.testing.expect(!pane.vsel.active);
- try std.testing.expectEqual(.none, p.drag);
-}
diff --git a/src/web.zig b/src/web.zig
index b5a992ae..4c2a9db8 100644
--- a/src/web.zig
+++ b/src/web.zig
@@ -10,6 +10,7 @@
const std = @import("std");
const pardes = @import("pardes.zig");
+const mouse = @import("mouse.zig");
const gpa = std.heap.wasm_allocator;
const embedded_dump = @embedFile("embedded.dump.zon");
@@ -441,7 +442,7 @@ export fn pardes_chrome_target(col_arg: u32, row_arg: u32) u32 {
const s = &(state orelse return std.math.maxInt(u32));
const col: u16 = @intCast(@min(col_arg, std.math.maxInt(u16)));
const row: u16 = @intCast(@min(row_arg, std.math.maxInt(u16)));
- const target = s.core.chromeTarget(col, row) orelse return std.math.maxInt(u32);
+ const target = mouse.chromeTarget(s.core, col, row) orelse return std.math.maxInt(u32);
return (@as(u32, target.row) << 16) | target.col;
}