//! 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 tagline = @import("tagline.zig"); const exec = @import("exec.zig"); const look = @import("look.zig"); const std = @import("std"); const layout = @import("layout.zig"); const Presentation = @import("Presentation.zig"); const animation = @import("animation.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 Layer = @import("Layer.zig"); 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 }, /// 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, /// A B1 body sweep held past the pane's top (-1) or bottom (1): the /// body scrolls a line a tick, as acme's frselect scrolls /// (libframe frselect.c, the `scroll` callback while the button is /// held outside the frame). edge: i2 = 0, /// Where the sweep began in the text, once it has scrolled: its /// press row may be off the screen. anchor: ?modal.Cursor = null, /// 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 => config.select_button, .select => |selection| selection.button, }; } }; /// A middle or right sweep over a column's tag or the workspace's, held as /// a pane tag's is: painted in its button's tint while the button is down, /// and run (or looked for) in that tag's context when it comes up. `anchor` /// and `cur` are byte offsets into the header's text. pub const HeaderSweep = struct { /// The column's serial; null for the workspace's tag. serial: ?u32, button: Mouse.Button, anchor: usize, cur: usize, /// The column index the sweep is over, or null for the workspace's /// tag; `error.Gone` once its column has been closed. pub fn column(s: HeaderSweep, p: *const Pardes) error{Gone}!?usize { const serial = s.serial orelse return null; return layout.columnBySerial(p, serial) orelse error.Gone; } /// The swept bytes, empty for a click that has not moved. pub fn span(s: HeaderSweep, len: usize) struct { lo: usize, hi: usize } { return .{ .lo = @min(s.anchor, s.cur, len), .hi = @min(@max(s.anchor, s.cur), len) }; } }; /// What a pointer at (mcol, mrow) is over in a column's tag, or the /// workspace's for null: the header's text, the line, and the byte offset /// in that text. `clamp` holds a held sweep to the header's rows and text, /// as a pane tag's sweep stays on its tag. fn headerPoint(p: *Pardes, column: ?usize, mcol: u16, mrow: u16, clamp: bool) ?struct { t: *panes.Text, text: []const u8, line: u16, at: usize } { const t = if (column) |c| &p.col_tags[c] else &p.global_tag; const x = if (column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; const focused = p.header_focus and p.header_column == column; const text = edit.flatSurface(p, t) catch return null; const base = if (column != null) p.topBarHeight() else 0; const rows = if (column != null) p.columnBarHeight() else p.topBarHeight(); const row: u16 = if (clamp) @min(mrow -| base, rows -| 1) else mrow -| base; const top: usize = if (focused) p.header_top else 0; const lines = modal.lineCount(text); const line: u16 = if (clamp) @intCast(@min(row + top, lines -| 1)) else @intCast(row + top); if (line > 0 and line >= lines) return null; const bar = modal.lineSlice(text, line); const scroll = if (focused) p.header_scroll else 0; const kind: Layer.Kind = if (column != null) .column else .workspace; const at = @min(bar.len, panes.File.rawAtDisplay(bar, (Layer.columnAt(p, kind, column orelse 0, p.pointer_tag_hit, clamp, row) orelse (mcol -| x)) + scroll)); return .{ .t = t, .text = text, .line = line, .at = modal.lineStartOffset(text, line) + at }; } /// A header sweep's button came up: a click runs the word under it, or the /// header's selection when it is on that; a sweep runs what it swept. fn headerSweepRun(p: *Pardes, sweep: HeaderSweep) void { const column = sweep.column(p) catch return; const t = if (column) |c| &p.col_tags[c] else &p.global_tag; const text = edit.flatSurface(p, t) catch return; const swept = sweep.span(text.len); const selected = if (swept.lo < swept.hi) text[swept.lo..swept.hi] else click: { const line = std.mem.count(u8, text[0..swept.lo], "\n"); const start = modal.lineStartOffset(text, line); const bar = modal.lineSlice(text, line); const at = swept.lo - start; const focused = p.header_focus and p.header_column == column; const on = if (focused) tagline.headerSelection(t, text, line) else null; break :click if (on) |sp| (if (at >= sp.lo and at < sp.hi) bar[sp.lo..sp.hi] else wordAtCol(bar, at)) else wordAtCol(bar, at); }; const word = p.scratch.allocator().dupe(u8, selected) catch return; tagline.exitHeader(p); // A column tag's command acts on that column, empty or not. p.exec_column = column; defer p.exec_column = null; p.exec_header = true; defer p.exec_header = false; if (word.len == 0) return; if (sweep.button == config.exec_button) { const named = std.meta.stringToEnum(Builtin, word); const held = if (named) |b| (if (builtins.registry.takesArg(b)) exec.heldSelection(p, p.active) else null) else exec.heldSelection(p, p.active); _ = exec.execute(p, p.active, exec.withArg(p, word, held)); } else exec.runBuiltin(p, config.look_cmd, p.active, "", word); } 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; } test "a pane's last row of text is no resize handle; the rule over the next pane is" { if (platform == .web) return; const p = try cornerFixture(std.testing.allocator); defer p.deinit(); const seam = seamRowOf(p, 1, 0); const inside = p.col_x[1] + p.col_w[1] / 2; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = inside, .row = seam } }); try std.testing.expect(p.drag != .border_h); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = inside, .row = seam } }); try std.testing.expect(nudgeRightSeam(p, 2) == seam + 2); } test "a pane's grip dragged up or down its own column moves its top, down to the floor of the pane above" { if (platform == .web) return; const p = try cornerFixture(std.testing.allocator); defer p.deinit(); const upper = p.col_panes[1][0]; const lower = p.col_panes[1][1]; const grip_x = p.rects[lower].x; // Up, past the pane above's first row: a text pane keeps its tag, a // terminal its tag and two rows (layout.floorRows). const top = p.rects[upper].y; const above = p.panes[upper].?; const floor: u16 = if (above.isTerminal()) above.tag_rows + layout.min_body_rows else 1; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = grip_x, .row = p.rects[lower].y } }); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = grip_x, .row = top } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = grip_x, .row = top } }); p.sync(); try std.testing.expectEqual(floor, p.rects[upper].h); try std.testing.expectEqual(lower, p.col_panes[1][1]); // Down, inside itself: its top follows, the pane above taking the rows. const to = p.rects[lower].y + 6; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = grip_x, .row = p.rects[lower].y } }); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = grip_x, .row = to } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = grip_x, .row = to } }); p.sync(); try std.testing.expectEqual(to, p.rects[lower].y); } 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); // On the rule a pixel frontend draws over the lower pane's first row. p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = inside, .row = from + 1, .rule_above = true } }); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = inside, .row = to + 1 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = inside, .row = to + 1 } }); 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| { const pane = slot orelse continue; const rect = p.rects[id]; const tag = p.tagTop(pane, rect); if (row >= tag and row < tag + pane.tag_rows and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER) return .{ .col = col, .row = row }; } 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| { const pane = slot orelse continue; const rect = p.rects[id]; const tag = p.tagTop(pane, rect); if (row >= tag and row < tag + pane.tag_rows and col >= rect.x and col < rect.x + rect.w) return .{ .col = col, .row = row }; } 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 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; } /// A wheel over a terminal whose program owns it (`terminal.ownsWheel`: /// codex, htop, less) goes to the program as the program asked, one turn /// per line, instead of scrolling a scrollback the alternate screen does /// not have. Shift keeps it pardes's, as xterm's Shift does. False when /// pardes scrolls. fn programWheel(p: *Pardes, id: usize, m: Mouse, col: u16, row: u16) bool { if (m.shift) return false; const pane = p.panes[id] orelse return false; if (!pane.isTerminal() or !panes.terminal.ownsWheel(pane)) return false; const r = p.rects[id]; const body_y = p.bodyTop(pane, r); const gcol = std.math.clamp(col -| (r.x + config.GUTTER), 0, pane.cols -| 1); const grow = std.math.clamp(row -| body_y, 0, pane.rows -| 1); var buf: [64]u8 = undefined; const wheel: panes.terminal.MouseButton = switch (m.button) { .wheel_up => .wheel_up, .wheel_down => .wheel_down, .wheel_left => .wheel_left, else => .wheel_right, }; const bytes = panes.terminal.wheelBytes(pane, &buf, wheel, gcol, grow, .{ .alt = m.alt, .ctrl = m.ctrl }) orelse return true; for (0..@intCast(config.wheel_rows)) |_| p.emitWrite(id, bytes); return true; } pub const Grab = struct { id: usize, serial: u32, held: u3 = 0 }; /// The 0-based cell of a terminal program's grid under screen cell /// `col`,`row`, or null over chrome (tag, gutter, scrollbar, anything past /// the grid). fn programCell(p: *const Pardes, id: usize, col: u16, row: u16) ?struct { col: i32, row: i32 } { const pane = p.panes[id] orelse return null; const r = p.rects[id]; const x0 = r.x + config.GUTTER; const y0 = p.bodyTop(pane, r); const h = @min(r.h -| pane.tag_rows, pane.rows); if (col < x0 or col >= r.x + r.w or col - x0 >= pane.cols or row < y0 or row - y0 >= h) return null; return .{ .col = col - x0, .row = row - y0 }; } /// A pane whose program tracks the mouse (1000, 1002, 1003, or X10's 9) /// and has the keyboard's raw input: its clicks are the program's. fn tracksMouse(pane: *const Pane) bool { return pane.isTerminal() and pane.body.mode == .tty and panes.terminal.reportsMouse(pane); } /// A program that tracks the mouse gets it over its grid, as xterm gives /// it: presses, releases, drags with a button down (1002) and, under 1003, /// every move, once per cell. A press it took keeps its drag and release /// wherever the pointer goes. Tags, gutters and the rest of pardes's /// chrome are never its, and Shift keeps pardes's own select, execute and /// look. True when the event was the program's. fn programMouse(p: *Pardes, m: Mouse) bool { const button: ?panes.terminal.MouseButton = switch (m.button) { .left => .left, .middle => .middle, .right => .right, .none => null, else => return false, }; const bit: u3 = if (button) |b| @as(u3, 1) << @intCast(@intFromEnum(b)) else 0; const at = p.presentation.pointer(p.screen_w, p.screen_h, @min(m.col, p.screen_w -| 1), @min(m.row, p.screen_h -| 1)); const mods: panes.terminal.MouseMods = .{ .shift = m.shift, .alt = m.alt, .ctrl = m.ctrl }; var buf: [64]u8 = undefined; if (p.mouse_grab) |*grab| { const pane = p.panes[grab.id] orelse { p.mouse_grab = null; return false; }; if (pane.serial != grab.serial or !pane.isTerminal()) { p.mouse_grab = null; return false; } // Off the grid the cell runs on past its edge, and the encoder // clamps it there. const r = p.rects[grab.id]; const col: i32 = if (at) |a| @as(i32, a.col) - @as(i32, r.x + config.GUTTER) else 0; const row: i32 = if (at) |a| @as(i32, a.row) - @as(i32, p.bodyTop(pane, r)) else 0; const action: panes.terminal.MouseAction = switch (m.kind) { .press => .press, .release => .release, .drag, .motion => .motion, }; if (m.kind == .press) grab.held |= bit; if (m.kind == .release) grab.held &= ~bit; const bytes = panes.terminal.mouseBytes(pane, &buf, action, button, mods, col, row, grab.held != 0); if (bytes.len > 0) p.emitWrite(grab.id, bytes); if (grab.held == 0) p.mouse_grab = null; return true; } if (m.shift or p.drag != .none or p.header_drag or p.header_sweep != null) return false; const mapped = at orelse return false; const id = paneAt(p, mapped.col, mapped.row) orelse return false; const pane = p.panes[id].?; if (!tracksMouse(pane)) return false; const cell = programCell(p, id, mapped.col, mapped.row) orelse return false; switch (m.kind) { .press => { if (id != p.active) if (p.panes[p.active]) |previous| { previous.focus = .body; previous.closePrompt(); }; p.active = id; pane.focus = .body; pane.closePrompt(); p.mouse_grab = .{ .id = id, .serial = pane.serial, .held = bit }; const bytes = panes.terminal.mouseBytes(pane, &buf, .press, button, mods, cell.col, cell.row, true); if (bytes.len > 0) p.emitWrite(id, bytes); return true; }, // A release or drag of a press pardes never passed on stays // pardes's. .release, .drag => return false, .motion => { const bytes = panes.terminal.mouseBytes(pane, &buf, .motion, null, mods, cell.col, cell.row, false); if (bytes.len > 0) p.emitWrite(id, bytes); // Under 1003 the move is the program's; below that pardes // hovers as ever. return panes.terminal.tracksAllMotion(pane); }, } } 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 or pane.prompt == .repl_choice) 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 == .select or p.header_drag or p.header_sweep != null)) { 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 == .select or p.header_drag or p.header_sweep != null)) { 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 (programMouse(p, m)) return; if (m.button == .none and m.kind == .motion) { p.raw_hover_intent = true; look.refreshLookHoverFromRaw(p); return; } p.raw_hover_intent = false; const mapped = p.presentation.pointer(p.screen_w, p.screen_h, raw_col, raw_row) orelse { look.cancelLookHover(p); if (m.kind == .release) { if (p.header_sweep) |sweep| if (sweep.button == m.button) { p.header_sweep = null; return headerSweepRun(p, sweep); }; 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 (p.header_sweep) |*sweep| { if (m.kind == .press and switch (m.button) { .left, .middle, .right => true, else => false, }) { // acme 2-3 / 3-2: the other of execute and look cancels the // sweep. The select button is inert: a header's command already // takes the held selection as its argument, which is what a 2-1 // chord would give it. if (m.button != config.select_button) p.header_sweep = null; return; } if (m.button == sweep.button and (m.kind == .drag or m.kind == .release)) { const column = sweep.column(p) catch { p.header_sweep = null; return; }; if (headerPoint(p, column, mcol, mrow, true)) |point| sweep.cur = point.at; if (m.kind == .release) { const done = sweep.*; p.header_sweep = null; headerSweepRun(p, done); } return; } } 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 t = tagline.focusedHeader(p) orelse { p.header_drag = false; return; }; const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; const text = edit.flatSurface(p, t) catch return; // `row` is the header's row on screen, `line` the line of its text // shown there. const base = if (p.header_column != null) p.topBarHeight() else 0; const row: u16 = @intCast(@min(mrow -| base, (if (p.header_column != null) p.columnBarHeight() else p.topBarHeight()) -| 1)); const line: u16 = @intCast(@min(row + p.header_top, modal.lineCount(text) -| 1)); const bar = modal.lineSlice(text, line); const at = @min(bar.len, panes.File.rawAtDisplay(bar, (Layer.columnAt(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag, row) orelse (mcol -| x)) + p.header_scroll)); // the anchor stays where the press put it t.cur_row = line; t.cur_col = @intCast(at); t.vsel.active = t.vsel.row != t.cur_row or t.vsel.col != t.cur_col; 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| { pane.focus = .body; pane.closePrompt(); }; 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; } const point = headerPoint(p, column, mcol, mrow, false) orelse return; if (m.button == config.select_button) { const t = point.t; const at = point.at - modal.lineStartOffset(point.text, point.line); // A clicked header is typed straight into, as a clicked tag is. if (!(p.header_focus and p.header_column == column)) tagline.enterHeader(p, column); t.mode = .insert; t.cur_row = point.line; t.cur_col = @intCast(at); t.vsel = .{ .active = false, .row = point.line, .col = @intCast(at) }; p.header_drag = true; } else { // Held, as a pane tag's sweep is: it runs when the button // comes up (headerSweepRun). p.header_sweep = .{ .serial = if (column) |c| p.col_serial[c] else null, .button = m.button, .anchor = point.at, .cur = point.at, }; } return; } if (m.kind == .press) { tagline.exitHeader(p); } 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 if (!programWheel(p, id, m, mcol, mrow)) 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 (programWheel(p, id, m, mcol, mrow)) { // the program's } else 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.body.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 (exec.heldSelection(p, 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 mpane = p.panes[mid].?; const mtag = p.tagTop(mpane, mr); if (mcol < mr.x + config.GUTTER and mrow >= mtag and mrow < mtag + mpane.tag_rows) { 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; } } // Two panes are resized on the rule between them, never on a // row of text; with no rule, by the grip, as acme moves a // window by its button. if (m.rule_above and mrow > 0) for (0..p.ncol) |cc| { if (mcol < p.col_x[cc] or mcol >= p.col_x[cc] + p.col_w[cc]) continue; const seam = if (p.settings.tag_bottom) mrow else mrow - 1; if (seamIdxAt(p, cc, seam)) |k| { p.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = seam } }; 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| { previous.focus = .body; previous.closePrompt(); }; const tag_y = p.tagTop(pane, r); const body_y = p.bodyTop(pane, 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 { const on_tag = mrow >= tag_y and mrow < tag_y + pane.tag_rows; if (on_tag and mcol < r.x + TAG_TEXT_INSET) return; p.active = id; p.noteTyped(); // acme's button 1 sets activecol (acme.c:659) pane.closePrompt(); 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; // A clicked tag is typed straight into, from where it was // clicked; `:` is the door to its normal mode. pane.focus = if (on_tag) .tag else .body; if (on_tag) { // a count typed before the click was meant for the body pane.body.normal.clear(); pane.tag.mode = .insert; pane.tag.normal.clear(); pane.tag.vsel.active = false; pane.tag.msel.active = false; pane.tag.nsel = 0; pane.tag.cur_pinned = true; const at = tagline.tagOffsetAt(p, pane, @intCast(@max(0, pointed.sel.r0)), @intCast(@max(0, pointed.sel.c0))) orelse modal.Cursor{ .row = 0, .col = 0 }; pane.tag.cur_row = @intCast(at.row); pane.tag.cur_col = @intCast(at.col); } p.drag = .{ .select = .{ .id = id, .button = config.select_button, .ctrl = m.ctrl, .pdf = panes.Pdf.pointerStart( p, pane, config.select_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, 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 = p.tagTop(pane, r); const body_y = p.bodyTop(pane, r); const on_tag = mrow >= tag_y and mrow < tag_y + pane.tag_rows; 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]]; // Held by the rule over the lower pane: the seam is the row above. const seam = if (p.settings.tag_bottom) mrow else mrow -| 1; d.cur_y = clampBorderRow(a.y, a.h, b.h, seam, 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); const tag_rows: i32 = pane.tag_rows; if (pane.body_rows > 0 and pane.sel[b].r0 >= tag_rows) 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) + tag_rows; return; }; // A sweep that began on the tag stays on it, on the tag line // nearest the pointer. const tag_y = p.tagTop(pane, r); const tag_line: u16 = @intCast(std.math.clamp(@as(i32, mrow) - @as(i32, tag_y), 0, tag_rows - 1)); if (pane.sel[b].r0 < tag_rows) if (Layer.columnAt(p, .pane, s.id, p.pointer_tag_hit, true, tag_line)) |value| { pane.sel[b].c1 = @as(i32, value) + pane.tag_scroll; pane.sel[b].r1 = tag_line; return; }; pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); // Sel row tag_rows 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(pane, r); const body_h = r.h -| pane.tag_rows; s.edge = if (s.button != config.select_button or pane.sel[b].r0 < tag_rows or pane.file == null or pane.hasPdf() or body_h == 0) 0 else if (mrow < body_y) -1 else if (mrow >= body_y + body_h or // The pointer cannot leave the screen: at a bottom pane, // its last row is the edge. (body_y + body_h >= p.screen_h and mrow + 1 >= body_y + body_h)) 1 else 0; pane.sel[b].r1 = if (pane.sel[b].r0 < tag_rows) tag_line else if (body_h == 0) 0 else @as(i32, @min(@max(mrow, body_y), body_y + body_h - 1)) - @as(i32, body_y) + tag_rows; if (pane.sel[b].r1 < tag_rows) pane.sel[b].c1 += @as(i32, pane.tag_scroll) - TAG_GAP; if (comptime pdf_enabled) panes.Pdf.pointerUpdate(&s.pdf, p, pane, mcol, mrow); } }, .none => {}, } } fn reportGesture( p: *Pardes, id: usize, cmd: Builtin, text: []const u8, on_tag: bool, operand: exec.PointerOperand, chord_arg: ?[]const u8, ) 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 = exec.namesBuiltin(text); const range = gestureRange(p, id, text, on_tag, operand); // No flag 2: the record already carries the word a click took, its // range and text, and in acme flag 2 promises a second record with them // (editors/acme/exec.c:154-183, look.c:42-68), which none follows here. var flag: u32 = if (named) ctlfs.events.flag_builtin else 0; if (is_look) { if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= ctlfs.events.flag_filename; } else if (chord_arg != null) flag |= ctlfs.events.flag_chorded; if (!ctlfs.events.noteAction(p, id, action, range.q0, range.q1, flag, text)) return false; // A chorded exec's argument follows as two records, its text and where // it came from (`file:#q0,#q1`, empty when that is not known), as acme // sends them (exec.c:182-188); the pane keeps it for the write-back. if (chord_arg) |a| { ctlfs.events.noteFollow(p, id, action, a); var where: [512]u8 = undefined; ctlfs.events.noteFollow(p, id, action, exec.heldSelectionPlace(p, id, &where) orelse ""); const pf = &scripted.fs; if (pf.chord_arg) |old| p.gpa.free(old); pf.chord_arg = p.gpa.dupe(u8, a) catch null; pf.chord_at = range; } return true; } fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: exec.PointerOperand) ctlfs.pane.State.Range { const pane = p.panes[id] orelse return .{}; if (on_tag) { const tag = tagline.tagText(p, p.scratch.allocator(), pane) catch return .{}; const shown = tagline.shownTag(p, p.scratch.allocator(), pane) catch return .{}; const sel = operand.expanded orelse operand.preview orelse return .{}; const lead = if (sel.r0 < sel.r1 or (sel.r0 == sel.r1 and sel.c0 <= sel.c1)) sel else Pane.Sel{ .r0 = sel.r1, .c0 = sel.c1 }; const row: usize = @as(usize, @intCast(@max(0, lead.r0))) + pane.tag_top; const start = modal.lineStartOffset(shown.text, row); const at = shown.toReal(start + panes.File.rawAtDisplay(modal.lineSlice(shown.text, row), @intCast(@max(0, lead.c0)))); 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 start in reading order, converted the way // `pointerOperand` converts the click itself. const lead = edit.sweepEnds(sel)[0]; const top = lead[0] - @as(i32, pane.tag_rows); if (top < 0) break :lead null; const w = pane.wrapAt(top); const col = edit.paneByteAtDisplay(p, &pane.body, w.line, w.at, lead[1] - 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: exec.PointerOperand }, ) void { const arg = p.chord_arg; p.chord_arg = null; defer if (arg) |a| p.gpa.free(a); // A click that takes no text does nothing, and tells an event reader // nothing, as acme's look3 and execute return on an empty expansion. const operand = text orelse return; if (gesture) |g| if (reportGesture(p, id, cmd, operand, g.on_tag, g.operand, arg)) return; // A middle click on a body is the exec a bound REPL may take. exec.runBuiltinFrom(p, cmd, id, "", exec.withArg(p, operand, arg), gesture != null and !gesture.?.on_tag); } 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 forward = gesture.r1 > gesture.r0 or (gesture.r1 == gesture.r0 and gesture.c1 >= gesture.c0); const endpoint = rows[if (forward) 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 (forward) modal.prevGrapheme(line, @min(endpoint.hi, line.len)) else endpoint.lo; pane.body.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.body.cur_row - overlay.row; if (relative >= 0 and relative < modal.lineCount(overlay.text)) shown = modal.lineSlice(overlay.text, @intCast(relative)); } pane.body.cur_col = @intCast(modal.graphemeStart(shown, @min(shown.len, byte -| endpoint.prompt_bytes))); pane.body.cur_pinned = true; pane.body.msel.active = false; pane.body.vsel.active = false; pane.body.nsel = 0; pane.body.select = false; pane.body.restore_cursor = false; pane.body.sticky_col = -1; pane.body.normal.clear(); edit.setYank(p, text); } /// acme's double-click (text.c:881 textselect, plan9port text.c:1407 /// textdoubleclick): a second plain B1 click at the same cell within half a /// second selects what edit.doubleClickRange says, from the caret the click /// left. A plain click still puts the caret on its cell. fn doubleClick(p: *Pardes, id: usize, t: *panes.Text, on_tag: bool, row: i32, col: i32) void { const now = clockMs(); const prev = p.last_click; p.last_click = .{ .id = id, .on_tag = on_tag, .row = row, .col = col, .ms = now }; const c = prev orelse return; if (c.id != id or c.on_tag != on_tag or c.row != row or c.col != col or now - c.ms > 500) return; p.last_click = null; const text = edit.flatSurface(p, t) catch return; const q = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }); const r = edit.doubleClickRange(text, q); if (r[1] <= r[0]) return; const a = modal.positionAt(text, r[0]); const h = modal.positionAt(text, modal.prevGrapheme(text, r[1])); t.vsel = .{ .active = true, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; t.cur_row = @intCast(h.row); t.cur_col = @intCast(h.col); t.cur_pinned = true; } /// On each tick while a B1 body sweep is held past an edge: the body /// scrolls a line toward it, the sweep's end stays under the pointer and /// its start stays on its text (Drag.select.anchor). pub fn edgeScroll(p: *Pardes) void { if (p.drag != .select) return; const s = &p.drag.select; if (s.edge == 0) return; const pane = p.panes[s.id] orelse return; const sl = &pane.sel[sel_slot]; if (s.anchor == null) s.anchor = sweepPoint(p, pane, sl.r0, sl.c0) orelse return; const before = pane.scroll(); pane.scrollBy(s.edge); // The start's row follows its text while it is on the screen, and // stays on the body's edge row once it is off (the anchor keeps it). const top: i32 = pane.tag_rows; sl.r0 = std.math.clamp(sl.r0 - (pane.scroll() - before), top, top + @as(i32, pane.rows) - 1); } /// The text position under a body cell of the sweep's space. fn sweepPoint(p: *Pardes, pane: *panes.Pane, r: i32, c: i32) ?modal.Cursor { const visible = r - @as(i32, pane.tag_rows); if (visible < 0) return null; const w = pane.wrapAt(visible); if (w.line < 0) return null; const col = edit.paneByteAtDisplay(p, &pane.body, w.line, w.at, c - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0)); return .{ .row = @intCast(w.line), .col = @intCast(@max(0, col)) }; } fn clockMs() i64 { if (comptime !pardes.hosted) return 0; var ts: std.c.timespec = undefined; if (std.c.clock_gettime(.MONOTONIC, &ts) != 0) return 0; return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000); } 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; defer p.chord_hold = false; 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); p.setActiveColumn(target); // a grip drag sets activecol (acme.c:640) }, .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 (layout.findPane(p, d.id)) |f| p.setActiveColumn(f.col); // BootShell replace: a file moved into the left column closes a // lone unused shell there if (p.panes[d.id]) |mt| if (mt.file != null) { if (layout.findPane(p, d.id)) |f| if (f.col == 0) exec.evictLonePristineTty(p, 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) { look.followPdfLink(p, 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 (sl.r0 < pane.tag_rows) { // On the tag the caret goes where the button came up. A // click on the path, if it can be renamed, drafts a name. const row: usize = @intCast(@max(0, sl.r1)); const col: usize = @intCast(@max(0, sl.c1)); if (!dragged and tagline.onName(pane, row, col)) { tagline.draftName(p, pane, col); } else if (tagline.tagOffsetAt(p, pane, row, col)) |cursor| { pane.tag.cur_row = @intCast(cursor.row); pane.tag.cur_col = @intCast(cursor.col); if (!dragged) doubleClick(p, s.id, &pane.tag, true, sl.r1, sl.c1); } return; } if (dragged and s.anchor == null) edit.capturePointerSelection(p, pane, sel_slot) catch {}; if (dragged and pane.body.mode == .tty) mirrorTtySelection(p, pane); const body_vis = sl.r1 - @as(i32, pane.tag_rows); if (body_vis >= 0 and pane.body.mode != .tty) { const w = pane.wrapAt(body_vis); pane.body.cur_row = w.line; pane.body.cur_col = edit.paneByteAtDisplay( p, &pane.body, w.line, w.at, sl.c1 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0), ); pane.body.cur_pinned = true; if (edit.clampFileCursor(&pane.body)) pane.ensureCursorVisible(); pane.body.msel.active = false; pane.body.normal.clear(); pane.body.vsel.active = false; pane.body.nsel = 0; // a click says WHERE the one cursor is pane.body.select = false; pane.body.restore_cursor = false; pane.body.sticky_col = -1; // A ctrl-click is a definition jump, not half a double-click. if (!dragged and !s.ctrl) doubleClick(p, s.id, &pane.body, false, sl.r1, sl.c1); if (s.ctrl) p.last_click = null; // A sweep that scrolled has its start off the screen: it // is kept as the text's own selection, start to end. if (dragged) if (s.anchor) |a| { pane.sel[sel_slot].state = .none; pane.body.vsel = .{ .active = true, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; }; 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 >= pane.tag_rows and clk.r0 == clk.r1 and clk.c0 == clk.c1 and clk.c0 >= 0 and pane.body.mode != .tty) if (pane.file) |f| if (f.output == null and clk.c0 < panes.File.gutterWidth(pane)) { const line = pane.wrapAt(clk.r0 - pane.tag_rows).line; if (line >= 0) { pane.sel[b].state = .none; var buf: [48]u8 = undefined; const target = std.fmt.bufPrint(&buf, "@p{d}:{d}", .{ pane.serial, @as(usize, @intCast(line)) + 1 }) catch return; exec.runBuiltin(p, config.look_cmd, s.id, "", target); return; } }; var operand = exec.pointerOperand(p, 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 >= pane.tag_rows and pane.body.mode != .tty) { pane.body.cur_row = operand.row; pane.body.cur_col = operand.col; if (edit.clampFileCursor(&pane.body)) pane.ensureCursorVisible(); } const txt = operand.text; const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd; if (clk.r0 == 0 and s.button == config.exec_button and pane.prompt == .name) { const prefix_width = panes.File.displayWidth(tagline.tagPrefix(p, pane) catch return); const on_name = @as(usize, @intCast(@max(0, clk.c0))) < prefix_width; if (!tagline.commitTagName(p, 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(tagline.tagPrefix(p, 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 < pane.tag_rows, .operand = operand, }); } }, .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 (@min(pane.sel[sel_slot].r0, pane.sel[sel_slot].r1) < pane.tag_rows) { // A sweep over the tag cuts from and pastes into the tag's own text; // the part of it over the prefix is not the tag's to cut, so the // sweep starts after it. const t = &pane.tag; if (!s.chorded) { s.chorded = true; const sl = pane.sel[sel_slot]; pane.sel[sel_slot].state = .none; const forward = sl.r0 < sl.r1 or (sl.r0 == sl.r1 and sl.c0 <= sl.c1); const start = if (forward) .{ sl.r0, sl.c0 } else .{ sl.r1, sl.c1 }; const end = if (forward) .{ sl.r1, sl.c1 } else .{ sl.r0, sl.c0 }; const lead = pane.tag_lead; const origin: modal.Cursor = .{ .row = 0, .col = lead }; var from = tagline.tagOffsetAt(p, pane, @intCast(@max(0, start[0])), @intCast(@max(0, start[1]))) orelse origin; var to = tagline.tagOffsetAt(p, pane, @intCast(@max(0, end[0])), @intCast(@max(0, end[1]))) orelse origin; if (from.row == 0) from.col = @max(from.col, lead); if (to.row == 0) to.col = @max(to.col, lead); t.cur_row = @intCast(to.row); t.cur_col = @intCast(to.col); t.cur_pinned = true; t.vsel = .{ .active = from.row != to.row or from.col != to.col, .row = @intCast(from.row), .col = @intCast(from.col), .explicit = false }; t.msel.active = false; t.nsel = 0; } if (cut) { if (t.vsel.active) edit.normalDelete(p, t, true); } else if (t.vsel.active) { edit.chordReplace(p, t); } else edit.pasteText(p, t, p.registers.text(p.gpa, pardes.Registers.default) orelse return, true, 1); return; } if (pane.body.mode == .tty) { if (!s.chorded) { s.chorded = true; pane.sel[sel_slot].state = .none; // drop the sweep highlight } if (cut) return; const y = p.registers.text(p.gpa, pardes.Registers.default) orelse return; if (y.len == 0) return; if (panes.terminal.reportsMouse(pane)) { // A click where the text goes, so the program's cursor is there. const col = @max(0, pane.sel[sel_slot].c1); const row = @max(0, pane.sel[sel_slot].r1 - @as(i32, pane.tag_rows)); var mb: [64]u8 = undefined; for ([_]panes.terminal.MouseAction{ .press, .release }) |action| { const bytes = panes.terminal.mouseBytes(pane, &mb, action, .left, .{}, col, row, action == .press); if (bytes.len > 0) p.emitWrite(s.id, bytes); } } edit.typeToTty(p, s.id, pane, y); return; } if (!s.chorded) { s.chorded = true; // Everything done while B1 stays down is one undo step, as acme // marks the file once per B1 hold (text.c:881 textselect, `state` // and its one filemark): B1-B2 then B1-B3 is a copy. edit.pushUndo(p, &pane.body); p.chord_hold = 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, pane.tag_rows))); const w1 = pane.wrapAt(@max(0, sl.r1 - @as(i32, pane.tag_rows))); const row0 = w0.line; const row1 = w1.line; const col0 = edit.paneByteAtDisplay(p, &pane.body, w0.line, w0.at, sl.c0 - pfx); const col1 = edit.paneByteAtDisplay(p, &pane.body, w1.line, w1.at, sl.c1 - pfx); pane.body.cur_row = row1; pane.body.cur_col = col1; pane.body.cur_pinned = true; pane.body.msel.active = false; pane.body.normal.clear(); if (!pane.isTerminal()) pane.body.mode = .normal; pane.body.vsel = .{ .active = row0 != row1 or col0 != col1, .row = row0, .col = col0, .explicit = false }; pane.body.nsel = 0; } if (cut) { if (pane.body.vsel.active) edit.normalDelete(p, &pane.body, true); } else if (pane.body.vsel.active) { edit.chordReplace(p, &pane.body); } 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.registers.text(p.gpa, pardes.Registers.default) orelse return; if (y.len == 0) return; const at = modal.Cursor{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) }; edit.pushUndo(p, &pane.body); const new = modal.insertAt(p.gpa, f.content, at, y) catch return; panes.File.setContent(p, f, new); pane.body.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.body.cur_row = @intCast(end.row); pane.body.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.body.cur_row = @intCast(end.row -| 1); pane.body.cur_col = @intCast(modal.lineSlice(f.content, end.row -| 1).len); } pane.body.cur_pinned = true; pane.body.sticky_col = -1; pane.ensureCursorVisible(); } else edit.pasteText(p, &pane.body, p.registers.text(p.gpa, pardes.Registers.default) 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.body.cur_row = 0; pane.body.cur_col = 5; pane.body.vsel = .{ .active = true, .row = 0, .col = 1, .explicit = true }; pane.body.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.body.cur_col); try std.testing.expect(!pane.body.vsel.active); try std.testing.expect(!pane.body.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: animation.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(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(Presentation.CellPosition{ .col = 100, .row = 10 }, p.presentation.pointer(p.screen_w, p.screen_h, 100, 10).?); p.acknowledgePanelPresentation(&.{animation.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 = [_]animation.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(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]animation.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: animation.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 ([_]animation.Transition{ .dissolve, .ascii }) |effect| { const frame: u16 = if (effect == .dissolve) 3 else 5; const track: animation.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 => animation.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(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: animation.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 }; for ([_]animation.Transition{ .dissolve, .ascii }) |effect| { p.acknowledgePanelPresentation(&.{.{ .serial = pane.serial, .pane = 0, .phase = .opening, .effect = effect, .from = box, .to = box, }}); try std.testing.expectEqual(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: animation.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.body.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.body.vsel.active); try std.testing.expectEqual(.none, p.drag); } test "a chorded exec an event reader holds is told with its argument, and written back runs with it" { const th = @import("ninep/testing.zig"); const p = try th.withFile(std.testing.allocator, "Msg said\narg-text\n"); defer p.deinit(); const serial = p.panes[0].?.serial; const event = ctlfs.Node.of(serial, .event); const h = th.call(p, .{ .tag = 1, .op = .open, .node = event, .omode = 2 }).reply.handle; // The argument: a selection held in the pane (its second line). ctlfs.pane.setDot(p.panes[0].?, .{ .q0 = 9, .q1 = 17 }); p.chord_arg = try p.gpa.dupe(u8, "arg-text"); dispatchPointerBuiltin(p, 0, config.exec_cmd, "Msg", .{ .on_tag = false, .operand = .{ .row = 0, .col = 1, .text = "Msg" } }); var records: [3][]const u8 = undefined; var got: [3][64]u8 = undefined; for (&records, &got) |*r, *g| { const bytes = th.call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 64 }).bytes; @memcpy(g[0..bytes.len], bytes); r.* = g[0..bytes.len]; } try std.testing.expect(std.mem.endsWith(u8, records[0], " 9 3 Msg\n")); // builtin|chorded, no expansion record try std.testing.expectEqualStrings("X0 0 0 8 arg-text\n", records[1][1..]); try std.testing.expect(std.mem.endsWith(u8, records[2], "test.txt:#9,#17\n")); // Written back whole, it runs with its argument. var back: [64]u8 = undefined; const line = try std.fmt.bufPrint(&back, "M{s}", .{records[0][1..]}); try std.testing.expectEqual(ctlfs.Status.ok, th.call(p, .{ .tag = 3, .op = .write, .node = event, .handle = h, .data = line }).reply.status); const pane = p.panes[0].?; try std.testing.expectEqualStrings("arg-text", pane.msg[0..pane.msg_len]); // All three records written back as read, in one write or in three: // the two after the first are its argument and where it came from, // never commands of their own. for ([_]bool{ true, false }) |together| { pane.msg_len = 0; var all: [256]u8 = undefined; var w = std.Io.Writer.fixed(&all); for (records) |r| try w.print("M{s}", .{r[1..]}); if (together) { _ = th.call(p, .{ .tag = 5, .op = .write, .node = event, .handle = h, .data = w.buffered() }); } else for (records) |r| { var one: [80]u8 = undefined; _ = th.call(p, .{ .tag = 6, .op = .write, .node = event, .handle = h, .data = try std.fmt.bufPrint(&one, "M{s}", .{r[1..]}) }); } try std.testing.expectEqualStrings("arg-text", pane.msg[0..pane.msg_len]); try std.testing.expect(!th.logHas(p, "run ")); } _ = th.call(p, .{ .tag = 4, .op = .release, .node = event, .handle = h }); } /// Every byte the core wrote to pane 0's pty since the last call. fn ptyWrites(p: *Pardes, out: *std.ArrayList(u8)) ![]const u8 { out.clearRetainingCapacity(); while (p.nextEffect()) |effect| { if (effect == .write and effect.write.pane == 0) try out.appendSlice(p.gpa, effect.write.bytes.slice()); } return out.items; } test "a wheel over a program that owns it is the program's, in the form it asked for; a shell's scrolls" { if (comptime !panes.terminal.enabled) return error.SkipZigTest; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 16, .tty_only = true }); defer p.deinit(); var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); var out: std.ArrayList(u8) = .empty; defer out.deinit(gpa); _ = try ptyWrites(p, &out); const pane = p.panes[0].?; const r = p.rects[0]; // Grid cell 6,4 (1-based), as a program counts. const col = r.x + config.GUTTER + 5; const row = p.bodyTop(pane, r) + 3; const Case = struct { modes: []const u8, button: Mouse.Button, ctrl: bool = false, alt: bool = false, shift: bool = false, sent: []const u8 }; const cases = [_]Case{ // codex's chat (0.158+): the alternate screen, any-event tracking, SGR. .{ .modes = "\x1b[?2004h\x1b[>7u\x1b[?1004h\x1b[?1049h\x1b[?1007l\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h", .button = .wheel_up, .sent = "\x1b[<64;6;4M" }, .{ .modes = "", .button = .wheel_down, .ctrl = true, .sent = "\x1b[<81;6;4M" }, // Meta is 8, both 24; Shift keeps the wheel pardes's, which has // nothing to scroll on the alternate screen. .{ .modes = "", .button = .wheel_up, .alt = true, .sent = "\x1b[<72;6;4M" }, .{ .modes = "", .button = .wheel_up, .alt = true, .ctrl = true, .sent = "\x1b[<88;6;4M" }, .{ .modes = "", .button = .wheel_up, .shift = true, .sent = "" }, // htop's legacy bytes (1000 alone), and 1015's. .{ .modes = "\x1b[?1006l", .button = .wheel_down, .sent = "\x1b[M\x61\x26\x24" }, .{ .modes = "\x1b[?1015h", .button = .wheel_up, .sent = "\x1b[96;6;4M" }, // codex's sign-in, less: the alternate screen asking for arrow keys // (1007), in the cursor keys' mode. .{ .modes = "\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1007h", .button = .wheel_down, .sent = "\x1b[B" }, .{ .modes = "\x1b[?1h", .button = .wheel_up, .sent = "\x1bOA" }, .{ .modes = "", .button = .wheel_right, .sent = "" }, // With alternate scroll off the alternate screen keeps the wheel. .{ .modes = "\x1b[?1007l", .button = .wheel_up, .sent = "" }, }; for (cases) |case| { p.update(.{ .output = .{ .pane = 0, .bytes = case.modes } }); _ = try ptyWrites(p, &out); p.update(.{ .mouse = .{ .button = case.button, .kind = .press, .col = col, .row = row, .ctrl = case.ctrl, .alt = case.alt, .shift = case.shift } }); try std.testing.expectEqualStrings(case.sent, try ptyWrites(p, &out)); } // A shell on the main screen: the wheel scrolls its scrollback, and // the shell hears nothing. p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1l\x1b[?1049l" } }); for (0..40) |i| { var line: [16]u8 = undefined; p.update(.{ .output = .{ .pane = 0, .bytes = try std.fmt.bufPrint(&line, "{d}\r\n", .{i}) } }); } _ = try p.render(frame.allocator()); _ = try ptyWrites(p, &out); const was = pane.scroll(); p.update(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = col, .row = row } }); try std.testing.expectEqualStrings("", try ptyWrites(p, &out)); try std.testing.expect(pane.scroll() != was); // A program tracking the mouse on the main screen: Shift and the // wheel scroll the scrollback, the program hears nothing. p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1000h\x1b[?1006h" } }); _ = try ptyWrites(p, &out); const tracked = pane.scroll(); p.update(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = col, .row = row, .shift = true } }); try std.testing.expectEqualStrings("", try ptyWrites(p, &out)); try std.testing.expect(pane.scroll() != tracked); p.update(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = col, .row = row } }); try std.testing.expectEqualStrings("\x1b[<64;6;4M", try ptyWrites(p, &out)); } test "a program tracking the mouse gets its clicks, drags and moves over its grid as xterm reports them; Shift and chrome stay pardes's" { if (comptime !panes.terminal.enabled) return error.SkipZigTest; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 16, .tty_only = true }); defer p.deinit(); var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); var out: std.ArrayList(u8) = .empty; defer out.deinit(gpa); _ = try ptyWrites(p, &out); const pane = p.panes[0].?; try std.testing.expectEqual(.tty, pane.body.mode); const r = p.rects[0]; const col = r.x + config.GUTTER + 5; const row = p.bodyTop(pane, r) + 3; const Ev = struct { button: Mouse.Button, kind: Mouse.Kind, dx: i32 = 0, dy: i32 = 0, shift: bool = false, alt: bool = false, ctrl: bool = false }; const send = struct { fn f(pp: *Pardes, o: *std.ArrayList(u8), c: u16, rw: u16, e: Ev) ![]const u8 { pp.update(.{ .mouse = .{ .button = e.button, .kind = e.kind, .col = @intCast(@as(i32, c) + e.dx), .row = @intCast(@as(i32, rw) + e.dy), .shift = e.shift, .alt = e.alt, .ctrl = e.ctrl, } }); return ptyWrites(pp, o); } }.f; // htop's modes: clicks in SGR, a release its own `m`. p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049h\x1b[?1h\x1b[?1006;1000h" } }); _ = try ptyWrites(p, &out); try std.testing.expectEqualStrings("\x1b[<0;6;4M", try send(p, &out, col, row, .{ .button = .left, .kind = .press })); try std.testing.expect(p.drag == .none); // 1000 has no drags; the release is reported where it lands. try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .drag, .dx = 1 })); try std.testing.expectEqualStrings("\x1b[<0;7;4m", try send(p, &out, col, row, .{ .button = .left, .kind = .release, .dx = 1 })); // Right with meta and ctrl: 2 + 8 + 16. try std.testing.expectEqualStrings("\x1b[<26;6;4M", try send(p, &out, col, row, .{ .button = .right, .kind = .press, .alt = true, .ctrl = true })); try std.testing.expectEqualStrings("\x1b[<2;6;4m", try send(p, &out, col, row, .{ .button = .right, .kind = .release })); // Moves are no 1000 program's business: pardes hovers. try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .none, .kind = .motion })); // Shift: pardes's own sweep, the program hears nothing of it. try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .press, .shift = true })); try std.testing.expect(p.drag == .select); try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .drag, .dx = 2 })); try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .release, .dx = 2 })); try std.testing.expect(p.drag == .none); // Chrome is never the program's: the tag, and the gutter. const tag_row = p.tagTop(pane, r); try std.testing.expectEqualStrings("", try send(p, &out, col, tag_row, .{ .button = .right, .kind = .press })); try std.testing.expectEqualStrings("", try send(p, &out, col, tag_row, .{ .button = .right, .kind = .release })); try std.testing.expectEqualStrings("", try send(p, &out, r.x, row, .{ .button = .left, .kind = .press })); try std.testing.expectEqualStrings("", try send(p, &out, r.x, row, .{ .button = .left, .kind = .release })); // codex's any-event tracking: a drag once per cell, carried past the // grid's edge and clamped there, and bare moves. p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1003h" } }); _ = try ptyWrites(p, &out); try std.testing.expectEqualStrings("\x1b[<35;9;4M", try send(p, &out, col, row, .{ .button = .none, .kind = .motion, .dx = 3 })); try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .none, .kind = .motion, .dx = 3 })); try std.testing.expectEqualStrings("\x1b[<0;6;4M", try send(p, &out, col, row, .{ .button = .left, .kind = .press })); try std.testing.expectEqualStrings("\x1b[<32;7;4M", try send(p, &out, col, row, .{ .button = .left, .kind = .drag, .dx = 1 })); try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .drag, .dx = 1 })); // Up over the tag: row 1, the grid's first. try std.testing.expectEqualStrings("\x1b[<32;7;1M", try send(p, &out, col + 1, tag_row, .{ .button = .left, .kind = .drag })); try std.testing.expectEqualStrings("\x1b[<0;7;1m", try send(p, &out, col + 1, tag_row, .{ .button = .left, .kind = .release })); try std.testing.expect(p.mouse_grab == null); // Over the tag a bare move is pardes's. try std.testing.expectEqualStrings("", try send(p, &out, col, tag_row, .{ .button = .none, .kind = .motion })); // Legacy X10 bytes: a release is button 3. (Turning 1003 off turns // tracking off, as xterm's one tracking mode goes.) p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1003l\x1b[?1006l\x1b[?1000h" } }); _ = try ptyWrites(p, &out); try std.testing.expectEqualStrings("\x1b[M &$", try send(p, &out, col, row, .{ .button = .left, .kind = .press })); try std.testing.expectEqualStrings("\x1b[M#&$", try send(p, &out, col, row, .{ .button = .left, .kind = .release })); // A pane out of the program's raw input (Esc to normal mode) clicks as // pardes clicks. pane.body.mode = .normal; try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .press })); try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .release })); }