From 232bfbbc4371a1fe9df210874118b4450dce7f9d Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 00:30:46 -0300 Subject: Let a pane's tag take a row for each line it holds A tag that is a text can hold a newline, and the tagline, the body under it and the pointer all assumed one row. The pane's tag now takes a row per line (up to eight, and never the body's last row); the body starts below it, the rows a pointer counts start the body after the tag's lines, a click on any line puts the tag's cursor there, and a pixel shell gets a tag layer per line. A tag of one line draws exactly as before. Co-Authored-By: Claude Opus 5.5 --- src/mouse.zig | 127 +++++++++++++++++++++++++++++++++------------------------- 1 file changed, 72 insertions(+), 55 deletions(-) (limited to 'src/mouse.zig') diff --git a/src/mouse.zig b/src/mouse.zig index 343051b0..33646090 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -103,11 +103,11 @@ 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 pane = slot orelse 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 }; + 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; @@ -122,11 +122,11 @@ pub fn chromeTarget(p: *const Pardes, col: u16, row: u16) ?ChromeTarget { } } for (p.panes, 0..) |slot, id| { - if (slot == null) continue; + const pane = slot orelse 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 }; + 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; @@ -182,11 +182,13 @@ pub fn sameTagCell(a: ?TagHit, b: ?TagHit) bool { 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 { +/// The tag column a host's pointer is at. Clamped, a point off the tag is +/// brought back onto line `line` of it, for a drag that leaves it. +pub fn tagColumn(p: *const Pardes, kind: TagKind, id: usize, supplied: ?TagHit, clamp: bool, line: u16) ?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; + if (layer.cols == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial or layer.line != line) 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; @@ -298,7 +300,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { }; const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; const bar = modal.lineSlice(edit.flatSurface(p, t) catch return, 0); - const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tagColumn(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + p.header_scroll)); + const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tagColumn(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag, 0) orelse (mcol -| x)) + p.header_scroll)); // the anchor stays where the press put it t.cur_row = 0; t.cur_col = @intCast(at); @@ -330,7 +332,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { const focused = p.header_focus and p.header_column == column; const bar = modal.lineSlice(edit.flatSurface(p, t) catch return, 0); const scroll = if (focused) p.header_scroll else 0; - const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tagColumn(p, if (column != null) .column else .workspace, column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + scroll)); + const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tagColumn(p, if (column != null) .column else .workspace, column orelse 0, p.pointer_tag_hit, p.header_drag, 0) orelse (mcol -| x)) + scroll)); if (m.button == config.select_button) { // A clicked header is typed straight into, as a clicked tag is. if (!focused) p.enterHeader(column); @@ -417,8 +419,9 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { } 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) { + 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; } @@ -449,14 +452,14 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { previous.focus = .body; previous.closePrompt(); }; - const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; - const body_y = p.bodyTop(r); + 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 + BOX_H; + 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; pane.closePrompt(); @@ -475,8 +478,9 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { pane.tag.msel.active = false; pane.tag.nsel = 0; pane.tag.cur_pinned = true; - pane.tag.cur_row = 0; - pane.tag.cur_col = @intCast(p.tagOffsetAt(pane, @intCast(@max(0, pointed.sel.c0))) orelse 0); + const at = p.tagOffsetAt(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, @@ -524,9 +528,9 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { 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; + 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; @@ -605,30 +609,37 @@ pub fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) vo 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 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) + BOX_H; + pane.sel[b].r1 = @as(i32, mapped_hit.row) + tag_rows; return; }; - if (pane.sel[b].r0 < BOX_H) if (tagColumn(p, .pane, s.id, p.pointer_tag_hit, true)) |value| { + // 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 (tagColumn(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 = 0; + pane.sel[b].r1 = tag_line; 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 + // 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(r); - const body_h = r.h -| BOX_H; - pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0) + const body_y = p.bodyTop(pane, r); + const body_h = r.h -| pane.tag_rows; + 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) + @as(i32, BOX_H); - if (pane.sel[b].r1 < BOX_H) pane.sel[b].c1 += @as(i32, pane.tag_scroll) - TAG_GAP; + @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); } @@ -668,8 +679,10 @@ fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: 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 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 = @intCast(@max(0, lead.r0)); + const start = modal.lineStartOffset(tag, row); + const at = start + panes.File.rawAtDisplay(modal.lineSlice(tag, 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)) }; } @@ -692,7 +705,7 @@ fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: 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); + const top = @min(sel.r0, sel.r1) - @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, @min(sel.c0, sel.c1) - panes.File.gutterWidth(pane)); @@ -825,19 +838,19 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { 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 < BOX_H) { + 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 at = p.tagOffsetAt(pane, @intCast(@max(0, sl.c1))); - if (at) |col| { - pane.tag.cur_row = 0; - pane.tag.cur_col = @intCast(col); + const at = p.tagOffsetAt(pane, @intCast(@max(0, sl.r1)), @intCast(@max(0, sl.c1))); + if (at) |cursor| { + pane.tag.cur_row = @intCast(cursor.row); + pane.tag.cur_col = @intCast(cursor.col); } else if (!dragged) p.draftName(pane, @intCast(@max(0, sl.c1))); return; } if (dragged) edit.capturePointerSelection(p, pane, sel_slot) catch {}; if (dragged and pane.body.mode == .tty) mirrorTtySelection(p, pane); - const body_vis = sl.r1 - @as(i32, BOX_H); + 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; @@ -864,10 +877,10 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { // 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 + 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 - BOX_H).line; + const line = pane.wrapAt(clk.r0 - pane.tag_rows).line; if (line >= 0) { pane.sel[b].state = .none; var buf: [48]u8 = undefined; @@ -881,14 +894,14 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { 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.body.mode != .tty) { + 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 < BOX_H and s.button == config.exec_button and pane.prompt == .name) { + if (clk.r0 == 0 and s.button == config.exec_button and pane.prompt == .name) { 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; @@ -909,7 +922,7 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { pane.sel[b].c1 += shift; } dispatchPointerBuiltin(p, s.id, cmd, txt, .{ - .on_tag = clk.r0 < BOX_H, + .on_tag = clk.r0 < pane.tag_rows, .operand = operand, }); } @@ -931,7 +944,7 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { 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) < BOX_H) { + 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. const t = &pane.tag; @@ -939,12 +952,16 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { s.chorded = true; const sl = pane.sel[sel_slot]; pane.sel[sel_slot].state = .none; - const from = p.tagOffsetAt(pane, @intCast(@max(0, @min(sl.c0, sl.c1)))) orelse 0; - const to = p.tagOffsetAt(pane, @intCast(@max(0, @max(sl.c0, sl.c1)))) orelse 0; - t.cur_row = 0; - t.cur_col = @intCast(to); + 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 origin: modal.Cursor = .{ .row = 0, .col = 0 }; + const from = p.tagOffsetAt(pane, @intCast(@max(0, start[0])), @intCast(@max(0, start[1]))) orelse origin; + const to = p.tagOffsetAt(pane, @intCast(@max(0, end[0])), @intCast(@max(0, end[1]))) orelse origin; + t.cur_row = @intCast(to.row); + t.cur_col = @intCast(to.col); t.cur_pinned = true; - t.vsel = .{ .active = from != to, .row = 0, .col = @intCast(from), .explicit = false }; + 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; } @@ -965,7 +982,7 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { 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)); + const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, pane.tag_rows) + 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); @@ -986,8 +1003,8 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { 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 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); -- cgit v1.3