diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-27 22:16:35 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 9660151c94e988620c79b40a0326057b9854c081 (patch) | |
| tree | 521248762c58ac4187b6a7e1179d963577c2680d /src/pardes.zig | |
| parent | f1025c18a78c02ebf157f0e3a5d9552605c082e8 (diff) | |
| download | pardes-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/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 1447 |
1 files changed, 26 insertions, 1421 deletions
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); -} |
